Skip to main content

embedded_gui/widgets/
action_menu.rs

1//! Hierarchical Action Menu and Cascading Action Sheets.
2//!
3//! Provides `ActionMenuWidget` (contextual cascading action menu with submenus and highlight cursor).
4
5use embedded_graphics_core::{draw_target::DrawTarget, pixelcolor::Rgb565};
6use heapless::Vec;
7
8use crate::{
9    geometry::Rect,
10    render::RenderCtx,
11    style::{Border, Style},
12    widget::{PropertyKey, PropertyValue, Widget},
13};
14
15/// Error indicating action menu capacity exceeded.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub struct ActionMenuError;
18
19/// A single item in an Action Menu.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct ActionMenuItem<'a> {
22    pub label: &'a str,
23    pub action_id: u16,
24    pub is_submenu: bool,
25}
26
27/// Cascading Hierarchical Action Menu widget.
28#[derive(Clone, Debug, PartialEq)]
29pub struct ActionMenuWidget<'a, const MAX_ITEMS: usize = 8> {
30    pub title: Option<&'a str>,
31    pub items: Vec<ActionMenuItem<'a>, MAX_ITEMS>,
32    pub selected_index: usize,
33    pub background_color: Rgb565,
34    pub text_color: Rgb565,
35    pub selected_bg_color: Rgb565,
36    pub selected_text_color: Rgb565,
37    pub accent_color: Rgb565,
38}
39
40impl<'a, const MAX_ITEMS: usize> ActionMenuWidget<'a, MAX_ITEMS> {
41    pub const fn new(title: Option<&'a str>) -> Self {
42        Self {
43            title,
44            items: Vec::new(),
45            selected_index: 0,
46            background_color: Rgb565::new(3, 6, 9),
47            text_color: Rgb565::new(31, 63, 31),
48            selected_bg_color: Rgb565::new(0, 45, 30),
49            selected_text_color: Rgb565::new(31, 63, 31),
50            accent_color: Rgb565::new(0, 35, 30),
51        }
52    }
53
54    pub fn add_item(
55        &mut self,
56        label: &'a str,
57        action_id: u16,
58        is_submenu: bool,
59    ) -> Result<(), ActionMenuError> {
60        self.items
61            .push(ActionMenuItem {
62                label,
63                action_id,
64                is_submenu,
65            })
66            .map_err(|_| ActionMenuError)
67    }
68
69    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
70    where
71        D: DrawTarget<Color = Rgb565>,
72        C: crate::render::Compositor<D>,
73    {
74        ctx.fill_rounded_rect(bounds, 4, self.background_color)?;
75        ctx.stroke_rounded_rect(bounds, 4, Border::one(self.accent_color))?;
76
77        let mut y = bounds.y + 4;
78
79        // Title (if present)
80        if let Some(title) = self.title {
81            ctx.draw_text(bounds.x + 8, y, title, Rgb565::new(15, 30, 20))?;
82            y += 14;
83        }
84
85        let item_h = 16;
86        for (i, item) in self.items.iter().enumerate() {
87            let is_selected = i == self.selected_index;
88            let item_rect = Rect::new(bounds.x + 4, y, bounds.w.saturating_sub(8), item_h as u32);
89
90            if is_selected {
91                ctx.fill_rounded_rect(item_rect, 2, self.selected_bg_color)?;
92            }
93
94            let fg = if is_selected {
95                self.selected_text_color
96            } else {
97                self.text_color
98            };
99            ctx.draw_text(bounds.x + 10, y + 3, item.label, fg)?;
100
101            // Submenu chevron hint '>'
102            if item.is_submenu {
103                ctx.draw_text(bounds.right() - 14, y + 3, ">", fg)?;
104            }
105
106            y += item_h + 2;
107        }
108
109        Ok(())
110    }
111}
112
113impl<'a, const MAX_ITEMS: usize> Widget for ActionMenuWidget<'a, MAX_ITEMS> {
114    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
115
116    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
117        match key {
118            PropertyKey::Selected => Some(PropertyValue::Int(self.selected_index as i32)),
119            _ => None,
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::framebuffer::Framebuffer;
128
129    #[test]
130    fn test_action_menu_render() {
131        let mut menu = ActionMenuWidget::<4>::new(Some("SETTINGS"));
132        assert!(menu.add_item("Wi-Fi", 1, true).is_ok());
133        assert!(menu.add_item("Bluetooth", 2, true).is_ok());
134        assert!(menu.add_item("Restart", 3, false).is_ok());
135
136        let mut fb = Framebuffer::<24000>::new(160, 100);
137        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 160, 100));
138        assert!(menu.render(&mut ctx, Rect::new(0, 0, 160, 100)).is_ok());
139    }
140}