diff --git a/gui/.gitignore b/gui/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/gui/.gitignore @@ -0,0 +1 @@ +/target diff --git a/gui/Cargo.toml b/gui/Cargo.toml new file mode 100644 index 0000000..cccb35f --- /dev/null +++ b/gui/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "gui" +version = "0.1.0" +edition = "2021" + +[dependencies] +embedded-graphics = "0.8.1" +embedded-layout = "0.4.1" +profont = "0.7.0" diff --git a/gui/src/lib.rs b/gui/src/lib.rs new file mode 100644 index 0000000..88ee475 --- /dev/null +++ b/gui/src/lib.rs @@ -0,0 +1,158 @@ +#![no_std] + +use embedded_graphics::{pixelcolor::Rgb565, prelude::*}; + +mod widgets; + +use core::cmp::PartialEq; +use core::result::Result::{self, Ok}; + +pub type Color = Rgb565; + +/// Enum representing the valid actions that can be performed in the GUI. +/// Each variant corresponds to a user input action that the interface can respond to. +#[derive(PartialEq, Clone, Copy)] +pub enum GuiAction { + Up, + Down, + Left, + Right, + Select, + Back, +} + +/// Enum representing all available views in the GUI. +/// Each variant corresponds to a different screen or state within the application. +mod views; +pub enum GuiView { + MainMenu(views::main_menu::State), + Scan, + Settings, +} + +impl GuiView { + /// Draws the current view to the provided display target. + /// This method maps each individual view's drawing function to a common interface, + /// allowing for a unified way to render different views. + /// + /// # Parameters + /// - `display`: A mutable reference to the display target where the view will be drawn. + /// + /// # Returns + /// - `Result<(), D::Error>`: Returns Ok if drawing was successful, or an error if it failed. + pub fn draw(&self, display: &mut D) -> Result<(), D::Error> + where + D: DrawTarget, + { + match self { + Self::MainMenu(state) => views::main_menu::draw(state, display)?, + Self::Scan => views::scan::draw(display)?, + Self::Settings => views::settings::draw(display)?, + } + + Ok(()) + } + + /// Handles user actions based on the current view. + /// This method maps individual action handling functions to a common interface, + /// allowing the GUI to respond to user inputs appropriately depending on the current view. + /// + /// # Parameters + /// - `action`: The action that has occurred, represented by the `GuiAction` enum. + /// + /// # Returns + /// - `Self`: Returns the updated view after processing the action. + /// The state may change based on the action taken, particularly in the MainMenu. + pub fn action(self, action: GuiAction) -> Self { + match self { + Self::MainMenu(state) => views::main_menu::action(state, action), + Self::Scan => Self::Scan, + Self::Settings => Self::Settings, + } + } +} + +/// A struct representing the graphical user interface (GUI). +/// It holds the current display target and the active view of the GUI. +pub struct Gui +where + // The display must implement the DrawTarget trait with a specified Color type + D: DrawTarget, +{ + /// The display target where the GUI will be rendered + display: D, + /// The current view of the GUI, represented by the GuiView enum + view: GuiView, +} + +impl Gui +where + D: DrawTarget, +{ + /// Creates a new instance of the GUI with the specified display target. + /// Initializes the GUI to start in the Main Menu view with the first item selected. + /// + /// # Parameters + /// - `display`: The display target to be used for rendering the GUI. + /// + /// # Returns + /// - `Self`: A new instance of the Gui struct. + pub fn new(display: D) -> Self { + Self { + view: GuiView::MainMenu(views::main_menu::State { selected: 0 }), // Init on main menu + display, + } + } + + /// Processes a user action and updates the GUI state accordingly. + /// This method delegates the action handling to the current view's action method. + /// + /// # Parameters + /// - `gui_action`: The action that has occurred, represented by the GuiAction enum. + /// + /// # Returns + /// - `Self`: A new instance of the Gui struct with the updated view. + pub fn action(self, gui_action: GuiAction) -> Self { + // Get the resulting view after the action has been performed + let new_view = self.view.action(gui_action); + + // Create a new GUI with the new view, retaining the display + Self { + display: self.display, // Move display to new GUI struct + view: new_view, // Set the new view + } + } + + /// Fills the entire display with a solid black color. + /// This can be used to clear the screen before drawing the next frame. + /// + /// # Returns + /// - `Result<(), D::Error>`: Returns Ok if the fill operation was successful, or an error if it failed. + pub fn fill_black(&mut self) -> Result<(), D::Error> { + let display: &mut D = &mut self.display; + display.fill_solid(&display.bounding_box(), Color::BLACK)?; + + Ok(()) + } + + /// Draws the current view of the GUI onto the display. + /// This method calls the draw method of the current view, rendering it to the display target. + /// + /// # Returns + /// - `Result<(), D::Error>`: Returns Ok if the drawing operation was successful, or an error if it failed. + pub fn draw(&mut self) -> Result<(), D::Error> { + let display: &mut D = &mut self.display; // Get a mutable reference to the display + self.view.draw(display)?; // Draw the view + + Ok(()) + } + + /// Returns a reference to the display target. + /// This can be useful for accessing the display outside of the GUI struct. + /// + /// # Returns + /// - `&D`: A reference to the display target. + pub fn display_ref(&self) -> &D { + &self.display // Return a reference to the display + } +} diff --git a/gui/src/views/main_menu.rs b/gui/src/views/main_menu.rs new file mode 100644 index 0000000..de8c081 --- /dev/null +++ b/gui/src/views/main_menu.rs @@ -0,0 +1,72 @@ +use crate::widgets::{button::Button, header::Header}; +use crate::{Color, GuiAction, GuiView}; +use core::result::Result::{self, Ok}; +use embedded_graphics::prelude::*; +use embedded_layout::{ + layout::linear::{FixedMargin, LinearLayout}, + prelude::*, +}; + +fn create_button(button_index: u8, selected_index: u8, text: &str) -> Button { + let button_width = 240; + let button_height = 60; + + if button_index == selected_index { + Button::new(Point::zero(), Size::new(button_width, button_height), text).toggle_highlight() + } else { + Button::new(Point::zero(), Size::new(button_width, button_height), text) + } +} + +pub fn draw(state: &State, display: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + // Create the header + let header = Header::new("Main Menu"); + header.draw(display)?; + + // Create the buttons + let scan_button = create_button(0, state.selected, "Scan"); + let settings_button = create_button(1, state.selected, "Settings"); + + // Draw the buttons in a vertical layout + LinearLayout::vertical(Chain::new(scan_button).append(settings_button)) + .with_spacing(FixedMargin(10)) + .arrange() + .align_to( + &display.bounding_box(), + horizontal::Center, + vertical::Center, + ) + .draw(display)?; + + Ok(()) +} + +#[derive(Debug)] +pub struct State { + /// Which button is currently highlighted + pub selected: u8, +} + +pub fn action(state: State, action: GuiAction) -> GuiView { + let num_buttons: u8 = 2; + + let new_selected = match (action, state.selected) { + // Check if a button was "pressed" + (GuiAction::Select, 0) => return GuiView::Scan, + (GuiAction::Select, 1) => return GuiView::Settings, + + // Scroll the selected and output the new selected + (GuiAction::Up, 0) => num_buttons - 1, + (GuiAction::Up, _) => state.selected - 1, + (GuiAction::Down, i) if i == num_buttons - 1 => 0, + (GuiAction::Down, _) => state.selected + 1, + _ => 0, + }; + + GuiView::MainMenu(State { + selected: new_selected, + }) +} diff --git a/gui/src/views/mod.rs b/gui/src/views/mod.rs new file mode 100644 index 0000000..8f33c68 --- /dev/null +++ b/gui/src/views/mod.rs @@ -0,0 +1,4 @@ +pub mod main_menu; +pub mod scan; +pub mod settings; +pub mod void; diff --git a/gui/src/views/scan.rs b/gui/src/views/scan.rs new file mode 100644 index 0000000..78295f8 --- /dev/null +++ b/gui/src/views/scan.rs @@ -0,0 +1,12 @@ +use crate::widgets::header::Header; +use core::result::Result::{self, Ok}; +use embedded_graphics::prelude::*; + +pub fn draw(display: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + Header::new("Scan page").draw(display)?; + + Ok(()) +} diff --git a/gui/src/views/settings.rs b/gui/src/views/settings.rs new file mode 100644 index 0000000..430abb8 --- /dev/null +++ b/gui/src/views/settings.rs @@ -0,0 +1,12 @@ +use crate::widgets::header::Header; +use core::result::Result::{self, Ok}; +use embedded_graphics::prelude::*; + +pub fn draw(display: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + Header::new("Settings page").draw(display)?; + + Ok(()) +} diff --git a/gui/src/views/void.rs b/gui/src/views/void.rs new file mode 100644 index 0000000..2a42c32 --- /dev/null +++ b/gui/src/views/void.rs @@ -0,0 +1,27 @@ +use crate::widgets::button::Button; +use core::result::Result::{self, Ok}; +use embedded_graphics::prelude::*; +use embedded_layout::{ + layout::linear::{FixedMargin, LinearLayout}, + prelude::*, +}; + +pub fn draw(display: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + let button1 = Button::new(Point::zero(), Size::new(100, 50), "Void"); + let button2 = Button::new(Point::zero(), Size::new(100, 50), "Void"); + + LinearLayout::vertical(Chain::new(button1).append(button2)) + .with_spacing(FixedMargin(10)) + .arrange() + .align_to( + &display.bounding_box(), + horizontal::Center, + vertical::Center, + ) + .draw(display)?; + + Ok(()) +} diff --git a/gui/src/widgets/button.rs b/gui/src/widgets/button.rs new file mode 100644 index 0000000..d23883c --- /dev/null +++ b/gui/src/widgets/button.rs @@ -0,0 +1,90 @@ +use core::result::Result::{self, Ok}; +use embedded_graphics::{ + mono_font::{ascii::FONT_10X20, MonoTextStyle}, + pixelcolor::Rgb888, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, + text::Text, +}; +use embedded_layout::{ + align::{horizontal, vertical, Align}, + View, +}; + +pub struct Button<'a> { + bounds: Rectangle, + pub highlighted: bool, + pub text: &'a str, +} + +impl<'a> Button<'a> { + pub fn new(position: Point, size: Size, text: &'a str) -> Self { + Self { + bounds: Rectangle::new(position, size), + highlighted: false, + text, + } + } + + pub fn toggle_highlight(self) -> Self { + Self { + bounds: self.bounds, + highlighted: !self.highlighted, + text: self.text, + } + } +} + +impl View for Button<'_> { + #[inline] + fn translate_impl(&mut self, by: Point) { + View::translate_mut(&mut self.bounds, by); + } + + #[inline] + fn bounds(&self) -> Rectangle { + self.bounds + } +} + +impl Drawable for Button<'_> { + type Color = crate::Color; + type Output = (); + + fn draw(&self, target: &mut D) -> Result + where + D: DrawTarget, + { + // Create styles + let border_style = PrimitiveStyle::with_stroke(Self::Color::WHITE, 5); + + let normal_background_style = PrimitiveStyle::with_fill(Self::Color::CSS_GRAY); + let highlighted_background_style = PrimitiveStyle::with_fill(Self::Color::BLUE); + + let background_style = if self.highlighted { + highlighted_background_style + } else { + normal_background_style + }; + + let character_style = MonoTextStyle::new(&FONT_10X20, Self::Color::WHITE); + + // Create the border + let border = self.bounds.into_styled(border_style); + + // Create the button fill + let fill = Rectangle::new(Point::zero(), self.bounds.size()).into_styled(background_style); + let fill = fill.align_to(&border, horizontal::Left, vertical::Center); + + // Create the button text + let text = Text::new(self.text, Point::zero(), character_style); + let text = text.align_to(&border, horizontal::Center, vertical::Center); + + // Draw the views + fill.draw(target)?; + border.draw(target)?; + text.draw(target)?; + + Ok(()) + } +} diff --git a/gui/src/widgets/header.rs b/gui/src/widgets/header.rs new file mode 100644 index 0000000..929f16b --- /dev/null +++ b/gui/src/widgets/header.rs @@ -0,0 +1,68 @@ +use core::result::Result::{self, Ok}; +use embedded_graphics::{ + mono_font::MonoTextStyle, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, + text::Text, +}; +use embedded_layout::{ + align::{horizontal, vertical, Align}, + View, +}; +use profont::PROFONT_24_POINT; + +use crate::Color; + +pub struct Header<'a> { + bounds: Rectangle, + pub text: &'a str, +} + +impl<'a> Header<'a> { + pub fn new(text: &'a str) -> Self { + Self { + bounds: Rectangle::new(Point::zero(), Size::new(240, 30)), + text, + } + } +} + +impl View for Header<'_> { + #[inline] + fn translate_impl(&mut self, by: Point) { + View::translate_mut(&mut self.bounds, by); + } + + #[inline] + fn bounds(&self) -> Rectangle { + self.bounds + } +} + +impl Drawable for Header<'_> { + type Color = crate::Color; + type Output = (); + + fn draw(&self, target: &mut D) -> Result + where + D: DrawTarget, + { + // Create styles + let background_style = PrimitiveStyle::with_fill(Color::CSS_MINT_CREAM); + let character_style = MonoTextStyle::new(&PROFONT_24_POINT, Self::Color::BLACK); + + // Create the background + let background = + Rectangle::new(Point::zero(), self.bounds.size()).into_styled(background_style); + + // Create the text + let text = Text::new(self.text, Point::zero(), character_style); + let text = text.align_to(&background, horizontal::Center, vertical::Center); + + // Draw the menu + background.draw(target)?; + text.draw(target)?; + + Ok(()) + } +} diff --git a/gui/src/widgets/mod.rs b/gui/src/widgets/mod.rs new file mode 100644 index 0000000..8310103 --- /dev/null +++ b/gui/src/widgets/mod.rs @@ -0,0 +1,3 @@ +pub mod button; +pub mod header; +pub mod progress; diff --git a/gui/src/widgets/progress.rs b/gui/src/widgets/progress.rs new file mode 100644 index 0000000..b0c2120 --- /dev/null +++ b/gui/src/widgets/progress.rs @@ -0,0 +1,95 @@ +use core::result::Result::{self, Ok}; +use embedded_graphics::{ + geometry::{Point, Size}, + pixelcolor::Rgb888, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, +}; +use embedded_layout::{ + align::{horizontal, vertical, Align}, + View, +}; + +pub struct ProgressBar { + progress: u32, + bounds: Rectangle, +} + +impl ProgressBar { + /// The progress bar needs a configurable position and size + pub fn new(position: Point, size: Size) -> Self { + Self { + bounds: Rectangle::new(position, size), + progress: 0, + } + } + + /// The progress bar needs a configurable position and size + pub fn with_progress(self, progress: u32) -> Self { + Self { + bounds: self.bounds, + progress, + } + } + + pub fn increment(self) -> Self { + Self { + bounds: self.bounds, + progress: self.progress + 1, + } + } +} + +/// Impleenting `View` is required by the layout and alignment operations +/// `View` teaches `embedded-layout` where our object is, how big it is, and how to move it. +impl View for ProgressBar { + #[inline] + fn translate_impl(&mut self, by: Point) { + // NB: Do not use translate (non-mut) + View::translate_mut(&mut self.bounds, by); + } + + #[inline] + fn bounds(&self) -> Rectangle { + self.bounds + } +} + +/// We need to implement `Drawable` for a reference of our view +impl Drawable for ProgressBar { + type Color = Rgb888; + type Output = (); + + fn draw(&self, target: &mut D) -> Result + where + D: DrawTarget, + { + // Create styles + let border_style = PrimitiveStyle::with_stroke(Self::Color::WHITE, 5); + let progress_style = PrimitiveStyle::with_fill(Self::Color::RED); + + // Create a border + let border = self.bounds.into_styled(border_style); + + // Create a rectangle to indicate progress + let progress = Rectangle::new( + Point::zero(), + Size::new( + (self.bounds.size().width - 4) * self.progress / 100, + self.bounds.size().height, + ), + ) + .into_styled(progress_style); + + // Align the progress bar with the border + let progress = progress + .align_to(&border, horizontal::Left, vertical::Center) + .translate(Point::new(6, 0)); + + // Draw the views + progress.draw(target)?; + border.draw(target)?; + + Ok(()) + } +}