Skip to main content

zest_widget/widget/
dropdown.rs

1//! Drop-down selector: a button-like field showing the current selection
2//! that, when open, reveals its option list as an overlay.
3//!
4//! Immediate-mode: the host owns *both* the open flag and the selected
5//! index and rebuilds the widget each frame, passing the current values to
6//! [`Dropdown::open`] and [`Dropdown::selected`]. The widget emits messages
7//! and never mutates either piece of state itself:
8//!
9//! * Tapping the field emits [`on_toggle`](Dropdown::on_toggle) with the
10//!   negated open flag, so the host flips its stored `bool`.
11//! * Tapping an option emits [`on_select`](Dropdown::on_select) with that
12//!   option's index, so the host stores it (and typically closes the list).
13//!
14//! ## How the overlay works
15//!
16//! The whole widget is assembled as a [`Stack`](crate::Stack): the field is
17//! the bottom layer, and — only while `open` — the option list is pushed
18//! *last* so it draws on top of and intercepts touch before everything
19//! beneath it (see [`stack`](super::stack) for the z-order rules). The list
20//! is aligned to the top of the stack region, directly under where the
21//! field sits, so it reads as "dropping down" from the field. The host owns
22//! the open flag, so the next rebuild adds or drops that layer.
23//!
24//! The stack is composed lazily on the first lifecycle call, so the
25//! chainable builders only record configuration — keeping each
26//! `Box<dyn Fn>` callback single-owned (callbacks cannot be cloned).
27//!
28//! Colors come from the theme: the field reuses the standard
29//! [`button`](zest_theme::Theme::button) component styling, the open list
30//! paints a [`background`](zest_theme::Theme::background)-based card, and the
31//! currently-selected row is highlighted with the
32//! [`accent`](zest_theme::Theme::accent) color.
33
34use super::{Widget, element::Element};
35use alloc::{boxed::Box, string::String, vec::Vec};
36use core::marker::PhantomData;
37use embedded_graphics::{
38    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
39};
40use zest_core::{
41    Constraints, Horizontal, Length, RenderError, Renderer, TouchPhase, UiAction, Vertical,
42    WidgetId,
43};
44use zest_theme::Theme;
45
46/// Default height of the field and of each option row, in pixels.
47const ROW_HEIGHT: u32 = 36;
48/// Horizontal text inset inside the field and option rows, in pixels.
49const TEXT_PAD: i32 = 8;
50
51/// A drop-down selector. The host owns the open flag and selected index;
52/// the widget renders the field, draws the option list as an overlay while
53/// open, and emits [`on_toggle`](Dropdown::on_toggle) /
54/// [`on_select`](Dropdown::on_select).
55pub struct Dropdown<'a, C: PixelColor, M: Clone> {
56    options: Vec<String>,
57    selected: usize,
58    is_open: bool,
59    placeholder: String,
60    id: Option<WidgetId>,
61    on_toggle: Option<Box<dyn Fn(bool) -> M + 'a>>,
62    on_select: Option<Box<dyn Fn(usize) -> M + 'a>>,
63    width: Length,
64    height: Length,
65    /// Composed stack, built on first lifecycle call. `None` until then.
66    stack: Option<Element<'a, C, M>>,
67    /// Option count cached at build time so `arrange` can size the overlay
68    /// region without re-reading `options`.
69    option_count: usize,
70}
71
72impl<'a, C: PixelColor + 'a, M: Clone + 'a> Dropdown<'a, C, M> {
73    /// New drop-down. Position and size are assigned by the parent
74    /// container via `arrange`. Defaults: no options, selection 0, closed,
75    /// fill width, 36px field height.
76    pub fn new() -> Self {
77        Self {
78            options: Vec::new(),
79            selected: 0,
80            is_open: false,
81            placeholder: String::new(),
82            id: None,
83            on_toggle: None,
84            on_select: None,
85            width: Length::Fill,
86            height: Length::Fixed(ROW_HEIGHT),
87            stack: None,
88            option_count: 0,
89        }
90    }
91
92    /// Width sizing intent (default [`Length::Fill`]).
93    #[must_use]
94    pub fn width(mut self, width: impl Into<Length>) -> Self {
95        self.width = width.into();
96        self
97    }
98
99    /// Height sizing intent of the *field* (default 36px). The
100    /// option list always uses one fixed-height row per option.
101    #[must_use]
102    pub fn height(mut self, height: impl Into<Length>) -> Self {
103        self.height = height.into();
104        self
105    }
106
107    /// The option labels. They are copied into owned `String`s so
108    /// the widget can outlive the borrowed slice.
109    #[must_use]
110    pub fn options(mut self, options: &[&str]) -> Self {
111        self.options = options.iter().map(|s| String::from(*s)).collect();
112        self.option_count = self.options.len();
113        self
114    }
115
116    /// The currently-selected option index (host-owned).
117    #[must_use]
118    pub fn selected(mut self, index: usize) -> Self {
119        self.selected = index;
120        self
121    }
122
123    /// Whether the option list is currently shown (host-owned).
124    #[must_use]
125    pub fn open(mut self, open: bool) -> Self {
126        self.is_open = open;
127        self
128    }
129
130    /// Text shown on the field when the selected index is out of
131    /// range (e.g. nothing selected yet).
132    #[must_use]
133    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
134        self.placeholder = placeholder.into();
135        self
136    }
137
138    /// Set a stable base id so the field and option rows can participate in
139    /// focus traversal.
140    #[must_use]
141    pub fn id(mut self, id: WidgetId) -> Self {
142        self.id = Some(id);
143        self
144    }
145
146    /// Callback invoked when the field is tapped, receiving the
147    /// negated open flag. Without it the field does not toggle.
148    #[must_use]
149    pub fn on_toggle<F: Fn(bool) -> M + 'a>(mut self, f: F) -> Self {
150        self.on_toggle = Some(Box::new(f));
151        self
152    }
153
154    /// Callback invoked when an option is tapped, receiving that
155    /// option's index. Without it the options are inert.
156    #[must_use]
157    pub fn on_select<F: Fn(usize) -> M + 'a>(mut self, f: F) -> Self {
158        self.on_select = Some(Box::new(f));
159        self
160    }
161
162    /// Compose the field (and, while open, the option list) into a
163    /// [`Stack`](crate::Stack), consuming the recorded config. Callbacks
164    /// are moved out via [`Option::take`] so each is owned by exactly one
165    /// inner widget.
166    fn build(&mut self) -> Element<'a, C, M> {
167        let label = self
168            .options
169            .get(self.selected)
170            .cloned()
171            .unwrap_or_else(|| self.placeholder.clone());
172
173        let field = DropdownField {
174            rect: Rectangle::zero(),
175            id: self.id,
176            label,
177            open: self.is_open,
178            on_toggle: self.on_toggle.take(),
179            focused: false,
180            pressed: false,
181            width: self.width,
182            height: self.height,
183            _color: PhantomData,
184        };
185
186        let mut stack = super::stack::Stack::new()
187            .width(self.width)
188            .height(self.height);
189        stack = stack.push_aligned(field, Horizontal::Left, Vertical::Top);
190
191        if self.is_open && !self.options.is_empty() {
192            let list = DropdownList {
193                rect: Rectangle::zero(),
194                base_id: self.id,
195                options: self.options.clone(),
196                selected: self.selected,
197                on_select: self.on_select.take(),
198                focused: None,
199                pressed: None,
200                _color: PhantomData,
201            };
202            // Push last → drawn on top, touched first; aligned under the
203            // field at the top of the stack region.
204            stack = stack.push_aligned(list, Horizontal::Left, Vertical::Top);
205        }
206
207        Element::new(stack)
208    }
209
210    /// Build the stack on demand if it hasn't been composed yet.
211    fn ensure_built(&mut self) {
212        if self.stack.is_none() {
213            self.stack = Some(self.build());
214        }
215    }
216}
217
218impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Dropdown<'a, C, M> {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Dropdown<'a, C, M> {
225    fn measure(&mut self, constraints: Constraints) -> Size {
226        self.ensure_built();
227        // Report only the field's slot to the parent; the open list is an
228        // overlay that paints outside the reported size (like any modal).
229        let w = self
230            .width
231            .resolve(constraints.max.width, constraints.max.width);
232        let h = self.height.resolve(ROW_HEIGHT, constraints.max.height);
233        constraints.clamp(Size::new(w, h))
234    }
235
236    fn preferred_size(&self) -> (Length, Length) {
237        (self.width, self.height)
238    }
239
240    fn arrange(&mut self, rect: Rectangle) {
241        self.ensure_built();
242        // Arrange the stack over a region tall enough for the field plus
243        // the open list, so the overlay rows get real rects. The field is
244        // top-aligned and keeps its own height inside this region.
245        let extra = if self.is_open {
246            ROW_HEIGHT.saturating_mul(self.option_count as u32)
247        } else {
248            0
249        };
250        let region = Rectangle::new(
251            rect.top_left,
252            Size::new(rect.size.width, rect.size.height + extra),
253        );
254        if let Some(stack) = self.stack.as_mut() {
255            stack.arrange(region);
256        }
257    }
258
259    fn rect(&self) -> Rectangle {
260        self.stack.as_ref().map_or(Rectangle::zero(), |s| s.rect())
261    }
262
263    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
264        self.ensure_built();
265        self.stack
266            .as_mut()
267            .and_then(|s| s.handle_touch(point, phase))
268    }
269
270    fn mark_pressed(&mut self, point: Point) {
271        self.ensure_built();
272        if let Some(stack) = self.stack.as_mut() {
273            stack.mark_pressed(point);
274        }
275    }
276
277    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
278        if let Some(id) = self.id
279            && self.on_toggle.is_some()
280        {
281            out.push(id);
282        }
283
284        if self.is_open && self.on_select.is_some() {
285            for index in 0..self.options.len() {
286                if let Some(id) = self.row_id(index) {
287                    out.push(id);
288                }
289            }
290        }
291    }
292
293    fn sync_focus(&mut self, focused: Option<WidgetId>) {
294        self.ensure_built();
295        if let Some(stack) = self.stack.as_mut() {
296            stack.sync_focus(focused);
297        }
298    }
299
300    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
301        self.ensure_built();
302        self.stack
303            .as_mut()
304            .and_then(|stack| stack.route_action(target, action))
305    }
306
307    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
308        self.stack
309            .as_ref()
310            .and_then(|stack| stack.focus_rect(target))
311    }
312
313    fn focus_at(&self, point: Point) -> Option<WidgetId> {
314        self.stack.as_ref().and_then(|stack| stack.focus_at(point))
315    }
316
317    fn draw<'t>(
318        &self,
319        renderer: &mut dyn Renderer<C>,
320        theme: &Theme<'t, C>,
321    ) -> Result<(), RenderError> {
322        if let Some(stack) = &self.stack {
323            stack.draw(renderer, theme)?;
324        }
325        Ok(())
326    }
327}
328
329impl<'a, C: PixelColor + 'a, M: Clone + 'a> Dropdown<'a, C, M> {
330    fn row_id(&self, index: usize) -> Option<WidgetId> {
331        self.id
332            .map(|id| WidgetId::new(id.raw().wrapping_add(index as u64 + 1)))
333    }
334}
335
336// ---- internal: the bottom-layer field --------------------------------------
337
338/// The always-present field: a button-like rect showing the current
339/// selection plus a caret. Emits the toggle callback on tap.
340struct DropdownField<'a, C: PixelColor, M: Clone> {
341    rect: Rectangle,
342    id: Option<WidgetId>,
343    label: String,
344    open: bool,
345    on_toggle: Option<Box<dyn Fn(bool) -> M + 'a>>,
346    focused: bool,
347    pressed: bool,
348    width: Length,
349    height: Length,
350    _color: PhantomData<C>,
351}
352
353impl<C: PixelColor, M: Clone> DropdownField<'_, C, M> {
354    fn hit_test(&self, point: Point) -> bool {
355        let tl = self.rect.top_left;
356        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
357        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
358    }
359}
360
361impl<C: PixelColor, M: Clone> Widget<C, M> for DropdownField<'_, C, M> {
362    fn measure(&mut self, constraints: Constraints) -> Size {
363        let w = self
364            .width
365            .resolve(constraints.max.width, constraints.max.width);
366        let h = self.height.resolve(ROW_HEIGHT, constraints.max.height);
367        constraints.clamp(Size::new(w, h))
368    }
369
370    fn preferred_size(&self) -> (Length, Length) {
371        (self.width, self.height)
372    }
373
374    fn arrange(&mut self, rect: Rectangle) {
375        self.rect = rect;
376    }
377
378    fn rect(&self) -> Rectangle {
379        self.rect
380    }
381
382    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
383        if self.on_toggle.is_none() || !self.hit_test(point) {
384            if matches!(phase, TouchPhase::Up | TouchPhase::Moved) {
385                self.pressed = false;
386            }
387            return None;
388        }
389        match phase {
390            TouchPhase::Down => {
391                self.pressed = true;
392                None
393            }
394            TouchPhase::Up => {
395                if self.pressed {
396                    self.pressed = false;
397                    let open = self.open;
398                    self.on_toggle.as_ref().map(|cb| cb(!open))
399                } else {
400                    None
401                }
402            }
403            TouchPhase::Moved => None,
404        }
405    }
406
407    fn mark_pressed(&mut self, point: Point) {
408        if self.on_toggle.is_some() && self.hit_test(point) {
409            self.pressed = true;
410        }
411    }
412
413    fn widget_id(&self) -> Option<WidgetId> {
414        self.id
415    }
416
417    fn is_focusable(&self) -> bool {
418        self.id.is_some() && self.on_toggle.is_some()
419    }
420
421    fn handle_action(&mut self, action: UiAction) -> Option<M> {
422        match action {
423            UiAction::Activate => self.on_toggle.as_ref().map(|cb| cb(!self.open)),
424            _ => None,
425        }
426    }
427
428    fn sync_focus(&mut self, focused: Option<WidgetId>) {
429        self.focused = self.id.is_some() && self.id == focused;
430    }
431
432    fn focus_at(&self, point: Point) -> Option<WidgetId> {
433        if self.is_focusable() && self.hit_test(point) {
434            self.id
435        } else {
436            None
437        }
438    }
439
440    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
441        if self.id == Some(target) {
442            Some(self.rect)
443        } else {
444            None
445        }
446    }
447
448    fn draw<'t>(
449        &self,
450        renderer: &mut dyn Renderer<C>,
451        theme: &Theme<'t, C>,
452    ) -> Result<(), RenderError> {
453        let comp = &theme.button;
454        let bg = if self.pressed {
455            comp.pressed
456        } else {
457            comp.base
458        };
459        let border = if self.focused {
460            theme.accent.base
461        } else {
462            comp.border
463        };
464        renderer.fill_rect(self.rect, bg)?;
465        renderer.stroke_rect(self.rect, border)?;
466
467        let font = theme.default_font();
468        let text_y = self.rect.top_left.y
469            + self.rect.size.height as i32 / 2
470            + font.character_size.height as i32 / 3;
471        renderer.draw_text(
472            &self.label,
473            Point::new(self.rect.top_left.x + TEXT_PAD, text_y),
474            font,
475            comp.on_base,
476            Alignment::Left,
477        )?;
478
479        // Caret on the right: a small triangle, flipped when open. Drawn as
480        // three strokes (the renderer has no triangle primitive).
481        let cx = self.rect.top_left.x + self.rect.size.width as i32 - TEXT_PAD - 6;
482        let cy = self.rect.top_left.y + self.rect.size.height as i32 / 2;
483        if self.open {
484            renderer.stroke_line(
485                Point::new(cx, cy + 3),
486                Point::new(cx + 6, cy + 3),
487                comp.on_base,
488                2,
489            )?;
490            renderer.stroke_line(
491                Point::new(cx, cy + 3),
492                Point::new(cx + 3, cy - 3),
493                comp.on_base,
494                2,
495            )?;
496            renderer.stroke_line(
497                Point::new(cx + 6, cy + 3),
498                Point::new(cx + 3, cy - 3),
499                comp.on_base,
500                2,
501            )?;
502        } else {
503            renderer.stroke_line(
504                Point::new(cx, cy - 3),
505                Point::new(cx + 6, cy - 3),
506                comp.on_base,
507                2,
508            )?;
509            renderer.stroke_line(
510                Point::new(cx, cy - 3),
511                Point::new(cx + 3, cy + 3),
512                comp.on_base,
513                2,
514            )?;
515            renderer.stroke_line(
516                Point::new(cx + 6, cy - 3),
517                Point::new(cx + 3, cy + 3),
518                comp.on_base,
519                2,
520            )?;
521        }
522        Ok(())
523    }
524}
525
526// ---- internal: the open option list overlay --------------------------------
527
528/// The drop-down list shown while open: one row per option, the selected
529/// row highlighted, each row emitting the select callback on tap.
530struct DropdownList<'a, C: PixelColor, M: Clone> {
531    rect: Rectangle,
532    base_id: Option<WidgetId>,
533    options: Vec<String>,
534    selected: usize,
535    on_select: Option<Box<dyn Fn(usize) -> M + 'a>>,
536    focused: Option<usize>,
537    /// Index of the row currently held down (for pressed feedback).
538    pressed: Option<usize>,
539    _color: PhantomData<C>,
540}
541
542impl<C: PixelColor, M: Clone> DropdownList<'_, C, M> {
543    fn list_height(&self) -> u32 {
544        ROW_HEIGHT.saturating_mul(self.options.len() as u32)
545    }
546
547    /// The row index containing `point`, if any.
548    fn row_at(&self, point: Point) -> Option<usize> {
549        let tl = self.rect.top_left;
550        if point.x < tl.x || point.x >= tl.x + self.rect.size.width as i32 {
551            return None;
552        }
553        let dy = point.y - tl.y;
554        if dy < 0 {
555            return None;
556        }
557        let idx = (dy as u32 / ROW_HEIGHT) as usize;
558        (idx < self.options.len()).then_some(idx)
559    }
560
561    fn row_id(&self, index: usize) -> Option<WidgetId> {
562        self.base_id
563            .map(|id| WidgetId::new(id.raw().wrapping_add(index as u64 + 1)))
564    }
565}
566
567impl<C: PixelColor, M: Clone> Widget<C, M> for DropdownList<'_, C, M> {
568    fn measure(&mut self, constraints: Constraints) -> Size {
569        let w = constraints.max.width;
570        let h = self.list_height().min(constraints.max.height);
571        constraints.clamp(Size::new(w, h))
572    }
573
574    fn preferred_size(&self) -> (Length, Length) {
575        (Length::Fill, Length::Fixed(self.list_height()))
576    }
577
578    fn arrange(&mut self, rect: Rectangle) {
579        self.rect = rect;
580    }
581
582    fn rect(&self) -> Rectangle {
583        self.rect
584    }
585
586    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
587        let row = self.row_at(point);
588        match phase {
589            TouchPhase::Down => {
590                self.pressed = row;
591                None
592            }
593            TouchPhase::Up => {
594                let armed = self.pressed.take();
595                match (row, armed) {
596                    (Some(r), Some(a)) if r == a => self.on_select.as_ref().map(|cb| cb(r)),
597                    _ => None,
598                }
599            }
600            TouchPhase::Moved => {
601                if row != self.pressed {
602                    self.pressed = None;
603                }
604                None
605            }
606        }
607    }
608
609    fn mark_pressed(&mut self, point: Point) {
610        if let Some(r) = self.row_at(point) {
611            self.pressed = Some(r);
612        }
613    }
614
615    fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
616        if self.on_select.is_none() {
617            return;
618        }
619        for index in 0..self.options.len() {
620            if let Some(id) = self.row_id(index) {
621                out.push(id);
622            }
623        }
624    }
625
626    fn sync_focus(&mut self, focused: Option<WidgetId>) {
627        self.focused = focused.and_then(|target| {
628            (0..self.options.len()).find(|index| self.row_id(*index) == Some(target))
629        });
630    }
631
632    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
633        let index = (0..self.options.len()).find(|index| self.row_id(*index) == Some(target))?;
634        match action {
635            UiAction::Activate => self.on_select.as_ref().map(|cb| cb(index)),
636            _ => None,
637        }
638    }
639
640    fn focus_at(&self, point: Point) -> Option<WidgetId> {
641        self.row_at(point).and_then(|index| self.row_id(index))
642    }
643
644    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
645        let index =
646            (0..self.options.len()).find(|candidate| self.row_id(*candidate) == Some(target))?;
647        Some(Rectangle::new(
648            Point::new(
649                self.rect.top_left.x,
650                self.rect.top_left.y + index as i32 * ROW_HEIGHT as i32,
651            ),
652            Size::new(self.rect.size.width, ROW_HEIGHT),
653        ))
654    }
655
656    fn draw<'t>(
657        &self,
658        renderer: &mut dyn Renderer<C>,
659        theme: &Theme<'t, C>,
660    ) -> Result<(), RenderError> {
661        let bg = theme.background.base;
662        let font = theme.default_font();
663        renderer.fill_rect(self.rect, bg)?;
664        renderer.stroke_rect(self.rect, theme.button.border)?;
665
666        let x = self.rect.top_left.x;
667        let w = self.rect.size.width;
668        for (i, opt) in self.options.iter().enumerate() {
669            let y = self.rect.top_left.y + (i as u32 * ROW_HEIGHT) as i32;
670            let row_rect = Rectangle::new(Point::new(x, y), Size::new(w, ROW_HEIGHT));
671
672            let highlighted = self.pressed == Some(i);
673            let selected = i == self.selected;
674            let focused = self.focused == Some(i);
675            if highlighted {
676                renderer.fill_rect(row_rect, theme.accent.pressed)?;
677            } else if selected {
678                renderer.fill_rect(row_rect, theme.accent.base)?;
679            }
680            let border = if focused {
681                theme.accent.base
682            } else {
683                theme.button.border
684            };
685            renderer.stroke_rect(row_rect, border)?;
686
687            let text_color = if highlighted || selected {
688                theme.accent.on_base
689            } else {
690                theme.background.on_base
691            };
692            let text_y = y + ROW_HEIGHT as i32 / 2 + font.character_size.height as i32 / 3;
693            renderer.draw_text(
694                opt,
695                Point::new(x + TEXT_PAD, text_y),
696                font,
697                text_color,
698                Alignment::Left,
699            )?;
700            if i + 1 < self.options.len() && !focused {
701                let sep_y = y + ROW_HEIGHT as i32 - 1;
702                renderer.fill_rect(
703                    Rectangle::new(Point::new(x, sep_y), Size::new(w, 1)),
704                    theme.background.divider,
705                )?;
706            }
707        }
708        Ok(())
709    }
710}