Skip to main content

frus_widgets/
dropdown.rs

1//! [`DropdownButton`]: a **controlled** dropdown list whose options float above
2//! everything else (through the overlay mechanism), below the header.
3//!
4//! Adjustable width ([`width`](DropdownButton::width)), the **selected** option highlighted
5//! and ticked ([`selected`](DropdownButton::selected)), and **keyboard** navigation: the
6//! header and the options take focus (Enter opens or picks, the arrows move through).
7//!
8//! A choice is a word in the ordinary case, and [`DropdownOption`] is what it is when it is
9//! not: a swatch, a flag, two lines with a subtitle — or a choice that is **there and
10//! cannot be picked**, which a list that simply left it out could not say.
11//!
12//! [`crate::DropdownMenu`] is the same closed set behind a **field** rather than a button:
13//! it filters as it is typed into, and it draws its choices with the rows in this module so
14//! that the two lists cannot drift apart.
15
16use std::rc::Rc;
17
18use frus_core::{
19    BorderRadius, Color, Insets, Path, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder,
20    TextStyle,
21};
22use frus_layout::{Align, Dimension, FlexDirection, Style};
23
24use crate::disabled::{disabled_container, disabled_content};
25use crate::icons::Icons;
26use crate::interaction::Status;
27use crate::menu::{Panel, PanelKind, PanelStyle};
28use crate::portal::Placement;
29use crate::theme::Theme;
30use crate::transparent::Shared;
31use crate::widget::Widget;
32
33const DEFAULT_WIDTH: f32 = 240.0;
34const ROW_H: f32 = 40.0;
35const PAD_X: f32 = 12.0;
36
37/// The style the value and the options are drawn in: what the caller said, else what the
38/// theme says, else the reference's — a dropdown is `titleMedium`.
39///
40/// **Resolved once**, so that the number the box is measured with is the number the glyphs
41/// are drawn at. Resolving is the single place the reader's font setting is applied
42/// (milestone 403); a size that never passes through it is a size the reader cannot change.
43pub(crate) fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
44    over.or(theme.and_then(|t| t.widgets.dropdown.text_style))
45        .unwrap_or_else(|| crate::theme::type_scale(theme).title_medium)
46        .resolved()
47}
48
49/// The room the tick takes on the right of a row, its gap included — kept clear on every
50/// row rather than on the ticked one, so that a choice the caller drew does not change
51/// width when it becomes the selected one.
52const TICK: f32 = 18.0;
53
54/// One row: the header, or an option.
55struct Row<Msg> {
56    /// The row's own words — `None` when the caller drew the choice, in which case their
57    /// widget is child 0.
58    label: Option<String>,
59    /// `[the caller's widget]`, or empty for a row of words.
60    children: Vec<Box<dyn Widget<Msg>>>,
61    width: f32,
62    is_header: bool,
63    /// The currently selected option (highlighted + ticked). Ignored for the header.
64    selected: bool,
65    /// Whether **this row** can be used: the list's availability and the choice's own.
66    enabled: bool,
67    text_style: Option<TextStyle>,
68    /// Whose panel an option is drawn on, and the caller's word about its surface, so a
69    /// row's highlight tints the surface it sits on. Ignored for the header.
70    kind: PanelKind,
71    menu_background: Option<Color>,
72    on_click: Option<Msg>,
73}
74
75impl<Msg> Row<Msg> {
76    fn sizing(&self, theme: Option<&Theme>) -> Style {
77        let line = frus_text::line_box(ROW_H, &label_style(self.text_style, theme), 0.0);
78        if self.children.is_empty() {
79            return Style {
80                width: Dimension::Length(self.width),
81                height: Dimension::Length(line),
82                ..Default::default()
83            };
84        }
85        Style {
86            width: Dimension::Length(self.width),
87            // A choice the caller drew **grows**: a two-line entry is half of why the
88            // form exists, and a fixed height would cut it in two.
89            height: Dimension::Auto,
90            min_height: Dimension::Length(line),
91            flex_direction: FlexDirection::Row,
92            align: Align::Center,
93            // The columns are kept clear by padding rather than by empty siblings, so the
94            // caller's widget gets exactly the room the words would have had.
95            padding: Insets::new(0.0, PAD_X + TICK, 0.0, PAD_X),
96            ..Default::default()
97        }
98    }
99}
100
101impl<Msg: Clone> Widget<Msg> for Row<Msg> {
102    fn style(&self) -> Style {
103        self.sizing(None)
104    }
105
106    fn style_themed(&self, theme: &Theme) -> Style {
107        self.sizing(Some(theme))
108    }
109
110    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
111        &self.children
112    }
113
114    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
115        let o = status.opacity;
116        if self.is_header {
117            // The header is the control, and a control keeps its box: a surface, a
118            // corner and an outline. No state layer while disabled — a hover tint is a
119            // promise that a press would do something — and the outline is the row's
120            // **container**, so it takes the container opacity rather than the content one.
121            let base = theme.scheme.surface_container;
122            let bg = if self.enabled {
123                theme.state_layer(base, theme.on_surface, &status)
124            } else {
125                base
126            };
127            let outline = if self.enabled {
128                theme.border
129            } else {
130                disabled_container(theme)
131            };
132            scene.draw_rect(bounds, bg.fade(o), theme.radius, 1.0, outline.fade(o));
133        } else {
134            // An option is **a strip of the panel**, not a box of its own. It drew a
135            // filled, outlined, rounded rectangle, four pixels from the next, with nothing
136            // behind the column — where the reference's rows are transparent on one
137            // surface and only a highlight marks one out. So: nothing at rest, the
138            // selected option tinted towards the primary, and the state layer on top.
139            let panel = PanelStyle {
140                background: self.menu_background,
141                ..PanelStyle::default()
142            }
143            .background(self.kind, theme);
144            let base = if self.selected && self.enabled {
145                panel.lerp(theme.primary, 0.14)
146            } else {
147                panel
148            };
149            let bg = if self.enabled {
150                theme.state_layer(base, theme.on_surface, &status)
151            } else {
152                base
153            };
154            if bg != panel {
155                scene.fill_rect(bounds, bg.fade(o));
156            }
157        }
158
159        let ink = if self.enabled {
160            theme.on_surface
161        } else {
162            disabled_content(theme)
163        };
164        let style = label_style(self.text_style, Some(theme));
165        let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
166        if let Some(label) = &self.label {
167            scene.text(
168                Point::new(bounds.x + PAD_X, ty),
169                label.clone(),
170                &style,
171                ink.fade(o),
172            );
173        }
174
175        if self.is_header {
176            // A vector "▾" chevron (a downward-pointing triangle), on the right.
177            let cx = bounds.x + self.width - PAD_X - 4.0;
178            let cy = bounds.y + bounds.height * 0.5;
179            let (w, h) = (5.0, 3.0);
180            let tri = Path::new()
181                .move_to(Point::new(cx - w, cy - h))
182                .line_to(Point::new(cx + w, cy - h))
183                .line_to(Point::new(cx, cy + h))
184                .close();
185            let chevron = if self.enabled {
186                theme.muted
187            } else {
188                disabled_content(theme)
189            };
190            scene.fill_path(&tri, chevron.fade(o));
191        } else if self.selected {
192            // The selected option's tick, on the right.
193            let size = 18.0;
194            let x = bounds.x + self.width - PAD_X - size;
195            let y = bounds.y + (bounds.height - size) * 0.5;
196            let path = Icons::CHECK.placed(size, x, y, theme.direction);
197            // The tick stays: which option is chosen is still owed to a reader who cannot
198            // choose another.
199            let check = if self.enabled {
200                theme.primary
201            } else {
202                disabled_content(theme)
203            };
204            scene.fill_path(&path, check.fade(o));
205        }
206    }
207
208    fn on_click(&self) -> Option<Msg> {
209        if !self.enabled {
210            return None;
211        }
212        self.on_click.clone()
213    }
214
215    fn focusable(&self) -> bool {
216        self.enabled && self.on_click.is_some()
217    }
218
219    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
220        // A dropdown row said nothing to a reader before this. Announcing that a row is
221        // unavailable without ever announcing the row would have been announcing an
222        // absence, the same hole `RadioOption` had in milestone 322.
223        //
224        // A choice the caller drew announces nothing here and leaves that to whatever they
225        // drew — a row with no words and a `Button` role would announce an empty button.
226        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
227        let semantics = match &self.label {
228            Some(label) => semantics.label(label.clone()),
229            None if !self.is_header => return None,
230            None => semantics,
231        };
232        let semantics = if self.is_header {
233            semantics
234        } else {
235            semantics.toggled(self.selected)
236        };
237        Some(if self.enabled {
238            semantics.clickable()
239        } else {
240            semantics.disabled(true)
241        })
242    }
243}
244
245/// **One choice in a [`DropdownButton`]'s list.**
246///
247/// [`options`](DropdownButton::options) takes plain strings and is still the right call
248/// for the common case; this is what a choice is when it is not one. A colour swatch
249/// beside a name, a flag, a two-line entry with a subtitle, a heading — and a choice that
250/// is **there but cannot be picked**, which no string can say.
251///
252/// A choice carries no message: [`options`](DropdownButton::options) and
253/// [`options_widgets`](DropdownButton::options_widgets) both map the chosen **index** to
254/// one, so that a list built from an application's own vector says what it means once.
255///
256/// ```
257/// use frus_widgets::{Container, DropdownButton, DropdownOption};
258/// use frus_core::Color;
259///
260/// # #[derive(Clone)] enum Msg { Toggle, Pick(usize) }
261/// let swatch = |c: Color| Container::<Msg>::new().width(14.0).height(14.0).color(c);
262/// let menu = DropdownButton::new("Amber", Msg::Toggle).options_widgets(
263///     true,
264///     vec![
265///         DropdownOption::widget(swatch(Color::rgb8(255, 193, 7))),
266///         DropdownOption::widget(swatch(Color::rgb8(3, 169, 244))),
267///         DropdownOption::new("Out of stock").enabled(false),
268///     ],
269///     Msg::Pick,
270/// );
271/// ```
272pub struct DropdownOption<Msg = crate::callback::Callback> {
273    label: Option<String>,
274    child: Option<Rc<dyn Widget<Msg>>>,
275    enabled: bool,
276}
277
278impl<Msg> DropdownOption<Msg> {
279    /// A choice of words — what [`options`](DropdownButton::options) builds.
280    pub fn new(label: impl Into<String>) -> Self {
281        Self {
282            label: Some(label.into()),
283            child: None,
284            enabled: true,
285        }
286    }
287
288    /// A choice **the caller draws**.
289    ///
290    /// It is laid out in the room the words would have had, with the tick's column kept
291    /// clear, and it **grows**: a choice of words is a row tall, a choice of anything else
292    /// is at least that and as much more as it needs.
293    pub fn widget(child: impl Widget<Msg> + 'static) -> Self {
294        Self {
295            label: None,
296            child: Some(Rc::new(child)),
297            enabled: true,
298        }
299    }
300
301    /// Whether **this choice** can be picked, over the list's own availability.
302    ///
303    /// A choice that cannot be picked is still drawn, still announced, and still ticked if
304    /// it is the one selected — which happens, and is the reason it is drawn rather than
305    /// left out: a list that hides what it cannot offer cannot show what was already
306    /// chosen.
307    #[must_use]
308    pub fn enabled(mut self, enabled: bool) -> Self {
309        self.enabled = enabled;
310        self
311    }
312
313    /// The choice's words, if it has any. `None` for a choice the caller drew.
314    pub(crate) fn label(&self) -> Option<&str> {
315        self.label.as_deref()
316    }
317
318    pub(crate) fn is_enabled(&self) -> bool {
319        self.enabled
320    }
321}
322
323/// Builds one choice's row.
324///
325/// Shared with [`crate::DropdownMenu`], which is the same closed set behind a field rather
326/// than behind a button and must draw its choices identically — two lists that look nearly
327/// alike are worse than one that looks the same.
328pub(crate) fn option_row<Msg: Clone + 'static>(
329    option: &DropdownOption<Msg>,
330    width: f32,
331    selected: bool,
332    enabled: bool,
333    text_style: Option<TextStyle>,
334    panel: (PanelKind, Option<Color>),
335    on_click: Option<Msg>,
336) -> impl Widget<Msg> {
337    Row {
338        label: option.label.clone(),
339        children: option
340            .child
341            .clone()
342            .map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
343            .into_iter()
344            .collect(),
345        width,
346        is_header: false,
347        selected,
348        enabled: enabled && option.enabled,
349        text_style,
350        kind: panel.0,
351        menu_background: panel.1,
352        on_click,
353    }
354}
355
356/// A single-selection dropdown list (a floating menu).
357pub struct DropdownButton<Msg = crate::callback::Callback> {
358    header_label: String,
359    on_toggle: Msg,
360    width: f32,
361    selected: Option<usize>,
362    open: bool,
363    enabled: bool,
364    options: Vec<DropdownOption<Msg>>,
365    text_style: Option<TextStyle>,
366    /// The panel the options float on — see [`menu_background`](Self::menu_background).
367    look: PanelStyle,
368    on_select: Option<Box<dyn Fn(usize) -> Msg>>,
369    children: Vec<Box<dyn Widget<Msg>>>,
370}
371
372impl<Msg: Clone + 'static> DropdownButton<Msg> {
373    /// Creates a list: the current label + the toggle message (open/close).
374    pub fn new(selected_label: impl Into<String>, on_toggle: Msg) -> Self {
375        let mut dropdown = Self {
376            header_label: selected_label.into(),
377            on_toggle,
378            width: DEFAULT_WIDTH,
379            selected: None,
380            open: false,
381            enabled: true,
382            options: Vec::new(),
383            text_style: None,
384            look: PanelStyle::default(),
385            on_select: None,
386            children: Vec::new(),
387        };
388        dropdown.rebuild();
389        dropdown
390    }
391
392    /// The value's and the options' type, over the theme's and the reference's.
393    #[must_use]
394    pub fn text_style(mut self, style: TextStyle) -> Self {
395        self.text_style = Some(style);
396        self.rebuild();
397        self
398    }
399
400    /// **The surface the options float on**, over
401    /// [`DropdownTheme::menu_background`](crate::DropdownTheme::menu_background) and
402    /// `surface_container`.
403    ///
404    /// The options sit on **one panel**: a surface, the corner, eight pixels above and
405    /// below, and a shadow eight high — the height the reference gives a dropdown button's
406    /// list. An option has no box of its own; only the selected one and the one under a
407    /// pointer are tinted.
408    #[must_use]
409    pub fn menu_background(mut self, color: Color) -> Self {
410        self.look.background = Some(color);
411        self.rebuild();
412        self
413    }
414
415    /// What shape the panel is, over the theme's and the framework's corner.
416    #[must_use]
417    pub fn menu_shape(mut self, shape: ShapeBorder) -> Self {
418        self.look.shape = Some(shape);
419        self.rebuild();
420        self
421    }
422
423    /// The shorthand for a rounded panel.
424    #[must_use]
425    pub fn menu_radius(self, radius: impl Into<BorderRadius>) -> Self {
426        self.menu_shape(ShapeBorder::rounded(radius.into()))
427    }
428
429    /// How far off the page the panel sits, in pixels. Eight by default.
430    #[must_use]
431    pub fn menu_elevation(mut self, elevation: f32) -> Self {
432        self.look.elevation = Some(elevation);
433        self.rebuild();
434        self
435    }
436
437    /// The colour of the panel's shadow, over the theme's and the scheme's shadow at 30 %.
438    /// [`Color::TRANSPARENT`] casts none.
439    #[must_use]
440    pub fn menu_shadow_color(mut self, color: Color) -> Self {
441        self.look.shadow_color = Some(color);
442        self.rebuild();
443        self
444    }
445
446    /// The room kept above and below the options, inside the panel. Eight by default.
447    #[must_use]
448    pub fn menu_padding(mut self, padding: Insets) -> Self {
449        self.look.padding = Some(padding);
450        self.rebuild();
451        self
452    }
453
454    /// Whether the list can be opened or chosen from. Disabled it is **inert** - the
455    /// header takes no press, no row takes focus - and it still shows the current choice.
456    ///
457    /// A disabled list is also **never open**: whatever `options` was told, the menu is
458    /// not built, because a floating menu over a control that cannot be chosen from is a
459    /// menu that traps a press and returns nothing.
460    ///
461    /// See [`crate::disabled`] for the whole contract.
462    pub fn enabled(mut self, enabled: bool) -> Self {
463        self.enabled = enabled;
464        self.rebuild();
465        self
466    }
467
468    /// Width of the header and the menu, in logical pixels (240 by default).
469    pub fn width(mut self, width: f32) -> Self {
470        self.width = width;
471        self.rebuild();
472        self
473    }
474
475    /// Index of the **selected** option (highlighted + ticked in the menu).
476    pub fn selected(mut self, index: usize) -> Self {
477        self.selected = Some(index);
478        self.rebuild();
479        self
480    }
481
482    /// Sets the options; if `open`, they float below the header. `on_select` maps
483    /// the chosen index to a message.
484    pub fn options(
485        self,
486        open: bool,
487        labels: &[&str],
488        on_select: impl Fn(usize) -> Msg + 'static,
489    ) -> Self {
490        let options = labels.iter().map(|s| DropdownOption::new(*s)).collect();
491        self.options_widgets(open, options, on_select)
492    }
493
494    /// The same, for choices that are **not** words: a swatch, a flag, two lines, or a
495    /// choice that is there and cannot be picked. See [`DropdownOption`].
496    ///
497    /// `on_select` is given the index into **this** list, so it is the caller's own order
498    /// whatever the widget does with it.
499    pub fn options_widgets(
500        mut self,
501        open: bool,
502        options: Vec<DropdownOption<Msg>>,
503        on_select: impl Fn(usize) -> Msg + 'static,
504    ) -> Self {
505        self.open = open;
506        self.options = options;
507        self.on_select = Some(Box::new(on_select));
508        self.rebuild();
509        self
510    }
511
512    /// Rebuilds the header (and the menu if open) from the current state.
513    fn rebuild(&mut self) {
514        let header = Row {
515            label: Some(self.header_label.clone()),
516            children: Vec::new(),
517            width: self.width,
518            is_header: true,
519            selected: false,
520            enabled: self.enabled,
521            text_style: self.text_style,
522            kind: PanelKind::Dropdown,
523            menu_background: None,
524            on_click: Some(self.on_toggle.clone()),
525        };
526        self.children = vec![Box::new(header)];
527
528        if self.open && self.enabled && !self.options.is_empty() {
529            // The options are the panel's own children, contiguous: the four-pixel gutter
530            // they used to leave would show the page through the middle of the list.
531            let mut rows: Vec<Box<dyn Widget<Msg>>> = Vec::with_capacity(self.options.len());
532            for (index, option) in self.options.iter().enumerate() {
533                let on_click = self
534                    .on_select
535                    .as_ref()
536                    .filter(|_| self.enabled && option.enabled)
537                    .map(|f| f(index));
538                rows.push(Box::new(option_row(
539                    option,
540                    self.width,
541                    self.selected == Some(index),
542                    self.enabled,
543                    self.text_style,
544                    (PanelKind::Dropdown, self.look.background),
545                    on_click,
546                )));
547            }
548            self.children
549                .push(Box::new(Panel::new(PanelKind::Dropdown, self.look, rows)));
550        }
551    }
552}
553
554impl<Msg: Clone> Widget<Msg> for DropdownButton<Msg> {
555    fn style(&self) -> Style {
556        Style {
557            flex_direction: FlexDirection::Column,
558            ..Default::default()
559        }
560    }
561
562    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
563        &self.children
564    }
565
566    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
567
568    fn on_click(&self) -> Option<Msg> {
569        None
570    }
571
572    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
573        self.children
574            .get(1)
575            .map(|menu| (menu.as_ref(), Placement::Below))
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::{build_ui, Runtime, Size};
583    use frus_core::Primitive;
584
585    #[derive(Clone, Debug, PartialEq)]
586    enum Msg {
587        Toggle,
588        Select(usize),
589    }
590
591    /// **A choice can be a widget the caller drew.** A colour swatch beside a name, a
592    /// flag, a two-line entry — none of which a `&str` can say, and all of which a list of
593    /// choices is routinely asked for.
594    ///
595    /// It is laid out in the room the words would have had, with the tick's column kept
596    /// clear, and it **grows**: the row is a row tall or as much more as the drawing needs.
597    #[test]
598    fn a_choice_can_be_a_widget_the_caller_drew() {
599        let mark = frus_core::Color::rgb(0.9, 0.1, 0.1);
600        let open = DropdownButton::new("Pick", Msg::Toggle)
601            .width(200.0)
602            .options_widgets(
603                true,
604                vec![
605                    DropdownOption::widget(
606                        crate::Container::<Msg>::new()
607                            .width(40.0)
608                            .height(60.0)
609                            .color(mark),
610                    ),
611                    DropdownOption::new("B"),
612                ],
613                Msg::Select,
614            );
615        let (menu, _) = Widget::<Msg>::overlay(&open).unwrap();
616        let ui = build_ui(
617            menu,
618            Size::new(220.0, 200.0),
619            &Runtime::default(),
620            &Theme::default(),
621        );
622        let drawn = rects(ui.scene())
623            .into_iter()
624            .find(|(_, color)| *color == mark)
625            .expect("the caller's own widget is drawn");
626        assert_eq!(drawn.0.x, PAD_X, "it starts where the words would have");
627        assert_eq!(drawn.0.height, 60.0, "and keeps the height it asked for");
628    }
629
630    /// **One choice can be unavailable while the list is not.** It was the whole list or
631    /// nothing, so an application with one choice it could not offer had to leave it out —
632    /// and a list that hides what it cannot offer cannot show what was already chosen.
633    ///
634    /// It is still drawn, still announced, and still ticked when it is the selected one.
635    #[test]
636    fn one_choice_can_be_unavailable_while_the_list_is_not() {
637        let open = DropdownButton::new("Pick", Msg::Toggle).options_widgets(
638            true,
639            vec![
640                DropdownOption::new("A"),
641                DropdownOption::new("B").enabled(false),
642            ],
643            Msg::Select,
644        );
645        let menu = &Widget::<Msg>::children(&open)[1];
646        assert_eq!(menu.children()[0].on_click(), Some(Msg::Select(0)));
647        assert_eq!(
648            menu.children()[1].on_click(),
649            None,
650            "the choice that said it was unavailable answers nothing"
651        );
652        assert!(
653            !menu.children()[1].focusable(),
654            "and does not stop the keyboard on the way past"
655        );
656        let announced = menu.children()[1].semantics().expect("still announced");
657        assert!(announced.disabled, "as unavailable");
658        assert_eq!(announced.label.as_deref(), Some("B"), "and by name");
659    }
660
661    /// Every filled rectangle in the frame, innermost layers included.
662    fn rects(scene: &frus_core::Scene) -> Vec<(Rect, frus_core::Color)> {
663        fn walk(primitives: &[Primitive], out: &mut Vec<(Rect, frus_core::Color)>) {
664            for p in primitives {
665                match p {
666                    Primitive::Rect { rect, color, .. } => out.push((*rect, *color)),
667                    Primitive::Layer { primitives, .. } => walk(primitives, out),
668                    _ => {}
669                }
670            }
671        }
672        let mut out = Vec::new();
673        walk(scene.primitives(), &mut out);
674        out
675    }
676
677    #[test]
678    fn closed_has_no_overlay_open_floats_options() {
679        let closed =
680            DropdownButton::new("Pick one", Msg::Toggle).options(false, &["A", "B"], Msg::Select);
681        assert!(
682            Widget::<Msg>::overlay(&closed).is_none(),
683            "closed: no overlay"
684        );
685
686        let open =
687            DropdownButton::new("Pick one", Msg::Toggle).options(true, &["A", "B"], Msg::Select);
688        assert!(
689            Widget::<Msg>::overlay(&open).is_some(),
690            "open: a floating menu"
691        );
692        let menu = &Widget::<Msg>::children(&open)[1];
693        assert_eq!(menu.children().len(), 2);
694        assert_eq!(menu.children()[1].on_click(), Some(Msg::Select(1)));
695    }
696
697    #[test]
698    fn header_and_options_are_keyboard_focusable() {
699        let open = DropdownButton::new("Pick", Msg::Toggle).options(true, &["A", "B"], Msg::Select);
700        // A focusable header (opens from the keyboard) + 2 options.
701        assert!(Widget::<Msg>::children(&open)[0].focusable());
702        let menu = &Widget::<Msg>::children(&open)[1];
703        assert!(menu.children()[0].focusable() && menu.children()[1].focusable());
704    }
705
706    #[test]
707    fn selected_option_is_highlighted_and_checked() {
708        let open = DropdownButton::new("Pick", Msg::Toggle)
709            .selected(1)
710            .options(true, &["A", "B"], Msg::Select)
711            .width(200.0);
712        // The menu is an overlay: render it on its own to read its primitives.
713        let (menu, _) = Widget::<Msg>::overlay(&open).unwrap();
714        let ui = build_ui(
715            menu,
716            Size::new(220.0, 120.0),
717            &Runtime::default(),
718            &Theme::default(),
719        );
720        let theme = Theme::default();
721        // The selected option's tick (a filled path).
722        let has_check = ui
723            .scene()
724            .primitives()
725            .iter()
726            .any(|p| matches!(p, Primitive::Path { .. }));
727        assert!(has_check, "the selected option is ticked");
728        // The selected option's primary-tinted background.
729        let sel = theme.scheme.surface_container.lerp(theme.primary, 0.14);
730        let has_tint = ui.scene().primitives().iter().any(|p| {
731            matches!(
732                p,
733                Primitive::Rect { color, .. } if color.fade(1.0) == sel.fade(1.0)
734            )
735        });
736        assert!(has_tint, "the selected option is highlighted");
737    }
738
739    #[test]
740    fn a_disabled_list_is_inert_and_cannot_be_open() {
741        let dead = DropdownButton::new("Option B", Msg::Toggle)
742            .selected(1)
743            .options(true, &["A", "B"], Msg::Select)
744            .enabled(false);
745        // Told to be open, and not open: a floating menu over a control that cannot be
746        // chosen from would trap a press and return nothing.
747        assert!(Widget::<Msg>::overlay(&dead).is_none(), "no floating menu");
748        assert_eq!(Widget::<Msg>::children(&dead).len(), 1, "header only");
749
750        let header = &Widget::<Msg>::children(&dead)[0];
751        assert_eq!(header.on_click(), None, "the header takes no press");
752        assert!(!header.focusable(), "and no focus");
753        let semantics = header.semantics().expect("still announced");
754        assert!(semantics.disabled, "announced as unavailable");
755        assert_eq!(
756            semantics.label.as_deref(),
757            Some("Option B"),
758            "and still says which option is current"
759        );
760    }
761
762    #[test]
763    fn a_live_list_is_untouched_by_it() {
764        let live = DropdownButton::new("Pick one", Msg::Toggle)
765            .options(true, &["A", "B"], Msg::Select)
766            .enabled(true);
767        assert!(Widget::<Msg>::overlay(&live).is_some());
768        assert_eq!(
769            Widget::<Msg>::children(&live)[0].on_click(),
770            Some(Msg::Toggle)
771        );
772    }
773
774    use crate::menu::probe::{blurred, colour_of, crisp, height_of};
775
776    /// Three options, open, nothing selected, in a frame with room for the shadow.
777    fn open_list() -> DropdownButton<Msg> {
778        DropdownButton::new("Pick", Msg::Toggle)
779            .width(200.0)
780            .options(true, &["A", "B", "C"], Msg::Select)
781    }
782
783    fn frame(list: &DropdownButton<Msg>, theme: &Theme) -> crate::Ui<Msg> {
784        build_ui(list, Size::new(400.0, 400.0), &Runtime::default(), theme)
785    }
786
787    /// **The options float on one panel and draw no box of their own.** Each was a filled,
788    /// outlined, rounded rectangle four pixels from the next, with nothing behind the
789    /// column — where the reference's list is one surface with its rows inside.
790    ///
791    /// With nothing selected and no pointer, the frame holds exactly two crisp
792    /// rectangles: the header, which is a control and keeps its outline, and the panel,
793    /// in `surface_container`, rounded, unoutlined, as wide as the options, three rows tall
794    /// with eight above and eight below, and starting where the header ends.
795    #[test]
796    fn the_options_float_on_one_panel_and_draw_no_box_of_their_own() {
797        let theme = Theme::default();
798        let ui = frame(&open_list(), &theme);
799        let painted = crisp(ui.scene());
800        assert_eq!(painted.len(), 2, "the header and one panel: {painted:#?}");
801        let (header, panel) = (painted[0], painted[1]);
802        assert_eq!(header.border_width, 1.0, "the header keeps its outline");
803        let row = frus_text::line_box(ROW_H, &label_style(None, Some(&theme)), 0.0);
804        assert_eq!(panel.color, theme.scheme.surface_container);
805        assert_eq!(panel.border_width, 0.0, "the panel has no outline");
806        assert!(panel.radius != BorderRadius::ZERO, "and is rounded");
807        // Under the header's box, which is a row tall, and the four pixels an overlay
808        // placed below its anchor always leaves.
809        assert_eq!(panel.rect.y, header.rect.y + row + 4.0, "under the header");
810        assert_eq!(panel.rect.width, 200.0);
811        assert_eq!(
812            panel.rect.height,
813            3.0 * row + 16.0,
814            "no gutters between rows"
815        );
816    }
817
818    /// **The list casts a shadow eight high**, the height the reference's dropdown button
819    /// gives its list: a blur of forty, dropped sixteen, in the scheme's shadow at 30 %.
820    #[test]
821    fn the_list_casts_a_shadow_eight_high() {
822        let theme = Theme::default();
823        let ui = frame(&open_list(), &theme);
824        let panel = crisp(ui.scene())[1];
825        let shadows = blurred(ui.scene());
826        assert_eq!(
827            shadows.len(),
828            3,
829            "one shadow, under the panel: {shadows:#?}"
830        );
831        assert_eq!(height_of(ui.scene()), 8.0);
832        assert_eq!(
833            shadows[1].rect.y + shadows[1].rect.height * 0.5,
834            panel.rect.y + panel.rect.height * 0.5 + 8.0,
835            "the second shadow drops by the height"
836        );
837        assert_eq!(colour_of(ui.scene()), Some(theme.scheme.shadow));
838
839        let closed = DropdownButton::new("Pick", Msg::Toggle).options(false, &["A"], Msg::Select);
840        assert!(
841            blurred(frame(&closed, &theme).scene()).is_empty(),
842            "and a shut list casts nothing"
843        );
844    }
845
846    /// **The panel answers to its theme, and to its caller over the theme** — surface,
847    /// corner, height, shadow colour and room — and the selected option's tint is taken
848    /// from the surface it sits on, not from the one the framework would have picked.
849    #[test]
850    fn the_panel_answers_to_its_theme_and_to_its_caller() {
851        let mut theme = Theme::default();
852        let (surface, shade) = (
853            frus_core::Color::rgb(0.2, 0.4, 0.6),
854            frus_core::Color::rgba(0.0, 0.0, 0.5, 0.5),
855        );
856        theme.widgets.dropdown.menu_background = Some(surface);
857        theme.widgets.dropdown.menu_radius = Some(3.0);
858        theme.widgets.dropdown.menu_elevation = Some(2.0);
859        theme.widgets.dropdown.menu_shadow_color = Some(shade);
860        theme.widgets.dropdown.menu_padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
861        let row = frus_text::line_box(ROW_H, &label_style(None, Some(&theme)), 0.0);
862
863        let ui = frame(&open_list().selected(0), &theme);
864        let painted = crisp(ui.scene());
865        let panel = *painted
866            .iter()
867            .find(|r| r.color == surface)
868            .expect("the theme's surface");
869        assert_eq!(panel.radius, BorderRadius::uniform(3.0));
870        assert_eq!(panel.rect.height, 3.0 * row + 40.0);
871        assert!(
872            painted
873                .iter()
874                .any(|r| r.color == surface.lerp(theme.primary, 0.14)),
875            "the selected option is tinted from the theme's surface: {painted:#?}"
876        );
877        assert_eq!(
878            (height_of(ui.scene()), colour_of(ui.scene())),
879            (2.0, Some(shade))
880        );
881
882        let own = frus_core::Color::rgb(0.9, 0.9, 0.1);
883        let own_shade = frus_core::Color::rgba(0.5, 0.0, 0.0, 0.4);
884        let told = open_list()
885            .menu_background(own)
886            .menu_radius(9.0)
887            .menu_elevation(1.0)
888            .menu_shadow_color(own_shade)
889            .menu_padding(Insets::ZERO)
890            .selected(0);
891        let ui = frame(&told, &theme);
892        let painted = crisp(ui.scene());
893        let panel = *painted
894            .iter()
895            .find(|r| r.color == own)
896            .expect("the caller's surface");
897        assert_eq!(panel.radius, BorderRadius::uniform(9.0));
898        assert_eq!(panel.rect.height, 3.0 * row);
899        assert!(painted
900            .iter()
901            .any(|r| r.color == own.lerp(theme.primary, 0.14)));
902        assert_eq!(
903            (height_of(ui.scene()), colour_of(ui.scene())),
904            (1.0, Some(own_shade))
905        );
906    }
907
908    /// **A transparent shadow colour casts nothing**, on the list or on its theme
909    /// (milestone 529), and neither does a flat panel.
910    #[test]
911    fn a_transparent_shadow_or_a_flat_list_casts_nothing() {
912        let theme = Theme::default();
913        let clear = open_list().menu_shadow_color(frus_core::Color::TRANSPARENT);
914        assert!(blurred(frame(&clear, &theme).scene()).is_empty());
915        let flat = open_list().menu_elevation(0.0);
916        assert!(blurred(frame(&flat, &theme).scene()).is_empty());
917
918        let mut themed = Theme::default();
919        themed.widgets.dropdown.menu_shadow_color = Some(frus_core::Color::TRANSPARENT);
920        assert!(blurred(frame(&open_list(), &themed).scene()).is_empty());
921    }
922
923    /// **The keyboard still walks the options and a press still picks one**, now that
924    /// they sit in a panel: the options are focusable, the second one answers where it is
925    /// drawn, and the panel's own room above the first swallows a press.
926    #[test]
927    fn the_options_still_answer_inside_the_panel() {
928        let theme = Theme::default();
929        let ui = frame(&open_list(), &theme);
930        let (header, panel) = {
931            let painted = crisp(ui.scene());
932            (painted[0].rect, painted[1].rect)
933        };
934        let row = frus_text::line_box(ROW_H, &label_style(None, Some(&theme)), 0.0);
935        let press = |y: f32| {
936            ui.hit(frus_core::Point::new(100.0, y))
937                .and_then(|id| ui.msg_for(id))
938        };
939        assert_eq!(press(panel.y + 8.0 + row * 1.5), Some(Msg::Select(1)));
940        assert_eq!(press(panel.y + 4.0), None, "the room above the first row");
941        assert_eq!(press(header.y + header.height * 0.5), Some(Msg::Toggle));
942        let list = open_list();
943        let menu = &Widget::<Msg>::children(&list)[1];
944        assert!(menu.children().iter().all(|row| row.focusable()));
945    }
946}