Skip to main content

rlvgl_widgets/
dropdown.rs

1//! Closed-trigger + open-list selector widget (LPAR-13 §5.C).
2//!
3//! A [`Dropdown`] occupies a single [`Rect`] and operates in two states:
4//!
5//! - **Closed** — draws a trigger button showing the selected option text plus
6//!   an optional indicator symbol at the right edge.
7//! - **Open** — draws the trigger in the upper (or lower, for `Up` direction)
8//!   portion and an option list in the remaining viewport.  The list is confined
9//!   to `Dropdown::bounds()` (v1; popover/z-order overlay is deferred-Coupled).
10//!
11//! # Key navigation (LPAR-12/LPAR-13 §5.J)
12//!
13//! The app wires `ObjectEvent::Key` to the imperative helpers:
14//!
15//! ```ignore
16//! // down-arrow → navigate_next, up-arrow → navigate_prev,
17//! // enter      → activate_selected, escape → close_key
18//! dropdown.navigate_next();
19//! dropdown.activate_selected();
20//! ```
21
22use alloc::{string::String, vec::Vec};
23use rlvgl_core::draw::draw_widget_bg;
24use rlvgl_core::event::Event;
25use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
26use rlvgl_core::renderer::Renderer;
27use rlvgl_core::style::Style;
28use rlvgl_core::widget::{Color, Rect, Widget};
29
30/// Row height in pixels, matching [`crate::list::List`]'s convention.
31const ROW_HEIGHT: i32 = 16;
32/// Approximate height of the trigger button.
33const TRIGGER_HEIGHT: i32 = 20;
34
35/// Whether the open list appears below or above the trigger.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DropdownDir {
38    /// List opens below the trigger (default).
39    Down,
40    /// List opens above the trigger.
41    Up,
42}
43
44/// Closed-trigger + open-list selector widget (LPAR-13 §5.C).
45///
46/// Stores option strings as owned [`String`]s so callers are not required to
47/// keep the source slice alive.
48pub struct Dropdown {
49    bounds: Rect,
50    options: Vec<String>,
51    /// Index of the currently selected option.
52    selected: usize,
53    /// Whether the option list is currently visible.
54    open: bool,
55    /// Which side the list opens toward.
56    dir: DropdownDir,
57    /// Optional indicator glyph drawn at the right edge of the trigger.
58    symbol: Option<String>,
59    /// Whether the selected item is drawn differently inside the open list.
60    selected_highlight: bool,
61    /// Background + border style for the trigger button (`Part::MAIN`).
62    pub style: Style,
63    /// Style applied to option rows (`Part::ITEMS`).
64    pub item_style: Style,
65    /// Highlight color for the selected row (`Part::SELECTED`).
66    pub selected_color: Color,
67    /// Text color for option labels.
68    pub text_color: Color,
69    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
70    /// when unset.
71    font: WidgetFont,
72}
73
74impl Dropdown {
75    /// Create a [`Dropdown`] occupying `bounds` with no options and the first
76    /// option selected.
77    pub fn new(bounds: Rect) -> Self {
78        Self {
79            bounds,
80            options: Vec::new(),
81            selected: 0,
82            open: false,
83            dir: DropdownDir::Down,
84            symbol: None,
85            selected_highlight: true,
86            style: Style::default(),
87            item_style: Style {
88                bg_color: Color(240, 240, 240, 255),
89                ..Style::default()
90            },
91            selected_color: Color(160, 200, 240, 255),
92            text_color: Color(30, 30, 30, 255),
93            font: WidgetFont::new(),
94        }
95    }
96
97    /// Assign the font used to render this widget (FONT-00 §5); resolves to
98    /// `FONT_6X10` when unset.
99    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
100        self.font.set(font);
101    }
102
103    // ── Options ───────────────────────────────────────────────────────────
104
105    /// Replace the option list.
106    ///
107    /// `selected` is reset to 0 regardless of the previous value.
108    pub fn set_options(&mut self, options: &[impl AsRef<str>]) {
109        self.options = options.iter().map(|s| s.as_ref().into()).collect();
110        self.selected = 0;
111    }
112
113    /// Return a slice of all option strings.
114    pub fn options(&self) -> &[String] {
115        &self.options
116    }
117
118    /// Return the number of options.
119    pub fn option_count(&self) -> usize {
120        self.options.len()
121    }
122
123    // ── Selection ─────────────────────────────────────────────────────────
124
125    /// Set the selected index (clamped to `0..options.len()`).
126    pub fn set_selected(&mut self, index: usize) {
127        self.selected = index.min(self.options.len().saturating_sub(1));
128    }
129
130    /// Return the currently selected index.
131    pub fn selected(&self) -> usize {
132        self.selected
133    }
134
135    /// Return the text of the selected option, or `""` when the list is empty.
136    pub fn selected_text(&self) -> &str {
137        self.options
138            .get(self.selected)
139            .map(|s| s.as_str())
140            .unwrap_or("")
141    }
142
143    // ── Open / close ──────────────────────────────────────────────────────
144
145    /// Open the option list.
146    pub fn open(&mut self) {
147        self.open = true;
148    }
149
150    /// Close the option list without changing the selection.
151    pub fn close(&mut self) {
152        self.open = false;
153    }
154
155    /// Toggle between open and closed states.
156    pub fn toggle(&mut self) {
157        self.open = !self.open;
158    }
159
160    /// Return `true` when the option list is visible.
161    pub fn is_open(&self) -> bool {
162        self.open
163    }
164
165    // ── Direction and symbol ──────────────────────────────────────────────
166
167    /// Set the direction the open list grows relative to the trigger.
168    pub fn set_dir(&mut self, dir: DropdownDir) {
169        self.dir = dir;
170    }
171
172    /// Return the current open direction.
173    pub fn dir(&self) -> DropdownDir {
174        self.dir
175    }
176
177    /// Set an optional indicator glyph drawn at the right of the trigger.
178    pub fn set_symbol(&mut self, sym: Option<&str>) {
179        self.symbol = sym.map(|s| s.into());
180    }
181
182    /// Return the indicator glyph, if any.
183    pub fn symbol(&self) -> Option<&str> {
184        self.symbol.as_deref()
185    }
186
187    // ── Highlight ─────────────────────────────────────────────────────────
188
189    /// Set whether the selected option is highlighted inside the open list.
190    pub fn set_selected_highlight(&mut self, enable: bool) {
191        self.selected_highlight = enable;
192    }
193
194    /// Return whether selected-item highlighting is enabled.
195    pub fn selected_highlight(&self) -> bool {
196        self.selected_highlight
197    }
198
199    // ── Key navigation helpers (LPAR-13 §5.J) ────────────────────────────
200
201    /// Move the highlighted selection one step downward, wrapping at the end.
202    ///
203    /// Wire to `ObjectEvent::Key(Key::ArrowDown)` via a node handler.
204    pub fn navigate_next(&mut self) {
205        if self.options.is_empty() {
206            return;
207        }
208        self.selected = (self.selected + 1) % self.options.len();
209    }
210
211    /// Move the highlighted selection one step upward, wrapping at the start.
212    ///
213    /// Wire to `ObjectEvent::Key(Key::ArrowUp)` via a node handler.
214    pub fn navigate_prev(&mut self) {
215        if self.options.is_empty() {
216            return;
217        }
218        let n = self.options.len();
219        self.selected = (self.selected + n - 1) % n;
220    }
221
222    /// Confirm the current highlighted selection and close the list.
223    ///
224    /// Wire to `ObjectEvent::Key(Key::Enter)` via a node handler.
225    pub fn activate_selected(&mut self) {
226        self.open = false;
227    }
228
229    /// Close without changing the selection.
230    ///
231    /// Wire to `ObjectEvent::Key(Key::Escape)` via a node handler.
232    pub fn close_key(&mut self) {
233        self.open = false;
234    }
235
236    // ── Internal geometry helpers ─────────────────────────────────────────
237
238    /// Return the bounds of the trigger button area.
239    fn trigger_rect(&self) -> Rect {
240        match self.dir {
241            DropdownDir::Down => Rect {
242                x: self.bounds.x,
243                y: self.bounds.y,
244                width: self.bounds.width,
245                height: TRIGGER_HEIGHT.min(self.bounds.height),
246            },
247            DropdownDir::Up => {
248                let h = TRIGGER_HEIGHT.min(self.bounds.height);
249                Rect {
250                    x: self.bounds.x,
251                    y: self.bounds.y + self.bounds.height - h,
252                    width: self.bounds.width,
253                    height: h,
254                }
255            }
256        }
257    }
258
259    /// Return the bounds of the open list area (below or above the trigger).
260    fn list_rect(&self) -> Rect {
261        let tr = self.trigger_rect();
262        match self.dir {
263            DropdownDir::Down => Rect {
264                x: self.bounds.x,
265                y: tr.y + tr.height,
266                width: self.bounds.width,
267                height: (self.bounds.height - tr.height).max(0),
268            },
269            DropdownDir::Up => Rect {
270                x: self.bounds.x,
271                y: self.bounds.y,
272                width: self.bounds.width,
273                height: (self.bounds.height - tr.height).max(0),
274            },
275        }
276    }
277}
278
279impl Widget for Dropdown {
280    fn bounds(&self) -> Rect {
281        self.bounds
282    }
283
284    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
285        Some(&mut self.font)
286    }
287
288    fn set_bounds(&mut self, bounds: Rect) {
289        self.bounds = bounds;
290    }
291
292    fn draw(&self, renderer: &mut dyn Renderer) {
293        let a = self.style.alpha;
294        let font = self.font.resolve();
295
296        // ── Trigger ───────────────────────────────────────────────────────
297        let tr = self.trigger_rect();
298        draw_widget_bg(renderer, tr, &self.style);
299
300        // Selected text — shaped LTR
301        let text = self.selected_text();
302        let baseline = tr.y + 14; // approximate descent below top
303        let text_pos = (tr.x + 4, baseline);
304        let shaped = shape_text_ltr(font, text, text_pos, 0);
305        renderer.draw_text_shaped(&shaped, (0, 0), self.text_color.with_alpha(a));
306
307        // Optional indicator symbol at right edge
308        if let Some(sym) = self.symbol.as_deref() {
309            let sym_x = tr.x + tr.width - 14;
310            let shaped_sym = shape_text_ltr(font, sym, (sym_x, baseline), 0);
311            renderer.draw_text_shaped(&shaped_sym, (0, 0), self.text_color.with_alpha(a));
312        } else {
313            // Draw a simple down-arrow triangle using fill_rect primitives.
314            // The triangle is approximated as a stack of filled horizontal strips.
315            let tip_x = tr.x + tr.width - 10;
316            let tip_y = tr.y + 8;
317            let tri_w = 6i32;
318            for row in 0..4i32 {
319                let w = tri_w - row * 2;
320                if w <= 0 {
321                    break;
322                }
323                renderer.fill_rect(
324                    Rect {
325                        x: tip_x + row,
326                        y: tip_y + row,
327                        width: w,
328                        height: 1,
329                    },
330                    self.text_color.with_alpha(a),
331                );
332            }
333        }
334
335        // ── Open list ─────────────────────────────────────────────────────
336        if !self.open {
337            return;
338        }
339        let lr = self.list_rect();
340        if lr.height <= 0 {
341            return;
342        }
343
344        draw_widget_bg(renderer, lr, &self.item_style);
345
346        let item_a = self.item_style.alpha;
347        for (i, option) in self.options.iter().enumerate() {
348            let row_y = lr.y + i as i32 * ROW_HEIGHT;
349            if row_y + ROW_HEIGHT < lr.y {
350                continue;
351            }
352            if row_y >= lr.y + lr.height {
353                break;
354            }
355
356            let row_rect = Rect {
357                x: lr.x,
358                y: row_y,
359                width: lr.width,
360                height: ROW_HEIGHT.min(lr.y + lr.height - row_y),
361            };
362
363            // Highlight the selected row
364            if self.selected_highlight && self.selected == i {
365                renderer.fill_rect(row_rect, self.selected_color.with_alpha(item_a));
366            }
367
368            // Item text
369            let baseline = row_y + ROW_HEIGHT - 2;
370            let opt_pos = (lr.x + 4, baseline);
371            let shaped_opt = shape_text_ltr(font, option, opt_pos, 0);
372            renderer.draw_text_shaped(&shaped_opt, (0, 0), self.text_color.with_alpha(item_a));
373        }
374    }
375
376    fn handle_event(&mut self, event: &Event) -> bool {
377        match event {
378            Event::PressRelease { x, y } => {
379                let tr = self.trigger_rect();
380                // Click on trigger toggles open/close
381                if *x >= tr.x && *x < tr.x + tr.width && *y >= tr.y && *y < tr.y + tr.height {
382                    self.open = !self.open;
383                    return true;
384                }
385
386                // Click on an option row commits that option
387                if self.open {
388                    let lr = self.list_rect();
389                    if *x >= lr.x && *x < lr.x + lr.width && *y >= lr.y && *y < lr.y + lr.height {
390                        let idx = ((*y - lr.y) / ROW_HEIGHT) as usize;
391                        if idx < self.options.len() {
392                            self.selected = idx;
393                            self.open = false;
394                            return true;
395                        }
396                    }
397                }
398                false
399            }
400            _ => false,
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use alloc::string::ToString;
409
410    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
411        Rect {
412            x,
413            y,
414            width: w,
415            height: h,
416        }
417    }
418
419    struct NullRenderer;
420    impl rlvgl_core::renderer::Renderer for NullRenderer {
421        fn fill_rect(&mut self, _: Rect, _: Color) {}
422        fn draw_text(&mut self, _: (i32, i32), _: &str, _: Color) {}
423    }
424
425    #[test]
426    fn new_starts_closed() {
427        let d = Dropdown::new(rect(0, 0, 120, 120));
428        assert!(!d.is_open());
429        assert_eq!(d.selected(), 0);
430    }
431
432    #[test]
433    fn set_options_resets_selection() {
434        let mut d = Dropdown::new(rect(0, 0, 120, 120));
435        d.set_options(&["A", "B", "C"]);
436        d.set_selected(2);
437        assert_eq!(d.selected(), 2);
438        d.set_options(&["X", "Y"]);
439        assert_eq!(d.selected(), 0);
440    }
441
442    #[test]
443    fn options_are_owned() {
444        let mut d = Dropdown::new(rect(0, 0, 120, 120));
445        {
446            let opts = alloc::vec!["Alpha".to_string(), "Beta".to_string()];
447            d.set_options(&opts);
448        }
449        // opts dropped — widget still owns the data
450        assert_eq!(d.option_count(), 2);
451        assert_eq!(d.options()[0], "Alpha");
452    }
453
454    #[test]
455    fn open_close_toggle() {
456        let mut d = Dropdown::new(rect(0, 0, 120, 120));
457        d.open();
458        assert!(d.is_open());
459        d.close();
460        assert!(!d.is_open());
461        d.toggle();
462        assert!(d.is_open());
463        d.toggle();
464        assert!(!d.is_open());
465    }
466
467    #[test]
468    fn navigate_next_wraps() {
469        let mut d = Dropdown::new(rect(0, 0, 120, 120));
470        d.set_options(&["A", "B", "C"]);
471        d.navigate_next();
472        assert_eq!(d.selected(), 1);
473        d.navigate_next();
474        assert_eq!(d.selected(), 2);
475        d.navigate_next(); // wrap
476        assert_eq!(d.selected(), 0);
477    }
478
479    #[test]
480    fn navigate_prev_wraps() {
481        let mut d = Dropdown::new(rect(0, 0, 120, 120));
482        d.set_options(&["A", "B", "C"]);
483        d.navigate_prev(); // 0 → wrap → 2
484        assert_eq!(d.selected(), 2);
485    }
486
487    #[test]
488    fn activate_selected_closes() {
489        let mut d = Dropdown::new(rect(0, 0, 120, 120));
490        d.set_options(&["A", "B"]);
491        d.open();
492        d.navigate_next();
493        d.activate_selected();
494        assert!(!d.is_open());
495        assert_eq!(d.selected(), 1);
496    }
497
498    #[test]
499    fn close_key_closes_without_changing_selection() {
500        let mut d = Dropdown::new(rect(0, 0, 120, 120));
501        d.set_options(&["A", "B", "C"]);
502        d.set_selected(1);
503        d.open();
504        d.close_key();
505        assert!(!d.is_open());
506        assert_eq!(d.selected(), 1);
507    }
508
509    #[test]
510    fn press_release_on_trigger_toggles_open() {
511        let mut d = Dropdown::new(rect(0, 0, 120, 120));
512        d.set_options(&["A"]);
513        // Click inside trigger rect
514        assert!(d.handle_event(&Event::PressRelease { x: 5, y: 5 }));
515        assert!(d.is_open());
516        assert!(d.handle_event(&Event::PressRelease { x: 5, y: 5 }));
517        assert!(!d.is_open());
518    }
519
520    #[test]
521    fn press_release_on_option_commits_selection() {
522        let mut d = Dropdown::new(rect(0, 0, 120, 120));
523        d.set_options(&["A", "B", "C"]);
524        d.open();
525        // List starts at y=20 (TRIGGER_HEIGHT=20), row 1 starts at y=36
526        let list_y = 20 + ROW_HEIGHT;
527        assert!(d.handle_event(&Event::PressRelease {
528            x: 5,
529            y: list_y + 2
530        }));
531        assert!(!d.is_open());
532        assert_eq!(d.selected(), 1);
533    }
534
535    #[test]
536    fn set_selected_clamps() {
537        let mut d = Dropdown::new(rect(0, 0, 120, 120));
538        d.set_options(&["A", "B"]);
539        d.set_selected(999);
540        assert_eq!(d.selected(), 1);
541    }
542
543    #[test]
544    fn selected_text_returns_correct_option() {
545        let mut d = Dropdown::new(rect(0, 0, 120, 120));
546        d.set_options(&["Alpha", "Beta"]);
547        d.set_selected(1);
548        assert_eq!(d.selected_text(), "Beta");
549    }
550
551    #[test]
552    fn set_bounds_adopted() {
553        let mut d = Dropdown::new(rect(0, 0, 120, 120));
554        d.set_bounds(rect(10, 20, 200, 150));
555        assert_eq!(d.bounds(), rect(10, 20, 200, 150));
556    }
557
558    #[test]
559    fn draw_does_not_panic() {
560        let mut d = Dropdown::new(rect(0, 0, 120, 120));
561        d.set_options(&["Option 1", "Option 2", "Option 3"]);
562        d.open();
563        let mut r = NullRenderer;
564        d.draw(&mut r);
565    }
566
567    #[test]
568    fn navigate_empty_options_no_panic() {
569        let mut d = Dropdown::new(rect(0, 0, 120, 120));
570        d.navigate_next();
571        d.navigate_prev();
572        // No panic, selected stays 0
573        assert_eq!(d.selected(), 0);
574    }
575}