1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub struct ActionMenuError;
18
19#[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#[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 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 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}