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    /// **Nothing selected shows nothing**, and the placeholder is what says so.
530    #[test]
531    fn nothing_selected_shows_the_placeholder() {
532        let none = DropdownMenu::new("", false, Msg::Query, Msg::Toggle)
533            .placeholder("Choose a colour")
534            .options(&colours(), Msg::Pick);
535        assert!(texts(frame(&none).scene())
536            .iter()
537            .any(|t| t == "Choose a colour"));
538    }
539
540    /// **The list filters as it is typed into**, on a case-insensitive **substring**. Two
541    /// of the four colours have `green` in them and neither of them starts with it: a
542    /// prefix rule would answer nothing here, which reads as a broken control rather than
543    /// as a rule.
544    #[test]
545    fn the_list_filters_on_a_substring_and_not_a_prefix() {
546        let shown = |query: &str| {
547            let ui = frame(&menu(query, true));
548            texts(ui.scene())
549                .into_iter()
550                .filter(|t| colours().contains(&t.as_str()))
551                .count()
552        };
553        assert_eq!(shown(""), 4, "an empty query is not a filter yet");
554        assert_eq!(shown("GREEN"), 2, "case-insensitive, and matching inside");
555        assert_eq!(shown("blu"), 1);
556    }
557
558    /// **The message carries the index in the caller's own list**, not in whatever the
559    /// filter left showing.
560    ///
561    /// This is the bug this control is famous for. It works perfectly until somebody
562    /// types, and then picks the wrong thing — silently, because both indices are valid.
563    /// `Light green` is the third colour and the second row of the filtered list.
564    #[test]
565    fn the_message_carries_the_index_in_the_callers_own_list() {
566        let ui = frame(&menu("green", true));
567        // The field is 56 tall, and the panel keeps eight above its first row.
568        let row = |n: f32| {
569            ui.hit(Point::new(120.0, 56.0 + 8.0 + ROW_H * (n + 0.5)))
570                .and_then(|id| ui.msg_for(id))
571        };
572        // Both, so that the coordinates are known to be landing on rows at all: an
573        // assertion about the second row alone passes just as well when it is hitting
574        // nothing.
575        assert_eq!(
576            row(0.0),
577            Some(Msg::Pick(1)),
578            "`Dark green` is the second colour"
579        );
580        assert_eq!(
581            row(1.0),
582            Some(Msg::Pick(2)),
583            "and the second row of the filtered list is the third"
584        );
585    }
586
587    /// **A query that matches nothing floats no panel.** An empty surface hanging under a
588    /// field is a control that looks broken.
589    #[test]
590    fn a_query_that_matches_nothing_floats_no_panel() {
591        let empty = menu("zzz", true);
592        assert!(Widget::<Msg>::overlay(&empty).is_none());
593        assert!(Widget::<Msg>::overlay(&menu("green", true)).is_some());
594    }
595
596    /// **Filtering off keeps the field showing the choice even while open**, because with
597    /// nothing to type there is no query to show — and the field is read-only, since one
598    /// that takes typing which changes nothing looks broken.
599    #[test]
600    fn filtering_off_leaves_the_choice_in_the_field() {
601        let unfiltered = menu("gre", true).filter(false);
602        let drawn = texts(frame(&unfiltered).scene());
603        assert!(drawn.iter().any(|t| t == "Dark green"));
604        assert!(!drawn.iter().any(|t| t == "gre"));
605        assert_eq!(
606            texts(frame(&unfiltered).scene())
607                .into_iter()
608                .filter(|t| colours().contains(&t.as_str()))
609                .count(),
610            5,
611            "all four choices, plus the one in the field"
612        );
613    }
614
615    /// **Disabled it is inert and never open**, like every other disabled control here.
616    #[test]
617    fn disabled_is_never_open() {
618        let off = menu("", true).enabled(false);
619        assert!(Widget::<Msg>::overlay(&off).is_none());
620        assert_eq!(Widget::<Msg>::overlay_dismiss(&off), None);
621    }
622
623    /// **A press outside shuts it**, and shutting it puts the choice back in the field on
624    /// its own — which is what deriving the display buys.
625    #[test]
626    fn a_press_outside_shuts_it() {
627        assert_eq!(
628            Widget::<Msg>::overlay_dismiss(&menu("", true)),
629            Some(Msg::Toggle)
630        );
631    }
632
633    /// **A choice the caller drew is not filtered out.** It has no words to match, and
634    /// hiding it would make the swatches vanish the moment anybody typed — a filter that
635    /// removes what it cannot read is worse than one that keeps it.
636    #[test]
637    fn a_choice_with_no_words_survives_the_filter() {
638        let swatch = |c: Color| Container::<Msg>::new().width(12.0).height(12.0).color(c);
639        let menu = DropdownMenu::new("blu", true, Msg::Query, Msg::Toggle).options_widgets(
640            vec![
641                DropdownOption::new("Red"),
642                DropdownOption::widget(swatch(Color::rgb(0.0, 0.0, 1.0))),
643                DropdownOption::new("Blue"),
644            ],
645            Msg::Pick,
646        );
647        let ui = frame(&menu);
648        let painted = texts(ui.scene());
649        assert!(!painted.iter().any(|t| t == "Red"), "filtered out");
650        assert!(painted.iter().any(|t| t == "Blue"), "kept");
651        let swatches = count_colour(ui.scene(), Color::rgb(0.0, 0.0, 1.0));
652        assert_eq!(swatches, 1, "and the one with no words is still there");
653    }
654
655    use crate::menu::probe::{blurred, crisp, Painted};
656
657    /// The height of one choice on the default theme.
658    fn row_height() -> f32 {
659        frus_text::line_box(ROW_H, &label_style(None, Some(&Theme::default())), 0.0)
660    }
661
662    /// What is painted **under the field** — the field has an outline of its own, and it
663    /// is the list this is about.
664    fn below_field(scene: &frus_core::Scene) -> Vec<Painted> {
665        crisp(scene)
666            .into_iter()
667            .filter(|r| r.rect.y >= 56.0)
668            .collect()
669    }
670
671    fn themed_frame(menu: &DropdownMenu<Msg>, theme: &Theme) -> crate::Ui<Msg> {
672        build_ui(menu, Size::new(400.0, 500.0), &Runtime::default(), theme)
673    }
674
675    /// **The choices float on one panel and draw no box of their own.** Each was an
676    /// outlined, rounded rectangle four pixels from the next, with nothing behind them.
677    ///
678    /// Under the field: the panel in `surface_container`, rounded, unoutlined, as wide as
679    /// the field, four rows and eight above and below — and the selected choice's tint,
680    /// which is the one other thing a list at rest draws.
681    #[test]
682    fn the_choices_float_on_one_panel_and_draw_no_box_of_their_own() {
683        let theme = Theme::default();
684        let ui = frame(&menu("", true));
685        let painted = below_field(ui.scene());
686        assert_eq!(
687            painted.len(),
688            2,
689            "the panel and the selection: {painted:#?}"
690        );
691        let (panel, selection) = (painted[0], painted[1]);
692        assert_eq!(panel.color, theme.scheme.surface_container);
693        assert!(panel.radius != frus_core::BorderRadius::ZERO);
694        assert_eq!(panel.rect.width, DEFAULT_WIDTH);
695        assert_eq!(panel.rect.height, 4.0 * row_height() + 16.0);
696        assert!(
697            painted.iter().all(|r| r.border_width == 0.0),
698            "no choice is outlined: {painted:#?}"
699        );
700        assert_eq!(
701            selection.color,
702            theme.scheme.surface_container.lerp(theme.primary, 0.14)
703        );
704        assert_eq!(
705            selection.rect.y,
706            panel.rect.y + 8.0 + row_height(),
707            "the second choice, on the panel"
708        );
709        assert_eq!(
710            selection.radius,
711            frus_core::BorderRadius::ZERO,
712            "a strip, not a box"
713        );
714    }
715
716    /// **The panel casts a shadow three high**, the reference's menu height, which a
717    /// dropdown menu falls through to: a blur of twenty in the scheme's shadow at 30 %.
718    #[test]
719    fn the_panel_casts_a_shadow_three_high() {
720        let theme = Theme::default();
721        let ui = frame(&menu("", true));
722        let panel = below_field(ui.scene())[0];
723        let shadows = blurred(ui.scene());
724        assert_eq!(shadows.len(), 1, "{shadows:#?}");
725        assert_eq!(shadows[0].blur, 20.0);
726        assert_eq!(shadows[0].rect.y, panel.rect.y + 6.0 - 20.0);
727        assert_eq!(shadows[0].color, theme.scheme.shadow.with_alpha(0.30));
728        assert!(
729            blurred(frame(&menu("", false)).scene()).is_empty(),
730            "shut, none"
731        );
732    }
733
734    /// **The panel answers to the menu theme, and to its caller over the theme**, as the
735    /// reference's dropdown menu answers to its anchor's.
736    #[test]
737    fn the_panel_answers_to_the_menu_theme_and_to_its_caller() {
738        let mut theme = Theme::default();
739        let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
740        theme.widgets.menu.background = Some(surface);
741        theme.widgets.menu.radius = Some(3.0);
742        theme.widgets.menu.elevation = Some(1.0);
743        theme.widgets.menu.shadow_color = Some(shade);
744        theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
745        let ui = themed_frame(&menu("", true), &theme);
746        let painted = below_field(ui.scene());
747        let panel = *painted
748            .iter()
749            .find(|r| r.color == surface)
750            .expect("the theme's");
751        assert_eq!(panel.radius, frus_core::BorderRadius::uniform(3.0));
752        assert_eq!(panel.rect.height, 4.0 * row_height() + 40.0);
753        assert!(painted
754            .iter()
755            .any(|r| r.color == surface.lerp(theme.primary, 0.14)));
756        let shadow = blurred(ui.scene())[0];
757        assert_eq!((shadow.blur, shadow.color), (12.0, shade));
758
759        let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
760        let told = menu("", true)
761            .menu_background(own)
762            .menu_radius(9.0)
763            .menu_elevation(2.0)
764            .menu_shadow_color(own_shade)
765            .menu_padding(Insets::ZERO);
766        let ui = themed_frame(&told, &theme);
767        let panel = *below_field(ui.scene())
768            .iter()
769            .find(|r| r.color == own)
770            .expect("the caller's");
771        assert_eq!(panel.radius, frus_core::BorderRadius::uniform(9.0));
772        assert_eq!(panel.rect.height, 4.0 * row_height());
773        let shadow = blurred(ui.scene())[0];
774        assert_eq!((shadow.blur, shadow.color), (16.0, own_shade));
775    }
776
777    /// **A transparent shadow colour casts nothing** (milestone 529), on the widget or on
778    /// the menu theme.
779    #[test]
780    fn a_transparent_shadow_casts_nothing() {
781        let clear = menu("", true).menu_shadow_color(Color::TRANSPARENT);
782        assert!(blurred(frame(&clear).scene()).is_empty());
783        let mut theme = Theme::default();
784        theme.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
785        assert!(blurred(themed_frame(&menu("", true), &theme).scene()).is_empty());
786    }
787
788    /// **A long list scrolls inside the panel, and is clipped there.** The panel stays
789    /// whole — two rows showing and its room above and below, and no clip of its own, since
790    /// a clip would take its shadow with it — and the rows are drawn clipped to the
791    /// viewport between the two rooms, so the choices past the fold are built, laid out
792    /// below it and not shown.
793    #[test]
794    fn a_long_list_scrolls_and_clips_inside_the_panel() {
795        let theme = Theme::default();
796        let ui = frame(&menu("", true).max_visible(2));
797        let painted = below_field(ui.scene());
798        let panel = *painted
799            .iter()
800            .find(|r| r.color == theme.scheme.surface_container)
801            .expect("a panel");
802        assert_eq!(panel.rect.height, 2.0 * row_height() + 16.0);
803        let window = Rect::new(0.0, 0.0, 400.0, 500.0);
804        assert_eq!(
805            panel.clip, window,
806            "the panel is clipped by nothing but the window"
807        );
808        assert_eq!(
809            blurred(ui.scene())[0].clip,
810            window,
811            "and neither is its shadow"
812        );
813
814        // The selected choice, the second, is inside the viewport: its clip is the viewport.
815        let selected = *painted
816            .iter()
817            .find(|r| r.color == theme.scheme.surface_container.lerp(theme.primary, 0.14))
818            .expect("the selected choice is drawn");
819        let clip = selected.clip;
820        assert_eq!(
821            clip.y,
822            panel.rect.y + 8.0,
823            "the viewport starts inside the room"
824        );
825        assert_eq!(clip.height, 2.0 * row_height(), "and shows two rows");
826        assert!(clip.x >= panel.rect.x && clip.x + clip.width <= panel.rect.x + panel.rect.width);
827
828        fn text_at(primitives: &[Primitive], words: &str) -> Option<Point> {
829            primitives.iter().find_map(|p| match p {
830                Primitive::Text { position, text, .. } if text == words => Some(*position),
831                Primitive::Layer { primitives, .. } => text_at(primitives, words),
832                _ => None,
833            })
834        }
835        let blue = text_at(ui.scene().primitives(), "Blue").expect("the last choice is built");
836        assert!(
837            blue.y >= clip.y + clip.height,
838            "the last choice is past the fold, so there is something to scroll to: {blue:?}"
839        );
840    }
841
842    fn count_colour(scene: &frus_core::Scene, wanted: Color) -> usize {
843        fn walk(primitives: &[Primitive], wanted: Color) -> usize {
844            primitives
845                .iter()
846                .map(|p| match p {
847                    Primitive::Rect { color, .. } if *color == wanted => 1,
848                    Primitive::Layer { primitives, .. } => walk(primitives, wanted),
849                    _ => 0,
850                })
851                .sum()
852        }
853        walk(scene.primitives(), wanted)
854    }
855}