Skip to main content

frus_widgets/
dropdownmenu.rs

1//! [`DropdownMenu`]: a dropdown that **looks like a text field**, filters as you type, and
2//! drops its choices under itself.
3//!
4//! It is not [`crate::Autocomplete`], which is the nearest-looking thing here and a
5//! different control: an autocomplete *suggests* over free text, where this has a **closed
6//! set** and a selected member of it. Nor is it [`crate::DropdownButton`], which is the
7//! same closed set behind a button rather than a field — no query, no filtering, no label
8//! floating over the border.
9//!
10//! # The rule that shapes it
11//!
12//! **A closed field shows the choice that is selected, and nothing else can appear there.**
13//! The query the caller is holding is displayed only while the menu is open. So a reader
14//! who types three letters, changes their mind and clicks away is looking at their actual
15//! choice again the moment the menu shuts — with no message sent, no state restored, and
16//! nothing for an application to remember to do.
17//!
18//! That is the whole reason the display is derived rather than passed in. Every other
19//! arrangement — the field showing the query at all times, with the application putting it
20//! back on dismissal — leaves the rule as something a caller can forget, and a dropdown
21//! showing `"gre"` where `"Green"` is selected is a dropdown that has lied about its own
22//! value.
23
24use std::rc::Rc;
25
26use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
27use frus_layout::{Dimension, FlexDirection, Style};
28
29use crate::dropdown::{label_style, option_row, DropdownOption};
30use crate::flex::Flex;
31use crate::icons::Icons;
32use crate::interaction::Status;
33use crate::menu::{Panel, PanelKind, PanelStyle};
34use crate::portal::Placement;
35use crate::scroll::SingleChildScrollView;
36use crate::textinput::TextField;
37use crate::theme::Theme;
38use crate::widget::Widget;
39
40/// The field's width when the caller has not said otherwise.
41const DEFAULT_WIDTH: f32 = 240.0;
42/// One choice's height, before the reader's type is applied to it.
43const ROW_H: f32 = 40.0;
44
45/// Whether `label` matches `query` — a case-insensitive **substring**, which is what
46/// filtering a short list of names wants.
47///
48/// Not a prefix: a set of `"Dark green"`, `"Light green"` and `"Sea green"` filtered by
49/// `"green"` on a prefix rule answers nothing at all, which reads as a broken control
50/// rather than as a rule.
51///
52/// An empty or blank query matches everything: it is not a filter yet.
53fn matches(label: &str, query: &str) -> bool {
54    if query.trim().is_empty() {
55        return true;
56    }
57    label.to_lowercase().contains(&query.trim().to_lowercase())
58}
59
60/// A dropdown that looks like a text field, filters as it is typed into, and drops its
61/// choices below itself.
62///
63/// Controlled, like everything else here: the application holds the query, whether the
64/// menu is open, and which choice is selected.
65///
66/// ```
67/// use frus_widgets::{DropdownMenu, DropdownOption};
68///
69/// # #[derive(Clone)] enum Msg { Query(String), Toggle, Pick(usize) }
70/// # let (query, open, chosen) = (String::new(), false, Some(1));
71/// let field = DropdownMenu::new(&query, open, Msg::Query, Msg::Toggle)
72///     .label("Colour")
73///     .selected(chosen)
74///     .options(&["Red", "Green", "Blue"], Msg::Pick);
75/// ```
76pub struct DropdownMenu<Msg = crate::callback::Callback> {
77    query: String,
78    open: bool,
79    enabled: bool,
80    filter: bool,
81    width: f32,
82    selected: Option<usize>,
83    label: Option<String>,
84    placeholder: Option<String>,
85    max_visible: Option<usize>,
86    text_style: Option<TextStyle>,
87    /// The panel the choices float on — see [`menu_background`](Self::menu_background).
88    look: PanelStyle,
89    on_input: Rc<dyn Fn(String) -> Msg>,
90    on_toggle: Msg,
91    options: Vec<DropdownOption<Msg>>,
92    on_select: Option<Rc<dyn Fn(usize) -> Msg>>,
93    /// `[field]`, or `[field, list]` when the menu is showing something.
94    children: Vec<Box<dyn Widget<Msg>>>,
95}
96
97impl<Msg: Clone + 'static> DropdownMenu<Msg> {
98    /// Creates the control: the query the application is holding, whether the menu is
99    /// open, what to send as the query changes, and what to send when the chevron is
100    /// pressed.
101    ///
102    /// `on_input` fires only while the menu is open, because that is the only time the
103    /// field shows the query — see the module documentation. An application will usually
104    /// open the menu on `on_toggle` and keep it open through `on_input`.
105    pub fn new(
106        query: impl Into<String>,
107        open: bool,
108        on_input: impl Fn(String) -> Msg + 'static,
109        on_toggle: Msg,
110    ) -> Self {
111        let mut menu = Self {
112            query: query.into(),
113            open,
114            enabled: true,
115            filter: true,
116            width: DEFAULT_WIDTH,
117            selected: None,
118            label: None,
119            placeholder: None,
120            max_visible: None,
121            text_style: None,
122            look: PanelStyle::default(),
123            on_input: Rc::new(on_input),
124            on_toggle,
125            options: Vec::new(),
126            on_select: None,
127            children: Vec::new(),
128        };
129        menu.rebuild();
130        menu
131    }
132
133    /// The name of the field, floating over its border the way a
134    /// [`TextField`](crate::TextField)'s does.
135    #[must_use]
136    pub fn label(mut self, label: impl Into<String>) -> Self {
137        self.label = Some(label.into());
138        self.rebuild();
139        self
140    }
141
142    /// What the field shows while **nothing is selected**.
143    #[must_use]
144    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
145        self.placeholder = Some(placeholder.into());
146        self.rebuild();
147        self
148    }
149
150    /// The field's width, and the list's, in logical pixels (240 by default).
151    #[must_use]
152    pub fn width(mut self, width: f32) -> Self {
153        self.width = width;
154        self.rebuild();
155        self
156    }
157
158    /// Which choice is selected — the one ticked in the list, and the one the closed field
159    /// shows.
160    #[must_use]
161    pub fn selected(mut self, index: Option<usize>) -> Self {
162        self.selected = index;
163        self.rebuild();
164        self
165    }
166
167    /// Whether the control can be used at all. Disabled it is **inert** and, like a
168    /// disabled [`DropdownButton`](crate::DropdownButton), **never open**.
169    ///
170    /// See [`crate::disabled`] for the whole contract.
171    #[must_use]
172    pub fn enabled(mut self, enabled: bool) -> Self {
173        self.enabled = enabled;
174        self.rebuild();
175        self
176    }
177
178    /// Whether typing **filters** the list. On by default.
179    ///
180    /// That is the opposite of the reference's own default, and deliberately: filtering is
181    /// the reason to reach for this control rather than
182    /// [`DropdownButton`](crate::DropdownButton), which is the same closed set behind a
183    /// button. A caller who does not want it has that widget already.
184    ///
185    /// Off, the field is **read-only**: a field that takes typing which changes nothing is
186    /// a field that looks broken.
187    #[must_use]
188    pub fn filter(mut self, filter: bool) -> Self {
189        self.filter = filter;
190        self.rebuild();
191        self
192    }
193
194    /// Limits the number of **visible** choices; past that the list scrolls instead of
195    /// stretching down the screen.
196    #[must_use]
197    pub fn max_visible(mut self, rows: usize) -> Self {
198        self.max_visible = Some(rows.max(1));
199        self.rebuild();
200        self
201    }
202
203    /// The type of the field and the choices, over the theme's and the reference's.
204    #[must_use]
205    pub fn text_style(mut self, style: TextStyle) -> Self {
206        self.text_style = Some(style);
207        self.rebuild();
208        self
209    }
210
211    /// **The surface the choices float on**, over
212    /// [`MenuTheme::background`](crate::MenuTheme::background) and `surface_container`.
213    ///
214    /// The choices sit on **one panel**, the one a menu floats on: a surface, the corner,
215    /// eight pixels above and below, and a shadow three high. The reference's dropdown menu
216    /// hands its panel to its menu anchor, so it answers to the menu theme, and so does
217    /// this. A choice has no box of its own; only the selected one and the one under a
218    /// pointer are tinted.
219    #[must_use]
220    pub fn menu_background(mut self, color: Color) -> Self {
221        self.look.background = Some(color);
222        self.rebuild();
223        self
224    }
225
226    /// What shape the panel is, over the theme's and the framework's corner.
227    #[must_use]
228    pub fn menu_shape(mut self, shape: ShapeBorder) -> Self {
229        self.look.shape = Some(shape);
230        self.rebuild();
231        self
232    }
233
234    /// The shorthand for a rounded panel.
235    #[must_use]
236    pub fn menu_radius(self, radius: impl Into<BorderRadius>) -> Self {
237        self.menu_shape(ShapeBorder::rounded(radius.into()))
238    }
239
240    /// How far off the page the panel sits, in pixels. Three by default.
241    #[must_use]
242    pub fn menu_elevation(mut self, elevation: f32) -> Self {
243        self.look.elevation = Some(elevation);
244        self.rebuild();
245        self
246    }
247
248    /// The colour of the panel's shadow, over the theme's and the scheme's shadow at 30 %.
249    /// [`Color::TRANSPARENT`] casts none.
250    #[must_use]
251    pub fn menu_shadow_color(mut self, color: Color) -> Self {
252        self.look.shadow_color = Some(color);
253        self.rebuild();
254        self
255    }
256
257    /// The room kept above and below the choices, inside the panel. Eight by default.
258    #[must_use]
259    pub fn menu_padding(mut self, padding: Insets) -> Self {
260        self.look.padding = Some(padding);
261        self.rebuild();
262        self
263    }
264
265    /// The choices, as words. `on_select` maps the chosen index to a message.
266    #[must_use]
267    pub fn options(self, labels: &[&str], on_select: impl Fn(usize) -> Msg + 'static) -> Self {
268        let options = labels.iter().map(|s| DropdownOption::new(*s)).collect();
269        self.options_widgets(options, on_select)
270    }
271
272    /// The same, for choices that are not words — see [`DropdownOption`].
273    ///
274    /// **`on_select` is given the index into this list**, not into whatever the filter left
275    /// showing. Handing back a filtered index is the classic bug in this control: it works
276    /// perfectly until somebody types, and then picks the wrong thing silently.
277    #[must_use]
278    pub fn options_widgets(
279        mut self,
280        options: Vec<DropdownOption<Msg>>,
281        on_select: impl Fn(usize) -> Msg + 'static,
282    ) -> Self {
283        self.options = options;
284        self.on_select = Some(Rc::new(on_select));
285        self.rebuild();
286        self
287    }
288
289    /// What the field displays: the query while the menu is open, the selected choice's
290    /// words while it is shut.
291    ///
292    /// A choice the caller drew has no words, so a closed field showing one falls back to
293    /// nothing rather than to a placeholder — something *is* selected, and saying
294    /// "choose one" would be false. The words are what the field can show; a caller whose
295    /// choices are pictures should give the field a
296    /// [`label`](Self::label) that says what has been chosen.
297    fn display(&self) -> String {
298        if self.open && self.enabled && self.filter {
299            return self.query.clone();
300        }
301        self.selected
302            .and_then(|i| self.options.get(i))
303            .and_then(DropdownOption::label)
304            .unwrap_or_default()
305            .to_string()
306    }
307
308    /// The choices the filter leaves, each with the index it has in the caller's own list.
309    fn showing(&self) -> Vec<usize> {
310        (0..self.options.len())
311            .filter(|&i| {
312                if !(self.filter && self.open) {
313                    return true;
314                }
315                match self.options[i].label() {
316                    Some(label) => matches(label, &self.query),
317                    // A choice the caller drew has no words to match against. It stays:
318                    // hiding it would make the swatches disappear as soon as anybody
319                    // typed, and a filter that removes what it cannot read is worse than
320                    // one that keeps it.
321                    None => true,
322                }
323            })
324            .collect()
325    }
326
327    fn rebuild(&mut self) {
328        let on_input = self.on_input.clone();
329        let mut field = TextField::new(self.display())
330            .width(self.width)
331            .enabled(self.enabled)
332            // The chevron turns over with the menu, which is the only thing on a closed
333            // field saying that there is more of it.
334            .suffix_icon(if self.open && self.enabled {
335                Icons::ARROW_DROP_UP
336            } else {
337                Icons::ARROW_DROP_DOWN
338            })
339            .on_suffix(self.on_toggle.clone())
340            .on_input(move |text| on_input(text));
341        if let Some(label) = &self.label {
342            field = field.label(label.clone());
343        }
344        if let Some(placeholder) = &self.placeholder {
345            field = field.placeholder(placeholder.clone());
346        }
347        if let Some(size) = self.text_style.and_then(|style| style.size) {
348            field = field.size(size);
349        }
350        if !self.filter {
351            // Typing that changes nothing looks like a broken field, so it does not take
352            // any.
353            field = field.read_only();
354        }
355        self.children = vec![Box::new(field)];
356
357        if !self.open || !self.enabled {
358            return;
359        }
360        let showing = self.showing();
361        if showing.is_empty() {
362            // **No empty panel.** A floating surface with nothing in it is a control that
363            // looks broken; a query matching nothing should leave the field alone.
364            return;
365        }
366        // Contiguous, on one panel: the four-pixel gutter the choices used to leave would
367        // show the page through the middle of the list.
368        let mut list = Flex::column();
369        for &index in &showing {
370            let option = &self.options[index];
371            let on_click = self
372                .on_select
373                .as_ref()
374                .filter(|_| option.is_enabled())
375                .map(|f| f(index));
376            list = list.child(option_row(
377                option,
378                self.width,
379                self.selected == Some(index),
380                self.enabled,
381                self.text_style,
382                (PanelKind::Menu, self.look.background),
383                on_click,
384            ));
385        }
386        let rows = showing.len();
387        // **A long list scrolls inside the panel**, not the panel inside a viewport: the
388        // surface, its room above and below and its shadow stay whole, and the rows are
389        // clipped in the viewport between the two rooms — where the reference's menu puts
390        // its scroll view, inside the panel's padding.
391        let content: Box<dyn Widget<Msg>> = match self.max_visible {
392            Some(n) if rows > n => {
393                // The rows' own height, not the floor: a viewport counted at `ROW_H`
394                // while the rows are taller shows `n` rows minus a sliver of each. `None`
395                // for the theme is the one thing a builder cannot have — the list is built
396                // before any theme exists — so an application that retypesets the choices
397                // through the theme *and* caps them should say the size on the widget.
398                let row = frus_text::line_box(ROW_H, &label_style(self.text_style, None), 0.0);
399                Box::new(
400                    SingleChildScrollView::new()
401                        .width(self.width)
402                        .height(n as f32 * row)
403                        .child(list),
404                )
405            }
406            _ => Box::new(list),
407        };
408        self.children.push(Box::new(Panel::new(
409            PanelKind::Menu,
410            self.look,
411            vec![content],
412        )));
413    }
414}
415
416impl<Msg: Clone> Widget<Msg> for DropdownMenu<Msg> {
417    fn style(&self) -> Style {
418        Style {
419            flex_direction: FlexDirection::Column,
420            width: Dimension::Length(self.width),
421            padding: Insets::ZERO,
422            ..Default::default()
423        }
424    }
425
426    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
427        &self.children
428    }
429
430    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
431
432    fn on_click(&self) -> Option<Msg> {
433        None
434    }
435
436    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
437        self.children
438            .get(1)
439            .map(|list| (list.as_ref(), Placement::Below))
440    }
441
442    fn overlay_dismiss(&self) -> Option<Msg> {
443        // A press outside shuts it, and shutting it puts the selected choice back in the
444        // field on its own — that is what deriving the display buys.
445        (self.open && self.enabled).then(|| self.on_toggle.clone())
446    }
447
448    fn overlay_traps_focus(&self) -> bool {
449        self.open
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::{build_ui, Container, Runtime, Size};
457    use frus_core::{Color, Point, Primitive};
458
459    #[derive(Clone, Debug, PartialEq)]
460    enum Msg {
461        Query(String),
462        Toggle,
463        Pick(usize),
464    }
465
466    /// Every run of text in the frame.
467    fn texts(scene: &frus_core::Scene) -> Vec<String> {
468        fn walk(primitives: &[Primitive], out: &mut Vec<String>) {
469            for p in primitives {
470                match p {
471                    Primitive::Text { text, .. } => out.push(text.clone()),
472                    Primitive::Layer { primitives, .. } => walk(primitives, out),
473                    _ => {}
474                }
475            }
476        }
477        let mut out = Vec::new();
478        walk(scene.primitives(), &mut out);
479        out
480    }
481
482    fn frame(menu: &DropdownMenu<Msg>) -> crate::Ui<Msg> {
483        build_ui(
484            menu,
485            Size::new(400.0, 500.0),
486            &Runtime::default(),
487            &Theme::default(),
488        )
489    }
490
491    fn colours() -> [&'static str; 4] {
492        ["Red", "Dark green", "Light green", "Blue"]
493    }
494
495    fn menu(query: &str, open: bool) -> DropdownMenu<Msg> {
496        DropdownMenu::new(query, open, Msg::Query, Msg::Toggle)
497            .selected(Some(1))
498            .options(&colours(), Msg::Pick)
499    }
500
501    /// **A closed field shows the choice, never the query.** The whole control is shaped
502    /// around this: a reader who types three letters, thinks better of it and clicks away
503    /// is looking at their actual choice again, with nothing sent and nothing for an
504    /// application to remember to put back.
505    ///
506    /// The alternative — the field showing the query at all times — leaves the rule as
507    /// something a caller can forget, and a field reading `gre` while `Dark green` is
508    /// selected has lied about its own value.
509    #[test]
510    fn a_closed_field_shows_the_choice_and_not_the_query() {
511        let shut = frame(&menu("gre", false));
512        let drawn = texts(shut.scene());
513        assert!(
514            drawn.iter().any(|t| t == "Dark green"),
515            "the selected choice: {drawn:?}"
516        );
517        assert!(
518            !drawn.iter().any(|t| t == "gre"),
519            "and not the half-typed query: {drawn:?}"
520        );
521
522        let open = frame(&menu("gre", true));
523        assert!(
524            texts(open.scene()).iter().any(|t| t == "gre"),
525            "which is the only thing an open one shows"
526        );
527    }
528
529    /// **An open menu shows its options wherever the field sits** (milestone 592): in a box
530    /// that measures it, in a builder given its size, and in a row of a list — each of which
531    /// used to paint its content through a reduced walk that had no overlays, so the field
532    /// opened onto nothing.
533    #[test]
534    fn an_open_menu_shows_its_options_wherever_it_sits() {
535        let options = |root: &dyn Widget<Msg>| {
536            let ui = build_ui(
537                root,
538                Size::new(400.0, 500.0),
539                &Runtime::default(),
540                &Theme::default(),
541            );
542            texts(ui.scene())
543                .into_iter()
544                .filter(|t| colours().contains(&t.as_str()))
545                .count()
546        };
547        let at_root = options(&menu("", true));
548        assert_eq!(at_root, 4);
549        let measured = crate::UnconstrainedBox::new(menu("", true));
550        assert_eq!(options(&measured), at_root, "in an unconstrained box");
551        let built = crate::LayoutBuilder::new(|_| menu("", true));
552        assert_eq!(options(&built), at_root, "in a layout builder");
553        let listed = crate::ListView::new(3, 80.0, |_| menu("", true)).height(400.0);
554        assert!(options(&listed) >= at_root, "in a list's row");
555    }
556
557    /// **Nothing selected shows nothing**, and the placeholder is what says so.
558    #[test]
559    fn nothing_selected_shows_the_placeholder() {
560        let none = DropdownMenu::new("", false, Msg::Query, Msg::Toggle)
561            .placeholder("Choose a colour")
562            .options(&colours(), Msg::Pick);
563        assert!(texts(frame(&none).scene())
564            .iter()
565            .any(|t| t == "Choose a colour"));
566    }
567
568    /// **The list filters as it is typed into**, on a case-insensitive **substring**. Two
569    /// of the four colours have `green` in them and neither of them starts with it: a
570    /// prefix rule would answer nothing here, which reads as a broken control rather than
571    /// as a rule.
572    #[test]
573    fn the_list_filters_on_a_substring_and_not_a_prefix() {
574        let shown = |query: &str| {
575            let ui = frame(&menu(query, true));
576            texts(ui.scene())
577                .into_iter()
578                .filter(|t| colours().contains(&t.as_str()))
579                .count()
580        };
581        assert_eq!(shown(""), 4, "an empty query is not a filter yet");
582        assert_eq!(shown("GREEN"), 2, "case-insensitive, and matching inside");
583        assert_eq!(shown("blu"), 1);
584    }
585
586    /// **The message carries the index in the caller's own list**, not in whatever the
587    /// filter left showing.
588    ///
589    /// This is the bug this control is famous for. It works perfectly until somebody
590    /// types, and then picks the wrong thing — silently, because both indices are valid.
591    /// `Light green` is the third colour and the second row of the filtered list.
592    #[test]
593    fn the_message_carries_the_index_in_the_callers_own_list() {
594        let ui = frame(&menu("green", true));
595        // The field is 56 tall, and the panel keeps eight above its first row.
596        let row = |n: f32| {
597            ui.hit(Point::new(120.0, 56.0 + 8.0 + ROW_H * (n + 0.5)))
598                .and_then(|id| ui.msg_for(id))
599        };
600        // Both, so that the coordinates are known to be landing on rows at all: an
601        // assertion about the second row alone passes just as well when it is hitting
602        // nothing.
603        assert_eq!(
604            row(0.0),
605            Some(Msg::Pick(1)),
606            "`Dark green` is the second colour"
607        );
608        assert_eq!(
609            row(1.0),
610            Some(Msg::Pick(2)),
611            "and the second row of the filtered list is the third"
612        );
613    }
614
615    /// **A query that matches nothing floats no panel.** An empty surface hanging under a
616    /// field is a control that looks broken.
617    #[test]
618    fn a_query_that_matches_nothing_floats_no_panel() {
619        let empty = menu("zzz", true);
620        assert!(Widget::<Msg>::overlay(&empty).is_none());
621        assert!(Widget::<Msg>::overlay(&menu("green", true)).is_some());
622    }
623
624    /// **Filtering off keeps the field showing the choice even while open**, because with
625    /// nothing to type there is no query to show — and the field is read-only, since one
626    /// that takes typing which changes nothing looks broken.
627    #[test]
628    fn filtering_off_leaves_the_choice_in_the_field() {
629        let unfiltered = menu("gre", true).filter(false);
630        let drawn = texts(frame(&unfiltered).scene());
631        assert!(drawn.iter().any(|t| t == "Dark green"));
632        assert!(!drawn.iter().any(|t| t == "gre"));
633        assert_eq!(
634            texts(frame(&unfiltered).scene())
635                .into_iter()
636                .filter(|t| colours().contains(&t.as_str()))
637                .count(),
638            5,
639            "all four choices, plus the one in the field"
640        );
641    }
642
643    /// **Disabled it is inert and never open**, like every other disabled control here.
644    #[test]
645    fn disabled_is_never_open() {
646        let off = menu("", true).enabled(false);
647        assert!(Widget::<Msg>::overlay(&off).is_none());
648        assert_eq!(Widget::<Msg>::overlay_dismiss(&off), None);
649    }
650
651    /// **A press outside shuts it**, and shutting it puts the choice back in the field on
652    /// its own — which is what deriving the display buys.
653    #[test]
654    fn a_press_outside_shuts_it() {
655        assert_eq!(
656            Widget::<Msg>::overlay_dismiss(&menu("", true)),
657            Some(Msg::Toggle)
658        );
659    }
660
661    /// **A choice the caller drew is not filtered out.** It has no words to match, and
662    /// hiding it would make the swatches vanish the moment anybody typed — a filter that
663    /// removes what it cannot read is worse than one that keeps it.
664    #[test]
665    fn a_choice_with_no_words_survives_the_filter() {
666        let swatch = |c: Color| Container::<Msg>::new().width(12.0).height(12.0).color(c);
667        let menu = DropdownMenu::new("blu", true, Msg::Query, Msg::Toggle).options_widgets(
668            vec![
669                DropdownOption::new("Red"),
670                DropdownOption::widget(swatch(Color::rgb(0.0, 0.0, 1.0))),
671                DropdownOption::new("Blue"),
672            ],
673            Msg::Pick,
674        );
675        let ui = frame(&menu);
676        let painted = texts(ui.scene());
677        assert!(!painted.iter().any(|t| t == "Red"), "filtered out");
678        assert!(painted.iter().any(|t| t == "Blue"), "kept");
679        let swatches = count_colour(ui.scene(), Color::rgb(0.0, 0.0, 1.0));
680        assert_eq!(swatches, 1, "and the one with no words is still there");
681    }
682
683    use crate::menu::probe::{blurred, colour_of, crisp, height_of, Painted};
684
685    /// The height of one choice on the default theme.
686    fn row_height() -> f32 {
687        frus_text::line_box(ROW_H, &label_style(None, Some(&Theme::default())), 0.0)
688    }
689
690    /// What is painted **under the field** — the field has an outline of its own, and it
691    /// is the list this is about.
692    fn below_field(scene: &frus_core::Scene) -> Vec<Painted> {
693        crisp(scene)
694            .into_iter()
695            .filter(|r| r.rect.y >= 56.0)
696            .collect()
697    }
698
699    fn themed_frame(menu: &DropdownMenu<Msg>, theme: &Theme) -> crate::Ui<Msg> {
700        build_ui(menu, Size::new(400.0, 500.0), &Runtime::default(), theme)
701    }
702
703    /// **The choices float on one panel and draw no box of their own.** Each was an
704    /// outlined, rounded rectangle four pixels from the next, with nothing behind them.
705    ///
706    /// Under the field: the panel in `surface_container`, rounded, unoutlined, as wide as
707    /// the field, four rows and eight above and below — and the selected choice's tint,
708    /// which is the one other thing a list at rest draws.
709    #[test]
710    fn the_choices_float_on_one_panel_and_draw_no_box_of_their_own() {
711        let theme = Theme::default();
712        let ui = frame(&menu("", true));
713        let painted = below_field(ui.scene());
714        assert_eq!(
715            painted.len(),
716            2,
717            "the panel and the selection: {painted:#?}"
718        );
719        let (panel, selection) = (painted[0], painted[1]);
720        assert_eq!(panel.color, theme.scheme.surface_container);
721        assert!(panel.radius != frus_core::BorderRadius::ZERO);
722        assert_eq!(panel.rect.width, DEFAULT_WIDTH);
723        assert_eq!(panel.rect.height, 4.0 * row_height() + 16.0);
724        assert!(
725            painted.iter().all(|r| r.border_width == 0.0),
726            "no choice is outlined: {painted:#?}"
727        );
728        assert_eq!(
729            selection.color,
730            theme.scheme.surface_container.lerp(theme.primary, 0.14)
731        );
732        assert_eq!(
733            selection.rect.y,
734            panel.rect.y + 8.0 + row_height(),
735            "the second choice, on the panel"
736        );
737        assert_eq!(
738            selection.radius,
739            frus_core::BorderRadius::ZERO,
740            "a strip, not a box"
741        );
742    }
743
744    /// **The panel casts a shadow three high**, the reference's menu height, which a
745    /// dropdown menu falls through to: a blur of twenty in the scheme's shadow at 30 %.
746    #[test]
747    fn the_panel_casts_a_shadow_three_high() {
748        let theme = Theme::default();
749        let ui = frame(&menu("", true));
750        let panel = below_field(ui.scene())[0];
751        let shadows = blurred(ui.scene());
752        assert_eq!(shadows.len(), 3, "{shadows:#?}");
753        assert_eq!(height_of(ui.scene()), 3.0);
754        assert_eq!(
755            shadows[1].rect.y + shadows[1].rect.height * 0.5,
756            panel.rect.y + panel.rect.height * 0.5 + 3.0,
757            "the second shadow drops by the height"
758        );
759        assert_eq!(colour_of(ui.scene()), Some(theme.scheme.shadow));
760        assert!(
761            blurred(frame(&menu("", false)).scene()).is_empty(),
762            "shut, none"
763        );
764    }
765
766    /// **The panel answers to the menu theme, and to its caller over the theme**, as the
767    /// reference's dropdown menu answers to its anchor's.
768    #[test]
769    fn the_panel_answers_to_the_menu_theme_and_to_its_caller() {
770        let mut theme = Theme::default();
771        let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
772        theme.widgets.menu.background = Some(surface);
773        theme.widgets.menu.radius = Some(3.0);
774        theme.widgets.menu.elevation = Some(1.0);
775        theme.widgets.menu.shadow_color = Some(shade);
776        theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
777        let ui = themed_frame(&menu("", true), &theme);
778        let painted = below_field(ui.scene());
779        let panel = *painted
780            .iter()
781            .find(|r| r.color == surface)
782            .expect("the theme's");
783        assert_eq!(panel.radius, frus_core::BorderRadius::uniform(3.0));
784        assert_eq!(panel.rect.height, 4.0 * row_height() + 40.0);
785        assert!(painted
786            .iter()
787            .any(|r| r.color == surface.lerp(theme.primary, 0.14)));
788        assert_eq!(
789            (height_of(ui.scene()), colour_of(ui.scene())),
790            (1.0, Some(shade))
791        );
792
793        let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
794        let told = menu("", true)
795            .menu_background(own)
796            .menu_radius(9.0)
797            .menu_elevation(2.0)
798            .menu_shadow_color(own_shade)
799            .menu_padding(Insets::ZERO);
800        let ui = themed_frame(&told, &theme);
801        let panel = *below_field(ui.scene())
802            .iter()
803            .find(|r| r.color == own)
804            .expect("the caller's");
805        assert_eq!(panel.radius, frus_core::BorderRadius::uniform(9.0));
806        assert_eq!(panel.rect.height, 4.0 * row_height());
807        assert_eq!(
808            (height_of(ui.scene()), colour_of(ui.scene())),
809            (2.0, Some(own_shade))
810        );
811    }
812
813    /// **A transparent shadow colour casts nothing** (milestone 529), on the widget or on
814    /// the menu theme.
815    #[test]
816    fn a_transparent_shadow_casts_nothing() {
817        let clear = menu("", true).menu_shadow_color(Color::TRANSPARENT);
818        assert!(blurred(frame(&clear).scene()).is_empty());
819        let mut theme = Theme::default();
820        theme.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
821        assert!(blurred(themed_frame(&menu("", true), &theme).scene()).is_empty());
822    }
823
824    /// **A long list scrolls inside the panel, and is clipped there.** The panel stays
825    /// whole — two rows showing and its room above and below, and no clip of its own, since
826    /// a clip would take its shadow with it — and the rows are drawn clipped to the
827    /// viewport between the two rooms, so the choices past the fold are built, laid out
828    /// below it and not shown.
829    #[test]
830    fn a_long_list_scrolls_and_clips_inside_the_panel() {
831        let theme = Theme::default();
832        let ui = frame(&menu("", true).max_visible(2));
833        let painted = below_field(ui.scene());
834        let panel = *painted
835            .iter()
836            .find(|r| r.color == theme.scheme.surface_container)
837            .expect("a panel");
838        assert_eq!(panel.rect.height, 2.0 * row_height() + 16.0);
839        let window = Rect::new(0.0, 0.0, 400.0, 500.0);
840        assert_eq!(
841            panel.clip, window,
842            "the panel is clipped by nothing but the window"
843        );
844        assert_eq!(
845            blurred(ui.scene())[0].clip,
846            window,
847            "and neither is its shadow"
848        );
849
850        // The selected choice, the second, is inside the viewport: its clip is the viewport.
851        let selected = *painted
852            .iter()
853            .find(|r| r.color == theme.scheme.surface_container.lerp(theme.primary, 0.14))
854            .expect("the selected choice is drawn");
855        let clip = selected.clip;
856        assert_eq!(
857            clip.y,
858            panel.rect.y + 8.0,
859            "the viewport starts inside the room"
860        );
861        assert_eq!(clip.height, 2.0 * row_height(), "and shows two rows");
862        assert!(clip.x >= panel.rect.x && clip.x + clip.width <= panel.rect.x + panel.rect.width);
863
864        fn text_at(primitives: &[Primitive], words: &str) -> Option<Point> {
865            primitives.iter().find_map(|p| match p {
866                Primitive::Text { position, text, .. } if text == words => Some(*position),
867                Primitive::Layer { primitives, .. } => text_at(primitives, words),
868                _ => None,
869            })
870        }
871        let blue = text_at(ui.scene().primitives(), "Blue").expect("the last choice is built");
872        assert!(
873            blue.y >= clip.y + clip.height,
874            "the last choice is past the fold, so there is something to scroll to: {blue:?}"
875        );
876    }
877
878    fn count_colour(scene: &frus_core::Scene, wanted: Color) -> usize {
879        fn walk(primitives: &[Primitive], wanted: Color) -> usize {
880            primitives
881                .iter()
882                .map(|p| match p {
883                    Primitive::Rect { color, .. } if *color == wanted => 1,
884                    Primitive::Layer { primitives, .. } => walk(primitives, wanted),
885                    _ => 0,
886                })
887                .sum()
888        }
889        walk(scene.primitives(), wanted)
890    }
891}