Skip to main content

zest_widget/widget/
menu.rs

1//! Vertical menu: a stack of selectable entries, each emitting a message
2//! when tapped.
3//!
4//! Immediate-mode: the host owns which entry is currently selected and
5//! passes it via [`Menu::selected`]; the selected entry is highlighted
6//! with the accent color. Tapping an entry emits the message bound to it,
7//! using the same press-then-release semantics as [`Button`](super::Button)
8//! (a press is only fired if finger-down and finger-up land on the same
9//! entry).
10
11use super::Widget;
12use alloc::{string::String, vec::Vec};
13use core::marker::PhantomData;
14use embedded_graphics::{
15    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
16};
17use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
18use zest_theme::Theme;
19
20/// Default per-entry height in pixels.
21const ROW_H: u32 = 30;
22/// Horizontal label inset in pixels.
23const PAD_X: i32 = 10;
24
25/// A single menu entry: a label and the message emitted when tapped.
26struct Entry<M> {
27    label: String,
28    message: M,
29}
30
31/// Vertical menu of selectable entries.
32pub struct Menu<C: PixelColor, M: Clone> {
33    rect: Rectangle,
34    entries: Vec<Entry<M>>,
35    id: Option<WidgetId>,
36    selected: Option<usize>,
37    focused: Option<usize>,
38    pressed: Option<usize>,
39    row_h: u32,
40    width: Length,
41    height: Length,
42    _color: PhantomData<C>,
43}
44
45impl<C: PixelColor, M: Clone> Menu<C, M> {
46    /// Create an empty menu. Add entries with [`entry`](Self::entry).
47    pub fn new() -> Self {
48        Self {
49            rect: Rectangle::zero(),
50            entries: Vec::new(),
51            id: None,
52            selected: None,
53            focused: None,
54            pressed: None,
55            row_h: ROW_H,
56            width: Length::Fill,
57            height: Length::Shrink,
58            _color: PhantomData,
59        }
60    }
61
62    /// Append an entry with the message emitted when it is tapped.
63    #[must_use]
64    pub fn entry(mut self, label: impl Into<String>, message: M) -> Self {
65        self.entries.push(Entry {
66            label: label.into(),
67            message,
68        });
69        self
70    }
71
72    /// Index of the currently-selected entry (highlighted).
73    #[must_use]
74    pub fn selected(mut self, index: usize) -> Self {
75        self.selected = Some(index);
76        self
77    }
78
79    /// Set a stable base id so menu rows can participate in focus traversal.
80    #[must_use]
81    pub fn id(mut self, id: WidgetId) -> Self {
82        self.id = Some(id);
83        self
84    }
85
86    /// Per-row height in pixels (default 30).
87    #[must_use]
88    pub fn row_height(mut self, h: u32) -> Self {
89        self.row_h = h.max(1);
90        self
91    }
92
93    /// Width sizing intent.
94    #[must_use]
95    pub fn width(mut self, w: impl Into<Length>) -> Self {
96        self.width = w.into();
97        self
98    }
99
100    /// Height sizing intent.
101    #[must_use]
102    pub fn height(mut self, h: impl Into<Length>) -> Self {
103        self.height = h.into();
104        self
105    }
106
107    fn row_rect(&self, i: usize) -> Rectangle {
108        Rectangle::new(
109            self.rect.top_left + Point::new(0, (i as u32 * self.row_h) as i32),
110            Size::new(self.rect.size.width, self.row_h),
111        )
112    }
113
114    fn row_at(&self, p: Point) -> Option<usize> {
115        let left = self.rect.top_left.x;
116        if p.x < left || p.x >= left + self.rect.size.width as i32 {
117            return None;
118        }
119        let dy = p.y - self.rect.top_left.y;
120        if dy < 0 {
121            return None;
122        }
123        let idx = (dy as u32 / self.row_h) as usize;
124        (idx < self.entries.len()).then_some(idx)
125    }
126
127    fn row_id(&self, index: usize) -> Option<WidgetId> {
128        self.id
129            .map(|id| WidgetId::new(id.raw().wrapping_add(index as u64 + 1)))
130    }
131}
132
133impl<C: PixelColor, M: Clone> Default for Menu<C, M> {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl<C: PixelColor, M: Clone> Widget<C, M> for Menu<C, M> {
140    fn measure(&mut self, constraints: Constraints) -> Size {
141        let intrinsic_h = self.row_h * self.entries.len() as u32;
142        let w = self
143            .width
144            .resolve(constraints.max.width, constraints.max.width);
145        let h = self.height.resolve(intrinsic_h, constraints.max.height);
146        constraints.clamp(Size::new(w, h))
147    }
148
149    fn preferred_size(&self) -> (Length, Length) {
150        (self.width, self.height)
151    }
152
153    fn arrange(&mut self, rect: Rectangle) {
154        self.rect = rect;
155    }
156
157    fn rect(&self) -> Rectangle {
158        self.rect
159    }
160
161    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
162        match phase {
163            TouchPhase::Down => {
164                self.pressed = self.row_at(point);
165                None
166            }
167            TouchPhase::Moved => {
168                if self.row_at(point) != self.pressed {
169                    self.pressed = None;
170                }
171                None
172            }
173            TouchPhase::Up => {
174                let now = self.row_at(point);
175                let pressed = self.pressed.take();
176                if let (Some(i), Some(p)) = (now, pressed) {
177                    if i == p {
178                        return Some(self.entries[i].message.clone());
179                    }
180                }
181                None
182            }
183        }
184    }
185
186    fn mark_pressed(&mut self, point: Point) {
187        // Don't overwrite a press already recorded earlier this frame.
188        if self.pressed.is_none() {
189            self.pressed = self.row_at(point);
190        }
191    }
192
193    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
194        for index in 0..self.entries.len() {
195            if let Some(id) = self.row_id(index) {
196                out.push(id);
197            }
198        }
199    }
200
201    fn sync_focus(&mut self, focused: Option<WidgetId>) {
202        self.focused = focused.and_then(|target| {
203            (0..self.entries.len()).find(|index| self.row_id(*index) == Some(target))
204        });
205    }
206
207    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
208        let index = (0..self.entries.len()).find(|index| self.row_id(*index) == Some(target))?;
209        match action {
210            UiAction::Activate => Some(self.entries[index].message.clone()),
211            _ => None,
212        }
213    }
214
215    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
216        let index =
217            (0..self.entries.len()).find(|candidate| self.row_id(*candidate) == Some(target))?;
218        Some(self.row_rect(index))
219    }
220
221    fn focus_at(&self, point: Point) -> Option<WidgetId> {
222        self.row_at(point).and_then(|index| self.row_id(index))
223    }
224
225    fn draw<'t>(
226        &self,
227        renderer: &mut dyn Renderer<C>,
228        theme: &Theme<'t, C>,
229    ) -> Result<(), RenderError> {
230        let font = theme.default_font();
231        let glyph_h = font.character_size.height as i32;
232        for (i, e) in self.entries.iter().enumerate() {
233            let r = self.row_rect(i);
234            let (bg, fg, border) = if Some(i) == self.selected {
235                (theme.accent.base, theme.accent.on_base, theme.button.border)
236            } else if Some(i) == self.focused {
237                (theme.button.base, theme.button.on_base, theme.accent.base)
238            } else if Some(i) == self.pressed {
239                (
240                    theme.button.pressed,
241                    theme.button.on_base,
242                    theme.button.border,
243                )
244            } else {
245                (theme.button.base, theme.button.on_base, theme.button.border)
246            };
247            renderer.fill_rect(r, bg)?;
248            renderer.stroke_rect(r, border)?;
249            renderer.draw_text(
250                &e.label,
251                Point::new(
252                    r.top_left.x + PAD_X,
253                    r.top_left.y + self.row_h as i32 / 2 + glyph_h / 3,
254                ),
255                font,
256                fg,
257                Alignment::Left,
258            )?;
259        }
260        Ok(())
261    }
262}