Skip to main content

frus_widgets/
menu.rs

1//! [`PopupMenuButton`]: a **floating** action menu — an anchor plus a list of items that opens
2//! over it, through the overlay, and closes on an outside click.
3//!
4//! A row is a label and a message in the ordinary case, and [`MenuItem`] is what it is
5//! when it is not: a picture in the leading column, a tick, the keys that work the action,
6//! a row the caller drew, a rule between two groups, or a row that is unavailable while
7//! the rest of the menu is not.
8//!
9//! **The leading column belongs to the menu.** If any row has a mark, every row keeps the
10//! room for one, so the marks line up down a column and a tick that is off holds its place
11//! rather than sliding its own label across. A menu with no marks at all is not indented at
12//! all.
13
14use std::rc::Rc;
15
16use frus_core::{
17    BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextStyle,
18};
19use frus_layout::{Align, Dimension, FlexDirection, Style};
20
21use crate::disabled::disabled_content;
22use crate::divider::Divider;
23use crate::flex::Flex;
24use crate::icons::{IconData, Icons};
25use crate::interaction::Status;
26use crate::portal::Placement;
27use crate::theme::Theme;
28use crate::transparent::Shared;
29use crate::widget::Widget;
30
31const WIDTH: f32 = 220.0;
32/// **One row's height** — the smallest box a control worked by a finger reserves for
33/// it, which is what the reference gives a menu item (`popup_menu.dart:279`). It was 38,
34/// ten pixels under the number the accessibility scanners on both mobile platforms check
35/// for, on a widget whose entire purpose is to be tapped.
36const ROW_H: f32 = crate::theme::MIN_TAP_TARGET;
37/// The room either side of a row's label (`popup_menu.dart:1876`).
38const PAD_X: f32 = 12.0;
39/// The room above and below the rows, inside the panel (`popup_menu.dart:1872`). It is
40/// twice the panel's own corner, which is why a row's highlight can never reach a curve
41/// and the panel needs no clip.
42const PAD_Y: f32 = 8.0;
43/// How far off the page the panel sits: three, the reference's popup menu's. A menu
44/// anchor's panel and a dropdown menu's stand at the same three: the reference's menu
45/// defaults.
46const ELEVATION: f32 = 3.0;
47/// How far off the page a **dropdown button's** list sits: the reference's dropdown button
48/// takes eight in its constructor, and its painter casts the shadow that height is given.
49const DROPDOWN_ELEVATION: f32 = 8.0;
50/// **The leading column**: the mark itself, and the room between it and the words.
51/// Eighteen is the size of [`DropdownButton`](crate::DropdownButton)'s own tick — the
52/// nearest control in the framework that puts a mark beside a label — and twelve is the
53/// row's own padding again, so a marked row's words start two paddings in.
54const LEAD: f32 = 18.0;
55const LEAD_GAP: f32 = 12.0;
56/// The least room between a label and the keys shown on its right. A shortcut close
57/// enough to be read as the end of the label is worse than no shortcut.
58const SHORTCUT_GAP: f32 = 24.0;
59
60/// The panel's surface: the caller's word, then the theme's, then the reference's
61/// — `surface_container`, a menu being a **distinct area within** the surface rather
62/// than something floating above it in a colour of its own (`popup_menu.dart:1858`).
63fn panel_background(own: Option<Color>, theme: &Theme, kind: PanelKind) -> Color {
64    own.or(menu_theme(theme, kind).background)
65        .unwrap_or(theme.scheme.surface_container)
66}
67
68/// **The menu theme a panel of `kind` answers to**: the application's [`MenuTheme`] for a
69/// popup menu; for a menu bar's menus, the bar's own, then — on a desktop theme — a
70/// desktop's menus, then the application's (milestone 636).
71///
72/// [`MenuTheme`]: crate::MenuTheme
73pub(crate) fn menu_theme(theme: &Theme, kind: PanelKind) -> crate::MenuTheme {
74    match kind {
75        PanelKind::Bar => {
76            let desktop = if theme.platform.is_desktop() {
77                crate::MenuTheme::desktop(theme)
78            } else {
79                crate::MenuTheme::default()
80            };
81            theme
82                .widgets
83                .menu_bar
84                .menus
85                .or(desktop)
86                .or(theme.widgets.menu)
87        }
88        _ => theme.widgets.menu,
89    }
90}
91
92/// **Which theme a panel answers to** once its caller has said nothing.
93///
94/// Four widgets float a list on a panel, and they do not all read the same theme — nor
95/// do the reference's. A popup menu, a menu anchor and a dropdown menu all resolve
96/// through the menu theme to the menu defaults; a dropdown button reads neither and
97/// stands higher.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub(crate) enum PanelKind {
100    /// [`PopupMenuButton`], [`MenuAnchor`](crate::MenuAnchor) and
101    /// [`DropdownMenu`](crate::DropdownMenu): [`MenuTheme`](crate::MenuTheme), then three.
102    Menu,
103    /// [`DropdownButton`](crate::DropdownButton): the `menu_*` fields of
104    /// [`DropdownTheme`](crate::DropdownTheme), then eight.
105    Dropdown,
106    /// A [`MenuBar`](crate::MenuBar)'s menus: the bar's menus, then a desktop's on a desktop
107    /// theme, then [`MenuTheme`](crate::MenuTheme) — see [`menu_theme`].
108    Bar,
109}
110
111/// **What a caller said about a menu's panel**, every word optional. The theme's, then
112/// the framework's, fill in the rest — see [`PanelKind`] for whose.
113#[derive(Clone, Copy, Debug, Default, PartialEq)]
114pub(crate) struct PanelStyle {
115    pub(crate) background: Option<Color>,
116    pub(crate) shape: Option<ShapeBorder>,
117    pub(crate) elevation: Option<f32>,
118    pub(crate) shadow_color: Option<Color>,
119    pub(crate) padding: Option<Insets>,
120    /// The panel's outline (milestone 636).
121    pub(crate) border: Option<frus_core::BorderSide>,
122}
123
124impl PanelStyle {
125    /// The surface: the caller's, the theme's, then `surface_container` for every kind.
126    pub(crate) fn background(&self, kind: PanelKind, theme: &Theme) -> Color {
127        match kind {
128            PanelKind::Menu | PanelKind::Bar => panel_background(self.background, theme, kind),
129            PanelKind::Dropdown => self
130                .background
131                .or(theme.widgets.dropdown.menu_background)
132                .unwrap_or(theme.scheme.surface_container),
133        }
134    }
135
136    /// The shape: the caller's, the theme's shape, the theme's radius, then the
137    /// framework's one corner — see [`Panel::shape_of`] for why not the reference's.
138    fn shape(&self, kind: PanelKind, theme: &Theme) -> ShapeBorder {
139        let (shape, radius) = match kind {
140            PanelKind::Menu | PanelKind::Bar => {
141                let m = menu_theme(theme, kind);
142                (m.shape, m.radius)
143            }
144            PanelKind::Dropdown => (
145                theme.widgets.dropdown.menu_shape,
146                theme.widgets.dropdown.menu_radius,
147            ),
148        };
149        crate::resolve_shape(
150            self.shape,
151            shape,
152            radius.map(BorderRadius::uniform),
153            ShapeBorder::rounded(theme.radius),
154        )
155    }
156
157    /// The height: the caller's, the theme's, then three for a menu and eight for a
158    /// dropdown button.
159    fn elevation(&self, kind: PanelKind, theme: &Theme) -> f32 {
160        match kind {
161            PanelKind::Menu | PanelKind::Bar => self
162                .elevation
163                .or(menu_theme(theme, kind).elevation)
164                .unwrap_or(ELEVATION),
165            PanelKind::Dropdown => self
166                .elevation
167                .or(theme.widgets.dropdown.menu_elevation)
168                .unwrap_or(DROPDOWN_ELEVATION),
169        }
170    }
171
172    /// The shadow's colour: the caller's, the theme's, then the scheme's shadow at 30 %,
173    /// the framework's colour for a height. It is used **as given**, alpha and all, so a
174    /// transparent one casts nothing (milestone 529).
175    fn shadow_color(&self, kind: PanelKind, theme: &Theme) -> Color {
176        let themed = match kind {
177            PanelKind::Menu | PanelKind::Bar => menu_theme(theme, kind).shadow_color,
178            PanelKind::Dropdown => theme.widgets.dropdown.menu_shadow_color,
179        };
180        self.shadow_color.or(themed).unwrap_or(theme.scheme.shadow)
181    }
182
183    /// The room above and below the rows: the caller's, the theme's, then eight — the
184    /// reference's for a menu and for a dropdown button's list alike.
185    fn padding(&self, kind: PanelKind, theme: Option<&Theme>) -> Insets {
186        let themed = theme.and_then(|t| match kind {
187            PanelKind::Menu | PanelKind::Bar => menu_theme(t, kind).padding,
188            PanelKind::Dropdown => t.widgets.dropdown.menu_padding,
189        });
190        self.padding
191            .or(themed)
192            .unwrap_or(Insets::new(PAD_Y, 0.0, PAD_Y, 0.0))
193    }
194}
195
196/// The style the items are drawn in: what the caller said, else what the theme says, else
197/// the reference's — a popup menu's items are `labelLarge` (`popup_menu.dart:1849`).
198///
199/// **Resolved once**, so that the number the box is measured with is the number the glyphs
200/// are drawn at. Resolving is the single place the reader's font setting is applied
201/// (milestone 403); a size that never passes through it is a size the reader cannot change.
202fn label_style(
203    over: Option<TextStyle>,
204    theme: Option<&Theme>,
205    kind: PanelKind,
206) -> ResolvedTextStyle {
207    over.or(theme.and_then(|t| menu_theme(t, kind).text_style))
208        .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
209        .resolved()
210}
211
212/// What a row does with the keyboard in a menu bar (milestone 605): what the left and right
213/// arrows send, and whether it takes the focus when its menu opens. A popup menu's rows do
214/// none of it.
215#[derive(Clone)]
216pub(crate) struct RowKeys<Msg> {
217    pub(crate) left: Option<Msg>,
218    pub(crate) right: Option<Msg>,
219    /// What Escape sends from the row: a menu bar's closes the row's own menu and no more.
220    pub(crate) escape: Option<Msg>,
221    pub(crate) autofocus: bool,
222}
223
224impl<Msg> Default for RowKeys<Msg> {
225    fn default() -> Self {
226        Self {
227            left: None,
228            right: None,
229            escape: None,
230            autofocus: false,
231        }
232    }
233}
234
235/// **What sits in a row's leading column**, when the row has anything to put there.
236#[derive(Clone, Copy)]
237enum Lead {
238    /// A tick, drawn only when the row is on — and the column is kept either way, so
239    /// turning something off does not slide its own label across.
240    Check(bool),
241    /// A picture of the caller's choosing.
242    Icon(IconData),
243}
244
245/// What one row needs **across**, kept where the panel can reach it.
246///
247/// The panel is what decides how wide a menu is, so the panel has to be able to measure
248/// the rows — and it cannot ask them. By the time it holds them they are `dyn Widget`,
249/// and a widget cannot be asked how wide it would like to be
250/// ([#52](https://github.com/KalybosPro/frus/issues/52)). So the words are copied to
251/// where the measuring happens.
252#[derive(Clone)]
253struct Measure {
254    /// The row's own words, or `None` for a rule and for a row the caller drew.
255    label: Option<String>,
256    shortcut: Option<String>,
257    /// Whether the row opens a submenu, and so ends in a chevron (milestone 603).
258    submenu: bool,
259}
260
261/// One menu action, a clickable row.
262///
263/// **Not a button.** It has no surface and no outline of its own: it is a strip of the
264/// panel that lights up under a pointer, which is what a row in a list of actions is
265/// everywhere it appears. It carries the panel's resolved colour down so its state layer
266/// has the right thing to sit on.
267struct Item<Msg> {
268    /// The row's own words — `None` when the caller drew the row, in which case their
269    /// widget is child 0.
270    label: Option<String>,
271    /// `[the caller's widget]`, or empty for a row of words.
272    children: Vec<Box<dyn Widget<Msg>>>,
273    /// This row's mark, if it has one.
274    lead: Option<Lead>,
275    /// Whether **the menu** keeps a leading column at all — a different question from
276    /// whether this row has anything to put in it, and the whole reason the marks line up
277    /// down a column instead of every row deciding for itself.
278    lead_column: bool,
279    /// The keys that work this action, shown on the right.
280    shortcut: Option<String>,
281    /// Whether this row opens a submenu: a chevron at the end (milestone 603).
282    submenu: bool,
283    /// Whether that submenu is open: the row keeps its highlight while the pointer is in
284    /// the submenu, so the eye can find what the submenu belongs to (milestone 636).
285    expanded: bool,
286    /// What the left and right arrows send while this row has the focus, and whether it
287    /// takes the focus when its menu opens (milestone 605).
288    keys: RowKeys<Msg>,
289    /// Whether this row can be used. The menu's own availability is folded in here, but
290    /// only for tidiness: a disabled menu never opens, so there is no row to disable.
291    enabled: bool,
292    text_style: Option<TextStyle>,
293    /// The caller's panel colour, so a row's highlight tints the surface it is drawn on
294    /// and not the one the framework would have picked.
295    background: Option<Color>,
296    /// The caller's row padding and row height, if either was named.
297    padding: Option<Insets>,
298    height: Option<f32>,
299    /// Whose theme the row answers to.
300    kind: PanelKind,
301    message: Msg,
302}
303
304impl<Msg> Item<Msg> {
305    /// The room either side of the label: the caller's, then the theme's, then the
306    /// reference's twelve.
307    fn padding(&self, theme: Option<&Theme>) -> Insets {
308        self.padding
309            .or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
310            .unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X))
311    }
312
313    /// What the leading column takes from the words — nought unless the menu keeps one.
314    fn lead_room(&self) -> f32 {
315        if self.lead_column {
316            LEAD + LEAD_GAP
317        } else {
318            0.0
319        }
320    }
321
322    /// The room kept clear on the right for the keys, their gap included.
323    fn shortcut_room(&self, theme: Option<&Theme>) -> f32 {
324        let keys = self.shortcut.as_deref().map_or(0.0, |keys| {
325            SHORTCUT_GAP
326                + frus_text::measure_resolved(keys, &label_style(self.text_style, theme, self.kind))
327                    .width
328        });
329        keys + if self.submenu {
330            SHORTCUT_GAP + LEAD
331        } else {
332            0.0
333        }
334    }
335
336    fn sizing(&self, theme: Option<&Theme>) -> Style {
337        let height = self
338            .height
339            .or(theme.and_then(|t| menu_theme(t, self.kind).item_height))
340            .unwrap_or(ROW_H);
341        // The row grows if the reader's type does not fit in it — the height is a
342        // floor, not a promise.
343        let line =
344            frus_text::line_box(height, &label_style(self.text_style, theme, self.kind), 0.0);
345        // **Across, a row says nothing.** The panel decides how wide the menu is and
346        // stretches every row to it; a row that named its own width would leave the
347        // highlights ragged the moment one label was longer than the others.
348        if self.children.is_empty() {
349            return Style {
350                width: Dimension::Auto,
351                height: Dimension::Length(line),
352                ..Default::default()
353            };
354        }
355        let pad = self.padding(theme);
356        Style {
357            width: Dimension::Auto,
358            // A row the caller drew **grows**. A two-line item is half of why the form
359            // exists at all, and a fixed height would cut it in two.
360            height: Dimension::Auto,
361            min_height: Dimension::Length(line),
362            flex_direction: FlexDirection::Row,
363            align: Align::Center,
364            // The columns are kept clear by padding rather than by empty siblings, so a
365            // caller's widget is laid out in exactly the room the words would have had.
366            padding: Insets::new(
367                pad.top,
368                pad.right + self.shortcut_room(theme),
369                pad.bottom,
370                pad.left + self.lead_room(),
371            ),
372            ..Default::default()
373        }
374    }
375}
376
377impl<Msg: Clone> Widget<Msg> for Item<Msg> {
378    fn style(&self) -> Style {
379        self.sizing(None)
380    }
381
382    fn style_themed(&self, theme: &Theme) -> Style {
383        self.sizing(Some(theme))
384    }
385
386    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
387        &self.children
388    }
389
390    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
391        let o = status.opacity;
392        // A row draws **nothing at rest**. The panel behind it is the surface; all this
393        // adds is the state layer that says a pointer is over it. No state layer while
394        // disabled: a hover tint promises that a press would do something.
395        //
396        // It used to draw a filled, outlined, rounded rectangle per row — which made a
397        // menu a stack of buttons with two-pixel gutters showing the page through, where
398        // the reference has one panel with rows inside it.
399        let base = panel_background(self.background, theme, self.kind);
400        if self.enabled {
401            let mut status = status;
402            if self.expanded {
403                status.hover_progress = 1.0;
404            }
405            let tinted = theme.state_layer(base, theme.on_surface, &status);
406            if tinted != base {
407                scene.fill_rect(bounds, tinted.fade(o));
408            }
409        }
410        let ink = if self.enabled {
411            theme.on_surface
412        } else {
413            disabled_content(theme)
414        };
415        let style = label_style(self.text_style, Some(theme), self.kind);
416        let pad = self.padding(Some(theme));
417        // The mark, centred in its column. A tick that is off draws nothing and still
418        // holds its place.
419        let mark = match self.lead {
420            Some(Lead::Check(on)) => on.then_some(Icons::CHECK),
421            Some(Lead::Icon(icon)) => Some(icon),
422            None => None,
423        };
424        if let Some(icon) = mark {
425            let y = bounds.y + (bounds.height - LEAD) * 0.5;
426            let path = icon.placed(LEAD, bounds.x + pad.left, y, theme.direction);
427            scene.fill_path(&path, ink.fade(o));
428        }
429        let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
430        if let Some(label) = &self.label {
431            scene.text(
432                Point::new(bounds.x + pad.left + self.lead_room(), ty),
433                label.clone(),
434                &style,
435                ink.fade(o),
436            );
437        }
438        // A row that opens a submenu ends in a chevron, pointing where it opens: the end
439        // side, so the left in right-to-left (milestone 603).
440        let chevron_room = if self.submenu {
441            let x = bounds.x + bounds.width - pad.right - LEAD;
442            let y = bounds.y + (bounds.height - LEAD) * 0.5;
443            let path = Icons::CHEVRON_RIGHT.placed(LEAD, x, y, theme.direction);
444            scene.fill_path(&path, ink.fade(o));
445            SHORTCUT_GAP + LEAD
446        } else {
447            0.0
448        };
449        if let Some(keys) = &self.shortcut {
450            let width = frus_text::measure_resolved(keys, &style).width + chevron_room;
451            // **Muted, never the label's ink.** The keys are a reminder of another way
452            // in, not a second thing to read.
453            let tint = if self.enabled {
454                theme.muted
455            } else {
456                disabled_content(theme)
457            };
458            scene.text(
459                Point::new(bounds.x + bounds.width - pad.right - width, ty),
460                keys.clone(),
461                &style,
462                tint.fade(o),
463            );
464        }
465    }
466
467    fn on_click(&self) -> Option<Msg> {
468        if !self.enabled {
469            return None;
470        }
471        Some(self.message.clone())
472    }
473
474    fn focusable(&self) -> bool {
475        self.enabled
476    }
477
478    fn autofocus(&self) -> bool {
479        self.enabled && self.keys.autofocus
480    }
481
482    fn on_key(&self, key: &crate::interaction::Key) -> crate::interaction::KeyResponse<Msg> {
483        use crate::interaction::{Key, KeyResponse};
484        // A row that cannot be used answers no key, as it answers no press.
485        if !self.enabled {
486            return KeyResponse::Ignored;
487        }
488        let sent = match key {
489            Key::Left { .. } => self.keys.left.clone(),
490            Key::Right { .. } => self.keys.right.clone(),
491            Key::Escape => self.keys.escape.clone(),
492            _ => None,
493        };
494        match sent {
495            Some(message) => KeyResponse::Handled(Some(message)),
496            None => KeyResponse::Ignored,
497        }
498    }
499
500    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
501        // A menu row said nothing to a reader before this.
502        //
503        // A row that is on or off is announced as a **checkbox**, with its state: there
504        // is no `menuitemcheckbox` in this framework's vocabulary, and a checkbox is the
505        // role that carries the one thing such a row has to say. A row the caller drew
506        // announces nothing here and leaves that to whatever they drew.
507        let (role, toggled) = match self.lead {
508            Some(Lead::Check(on)) => (frus_core::Role::CheckBox, Some(on)),
509            _ => (frus_core::Role::Button, None),
510        };
511        let mut spoken = self.label.clone().unwrap_or_default();
512        if let Some(keys) = &self.shortcut {
513            // The keys are on the row for someone who can see them, and the announcement
514            // is the only other way anybody hears about them.
515            if !spoken.is_empty() {
516                spoken.push_str(", ");
517            }
518            spoken.push_str(keys);
519        }
520        let mut semantics = frus_core::SemanticsProperties::new(role).maybe_toggled(toggled);
521        if !spoken.is_empty() {
522            semantics = semantics.label(spoken);
523        }
524        Some(if self.enabled {
525            semantics.clickable()
526        } else {
527            semantics.disabled(true)
528        })
529    }
530}
531
532/// A rule between a menu's rows: the application's divider, as tall as the menu's theme
533/// says (milestone 636). Its own widget, not a divider built from the theme, because a
534/// built child sits in a box of its own and a rule's width is the column's stretch.
535struct Rule {
536    kind: PanelKind,
537    /// The caller's height, before the theme's.
538    height: Option<f32>,
539}
540
541impl Rule {
542    fn divider(&self, theme: &Theme) -> Divider {
543        match self.height.or(menu_theme(theme, self.kind).divider_height) {
544            Some(h) => Divider::new().height(h),
545            None => Divider::new(),
546        }
547    }
548}
549
550impl<Msg> Widget<Msg> for Rule {
551    fn style(&self) -> Style {
552        Widget::<Msg>::style(&Divider::new())
553    }
554
555    fn style_themed(&self, theme: &Theme) -> Style {
556        Widget::<Msg>::style_themed(&self.divider(theme), theme)
557    }
558
559    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
560        &[]
561    }
562
563    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
564        Widget::<Msg>::paint(&self.divider(theme), bounds, status, theme, scene);
565    }
566
567    fn on_click(&self) -> Option<Msg> {
568        None
569    }
570
571    fn debug_name(&self) -> &'static str {
572        "MenuRule"
573    }
574}
575
576/// **The thing a menu actually is**: one surface, off the page, with the rows inside it.
577///
578/// This did not exist. `rebuild` handed the overlay a bare column of rows, so a menu was
579/// a stack of outlined buttons with the page showing through two-pixel gutters — which
580/// is not what a menu looks like anywhere, and is not what the reference draws
581/// (`popup_menu.dart:1837`).
582///
583/// **Shared by every list that floats.** [`PopupMenuButton`] built it first;
584/// [`MenuAnchor`](crate::MenuAnchor), [`DropdownButton`](crate::DropdownButton) and
585/// [`DropdownMenu`](crate::DropdownMenu) float on it too since milestone 532, where each
586/// had floated bare content or a column of separately outlined rows with nothing behind
587/// them — and so nothing a shadow could be the shadow of. A third copy of the surface
588/// would have been three panels drifting apart; this is one, told by [`PanelKind`] whose
589/// theme it answers to.
590///
591/// **It does not clip.** A clip takes the clipping widget's own paint with it, and the
592/// shadow is that paint. The room above and below the rows keeps a row's highlight off
593/// the corners instead, and a list long enough to scroll clips in its own viewport,
594/// inside that room.
595pub(crate) struct Panel<Msg> {
596    children: Vec<Box<dyn Widget<Msg>>>,
597    kind: PanelKind,
598    look: PanelStyle,
599    /// The popup menu's rows, measured because the popup menu's panel is what decides
600    /// its width. `None` for a panel as wide as what is inside it.
601    measure: Option<RowMeasure>,
602}
603
604/// What a popup menu's panel measures its rows by — see [`Panel::row_width`].
605struct RowMeasure {
606    /// What each row needs across, in order — see [`Measure`]. The panel is what decides
607    /// the menu's width, so this is where the words have to be.
608    rows: Vec<Measure>,
609    /// Whether any row has a mark, and so whether every row keeps room for one.
610    lead_column: bool,
611    text_style: Option<TextStyle>,
612    item_padding: Option<Insets>,
613}
614
615impl<Msg> Panel<Msg> {
616    /// A panel **as wide as what is inside it**, in the look `kind` resolves `look` to.
617    pub(crate) fn new(
618        kind: PanelKind,
619        look: PanelStyle,
620        children: Vec<Box<dyn Widget<Msg>>>,
621    ) -> Self {
622        Self {
623            children,
624            kind,
625            look,
626            measure: None,
627        }
628    }
629
630    /// What shape the panel is: the caller's word, then the theme's shape, then the
631    /// theme's plain radius, then the framework's own corner.
632    ///
633    /// The framework's is `theme.radius` and **not** the reference's four. The reference
634    /// gives every component its own corner — four for a menu, twelve for a card,
635    /// twenty-eight for a sheet — where this framework collapses them into one number an
636    /// application sets once. Reaching into that collapse for one widget would make the
637    /// menu the only thing on screen that ignores it; `MenuTheme::radius` is there for an
638    /// application that wants the reference's number.
639    fn shape_of(&self, theme: &Theme) -> ShapeBorder {
640        self.look.shape(self.kind, theme)
641    }
642
643    /// The box: a column, the room above and below the rows, and — for a popup menu —
644    /// the width its rows measure to.
645    fn sizing(&self, theme: Option<&Theme>) -> Style {
646        let padding = self.look.padding(self.kind, theme);
647        Style {
648            // The rows are the width they measure to; the panel is that plus whatever
649            // room it was told to keep at its own edges.
650            width: self.row_width(theme).map_or(Dimension::Auto, |rows| {
651                Dimension::Length(rows + padding.left + padding.right)
652            }),
653            flex_direction: FlexDirection::Column,
654            padding,
655            ..Default::default()
656        }
657    }
658
659    /// **How wide a row is**: two hundred and twenty, or the widest row when a row wants
660    /// more than that.
661    ///
662    /// It was a bare constant. That is fine for a list of one-word actions and wrong the
663    /// moment a row carries a mark, a label and the keys that work it — and the way it
664    /// fails is by overlapping, which no assertion about the tree catches and only a
665    /// picture shows. The width is a **floor** so that no menu already drawn moves: a
666    /// hundred per cent of the menus in this repository still measure under it.
667    ///
668    /// A row the caller drew contributes nothing, because it cannot be asked what it
669    /// wants ([#52](https://github.com/KalybosPro/frus/issues/52)). It takes whatever
670    /// the words decided, and that is the single place in the menu where that gap shows.
671    ///
672    /// `None` for a panel that measures no rows — it is as wide as what is inside it.
673    fn row_width(&self, theme: Option<&Theme>) -> Option<f32> {
674        let measure = self.measure.as_ref()?;
675        let style = label_style(measure.text_style, theme, self.kind);
676        let pad = measure
677            .item_padding
678            .or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
679            .unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X));
680        let lead = if measure.lead_column {
681            LEAD + LEAD_GAP
682        } else {
683            0.0
684        };
685        let widest = measure.rows.iter().fold(0.0f32, |wide, row| {
686            let label = row
687                .label
688                .as_deref()
689                .map_or(0.0, |t| frus_text::measure_resolved(t, &style).width);
690            let keys = row.shortcut.as_deref().map_or(0.0, |t| {
691                SHORTCUT_GAP + frus_text::measure_resolved(t, &style).width
692            });
693            let chevron = if row.submenu {
694                SHORTCUT_GAP + LEAD
695            } else {
696                0.0
697            };
698            wide.max(pad.left + lead + label + keys + chevron + pad.right)
699        });
700        // Rounded up: half a pixel of a glyph past the edge is the whole of the bug.
701        Some(widest.max(WIDTH).ceil())
702    }
703}
704
705impl<Msg: Clone> Widget<Msg> for Panel<Msg> {
706    fn style(&self) -> Style {
707        self.sizing(None)
708    }
709
710    fn style_themed(&self, theme: &Theme) -> Style {
711        self.sizing(Some(theme))
712    }
713
714    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
715        &self.children
716    }
717
718    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
719        let o = status.opacity;
720        let shape = self.shape_of(theme);
721        let radius = shape
722            .as_rounded(bounds)
723            .map(|(_, r)| r)
724            .unwrap_or(BorderRadius::ZERO);
725        let depth = self.look.elevation(self.kind, theme);
726        let shadow = self.look.shadow_color(self.kind, theme);
727        // A height casts only in a colour that shows (milestone 529): a transparent
728        // shadow colour is the caller saying there is none, whatever the height.
729        if depth > 0.0 && shadow.a > 0.0 {
730            // The reference's shadows for this height (milestone 606).
731            frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
732        }
733        // Opaque, and no outline unless the theme names one: a panel that is off the page
734        // says so with its shadow. A desktop's menus draw a hairline as well
735        // (milestone 636).
736        let border = match self.kind {
737            PanelKind::Menu | PanelKind::Bar => {
738                self.look.border.or(menu_theme(theme, self.kind).border)
739            }
740            PanelKind::Dropdown => None,
741        };
742        let shape = match border {
743            Some(side) => {
744                shape.with_side(frus_core::BorderSide::new(side.color.fade(o), side.width))
745            }
746            None => shape,
747        };
748        scene.draw_shape(
749            bounds,
750            shape,
751            self.look.background(self.kind, theme).fade(o),
752        );
753    }
754
755    /// The panel itself answers nothing: the rows do.
756    fn on_click(&self) -> Option<Msg> {
757        None
758    }
759
760    /// It **traps** the press all the same. A menu is a surface, and a press on the room
761    /// above the first row, on the gap a rule leaves, or on a row that said it was
762    /// unavailable, must not reach the page behind and dismiss the menu.
763    ///
764    /// The comment above this used to claim `on_click` did that. It did not: a widget
765    /// with no message is not a target at all, so every one of those presses fell
766    /// through, and the menu closed. Per-row availability is what found it — before
767    /// that, every row in an open menu had a message and there was nothing to fall
768    /// through.
769    fn opaque(&self) -> bool {
770        true
771    }
772}
773
774/// **One entry of a [`PopupMenuButton`]'s list**: an action, or the rule between two
775/// groups of them.
776///
777/// The shorthands on the button itself — [`item`](PopupMenuButton::item),
778/// [`icon_item`](PopupMenuButton::icon_item),
779/// [`checked_item`](PopupMenuButton::checked_item),
780/// [`item_widget`](PopupMenuButton::item_widget) and
781/// [`divider`](PopupMenuButton::divider) — each build one of these, and are what most
782/// call sites should write. This type is the door to the two things a shorthand cannot
783/// carry: a row that is **not available** while the rest of the menu is, and the **keys**
784/// that work an action.
785///
786/// ```
787/// use frus_widgets::{Container, Icons, MenuItem, PopupMenuButton};
788///
789/// #[derive(Clone)]
790/// enum Msg {
791///     Close,
792///     Cut,
793///     Paste,
794///     Wrap,
795/// }
796///
797/// let menu = PopupMenuButton::new(Container::<Msg>::new(), true, Msg::Close)
798///     .entry(MenuItem::icon(Icons::CONTENT_CUT, "Cut", Msg::Cut).shortcut("Ctrl+X"))
799///     .entry(MenuItem::new("Paste", Msg::Paste).enabled(false))
800///     .divider()
801///     .checked_item("Word wrap", true, Msg::Wrap);
802/// ```
803pub struct MenuItem<Msg = crate::callback::Callback> {
804    label: Option<String>,
805    /// The caller's own drawing, shared so the panel can be built again.
806    child: Option<Rc<dyn Widget<Msg>>>,
807    lead: Option<Lead>,
808    shortcut: Option<String>,
809    enabled: bool,
810    /// **`None` is the rule** between two groups: the one entry that is not an action,
811    /// and so the one entry with nothing to send.
812    message: Option<Msg>,
813    /// Whether the row opens a submenu (milestone 603).
814    pub(crate) submenu: bool,
815    /// Whether that submenu is open (milestone 636).
816    pub(crate) expanded: bool,
817    /// The row's keyboard, in a menu bar (milestone 605).
818    pub(crate) keys: RowKeys<Msg>,
819}
820
821impl<Msg> MenuItem<Msg> {
822    /// An action with nothing on it yet.
823    fn action(message: Msg) -> Self {
824        Self {
825            label: None,
826            child: None,
827            lead: None,
828            shortcut: None,
829            enabled: true,
830            message: Some(message),
831            submenu: false,
832            expanded: false,
833            keys: RowKeys::default(),
834        }
835    }
836
837    /// A row of words: the ordinary item, and what
838    /// [`PopupMenuButton::item`] builds.
839    pub fn new(label: impl Into<String>, message: Msg) -> Self {
840        Self {
841            label: Some(label.into()),
842            ..Self::action(message)
843        }
844    }
845
846    /// A row **the caller draws**: two lines, a colour swatch, a badge, rich text —
847    /// whatever the menu is for.
848    ///
849    /// It is laid out in exactly the room a label would have had, columns and all, and it
850    /// **grows**: a row of words is a tap target tall, a row of anything else is at least
851    /// that and as much more as it needs.
852    pub fn widget(child: impl Widget<Msg> + 'static, message: Msg) -> Self {
853        Self {
854            child: Some(Rc::new(child)),
855            ..Self::action(message)
856        }
857    }
858
859    /// A row with a picture in the leading column.
860    pub fn icon(icon: IconData, label: impl Into<String>, message: Msg) -> Self {
861        Self {
862            lead: Some(Lead::Icon(icon)),
863            ..Self::new(label, message)
864        }
865    }
866
867    /// A row that is **on or off**, ticked when it is on.
868    ///
869    /// It still takes a message, and turning it over is still the application's job: this
870    /// says what the state *is*, not what it will be.
871    pub fn checked(label: impl Into<String>, checked: bool, message: Msg) -> Self {
872        Self {
873            lead: Some(Lead::Check(checked)),
874            ..Self::new(label, message)
875        }
876    }
877
878    /// The **rule** between two groups of actions. It answers nothing, takes no focus,
879    /// and is not a row's height: sixteen, the room a separator needs to read as one.
880    pub fn divider() -> Self {
881        Self {
882            label: None,
883            child: None,
884            lead: None,
885            shortcut: None,
886            enabled: false,
887            message: None,
888            submenu: false,
889            expanded: false,
890            keys: RowKeys::default(),
891        }
892    }
893
894    /// Whether **this row** can be used, over the menu's own availability.
895    ///
896    /// A disabled row is still drawn and still read out — greyed, announced as disabled,
897    /// and returning no message. An action that is missing from the list altogether tells
898    /// a reader nothing about why.
899    #[must_use]
900    pub fn enabled(mut self, enabled: bool) -> Self {
901        self.enabled = enabled;
902        self
903    }
904
905    /// Whether the row is an action that can be used: not a rule, not disabled.
906    pub(crate) fn is_action(&self) -> bool {
907        self.message.is_some() && self.enabled
908    }
909
910    /// The same row again, for a menu built twice (milestone 603).
911    pub(crate) fn clone_row(&self) -> Self
912    where
913        Msg: Clone,
914    {
915        Self {
916            label: self.label.clone(),
917            child: self.child.clone(),
918            lead: self.lead,
919            shortcut: self.shortcut.clone(),
920            enabled: self.enabled,
921            message: self.message.clone(),
922            submenu: self.submenu,
923            expanded: self.expanded,
924            keys: self.keys.clone(),
925        }
926    }
927
928    /// The **keys** that work this action, shown muted on the right.
929    ///
930    /// The text is the caller's, not a key combination this framework parses: the
931    /// spelling differs by platform (`Ctrl+X`, `⌘X`) and nothing here knows which one an
932    /// application means. It is announced after the label, since a reader who cannot see
933    /// it has no other way to hear it.
934    #[must_use]
935    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
936        self.shortcut = Some(keys.into());
937        self
938    }
939}
940
941/// A controlled action menu, opened and closed by the application.
942pub struct PopupMenuButton<Msg = crate::callback::Callback> {
943    open: bool,
944    enabled: bool,
945    /// `[anchor]`, or `[anchor, list]` when the menu is showing.
946    children: Vec<Box<dyn Widget<Msg>>>,
947    items: Vec<MenuItem<Msg>>,
948    text_style: Option<TextStyle>,
949    /// The panel's look, and the rows'. Every builder that writes one of these calls
950    /// [`rebuild`](Self::rebuild), so **the order they are written in does not matter**
951    /// — unlike [`BottomSheet`](crate::BottomSheet), where the panel is built by
952    /// `body` and anything said after it is dropped.
953    look: PanelStyle,
954    item_padding: Option<Insets>,
955    item_height: Option<f32>,
956    dismiss: Option<Msg>,
957    on_dismiss: Option<Msg>,
958}
959
960impl<Msg: Clone + 'static> PopupMenuButton<Msg> {
961    /// Creates a menu around an anchor. When `open`, the list floats above it;
962    /// `on_dismiss` is emitted on a click **outside** the menu.
963    pub fn new(anchor: impl Widget<Msg> + 'static, open: bool, on_dismiss: Msg) -> Self {
964        Self {
965            open,
966            enabled: true,
967            children: vec![Box::new(anchor)],
968            items: Vec::new(),
969            text_style: None,
970            look: PanelStyle::default(),
971            item_padding: None,
972            item_height: None,
973            dismiss: Some(on_dismiss.clone()),
974            on_dismiss: if open { Some(on_dismiss) } else { None },
975        }
976    }
977
978    /// The items' type, over the theme's and the reference's.
979    #[must_use]
980    pub fn text_style(mut self, style: TextStyle) -> Self {
981        self.text_style = Some(style);
982        self.rebuild();
983        self
984    }
985
986    /// **The panel's surface**, over the theme's and the reference's `surface_container`.
987    #[must_use]
988    pub fn background(mut self, color: Color) -> Self {
989        self.look.background = Some(color);
990        self.rebuild();
991        self
992    }
993
994    /// **What shape the panel is**, over the theme's and the framework's corner.
995    #[must_use]
996    pub fn shape(mut self, shape: ShapeBorder) -> Self {
997        self.look.shape = Some(shape);
998        self.rebuild();
999        self
1000    }
1001
1002    /// The shorthand for a rounded rectangle — `radius(4.0)` is the reference's own
1003    /// menu corner, which this framework does not use by default because it keeps **one**
1004    /// corner for the whole interface, in `Theme::radius`. Reaching past that for a single
1005    /// widget would make the menu the only thing on screen ignoring the number an
1006    /// application set.
1007    #[must_use]
1008    pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
1009        self.shape(ShapeBorder::rounded(radius.into()))
1010    }
1011
1012    /// **How far off the page the panel sits**, in pixels. Three by default.
1013    #[must_use]
1014    pub fn elevation(mut self, elevation: f32) -> Self {
1015        self.look.elevation = Some(elevation);
1016        self.rebuild();
1017        self
1018    }
1019
1020    /// The colour of the panel's shadow, over the theme's and the scheme's shadow at 30 %.
1021    /// It is used as given, alpha and all: [`Color::TRANSPARENT`] casts none.
1022    #[must_use]
1023    pub fn shadow_color(mut self, color: Color) -> Self {
1024        self.look.shadow_color = Some(color);
1025        self.rebuild();
1026        self
1027    }
1028
1029    /// The room kept **above and below** the rows, inside the panel.
1030    #[must_use]
1031    pub fn menu_padding(mut self, padding: Insets) -> Self {
1032        self.look.padding = Some(padding);
1033        self.rebuild();
1034        self
1035    }
1036
1037    /// The room kept either side of a row's label.
1038    #[must_use]
1039    pub fn item_padding(mut self, padding: Insets) -> Self {
1040        self.item_padding = Some(padding);
1041        self.rebuild();
1042        self
1043    }
1044
1045    /// **How tall one row is**, as a floor — a row still grows to fit the reader's
1046    /// type. The default is the smallest box a finger can be asked to hit.
1047    #[must_use]
1048    pub fn item_height(mut self, height: f32) -> Self {
1049        self.item_height = Some(height);
1050        self.rebuild();
1051        self
1052    }
1053
1054    /// Whether the menu can be used. Disabled it is **inert** and, like a disabled
1055    /// [`DropdownButton`](crate::DropdownButton), **never open**: a floating panel over an anchor that
1056    /// answers nothing traps a press and returns no message.
1057    ///
1058    /// See [`crate::disabled`] for the whole contract.
1059    pub fn enabled(mut self, enabled: bool) -> Self {
1060        self.enabled = enabled;
1061        self.on_dismiss = if self.open && enabled {
1062            self.dismiss.clone()
1063        } else {
1064            None
1065        };
1066        self.rebuild();
1067        self
1068    }
1069
1070    /// Adds an action: a label plus a message on click. Ignored when the menu is closed.
1071    pub fn item(self, label: impl Into<String>, message: Msg) -> Self {
1072        self.entry(MenuItem::new(label, message))
1073    }
1074
1075    /// Adds a row **the caller draws** — two lines, a swatch, a badge, rich text — plus
1076    /// the message it sends.
1077    pub fn item_widget(self, child: impl Widget<Msg> + 'static, message: Msg) -> Self {
1078        self.entry(MenuItem::widget(child, message))
1079    }
1080
1081    /// Adds an action with a picture in the leading column.
1082    pub fn icon_item(self, icon: IconData, label: impl Into<String>, message: Msg) -> Self {
1083        self.entry(MenuItem::icon(icon, label, message))
1084    }
1085
1086    /// Adds an action that is **on or off**, ticked when it is on.
1087    pub fn checked_item(self, label: impl Into<String>, checked: bool, message: Msg) -> Self {
1088        self.entry(MenuItem::checked(label, checked, message))
1089    }
1090
1091    /// Adds the **rule** between two groups of actions.
1092    pub fn divider(self) -> Self {
1093        self.entry(MenuItem::divider())
1094    }
1095
1096    /// Adds an entry however it was built — the door to [`MenuItem::enabled`] and
1097    /// [`MenuItem::shortcut`], which no shorthand carries. Ignored when the menu is
1098    /// closed.
1099    pub fn entry(mut self, item: MenuItem<Msg>) -> Self {
1100        if self.open {
1101            self.items.push(item);
1102            self.rebuild();
1103        }
1104        self
1105    }
1106
1107    /// (Re)builds the floating panel (child 1) from the items.
1108    ///
1109    /// A disabled menu keeps only its anchor, so there is no overlay to return and no
1110    /// panel to trap a press.
1111    fn rebuild(&mut self) {
1112        if !self.enabled {
1113            self.children.truncate(1);
1114            return;
1115        }
1116        let panel = menu_panel(
1117            &self.items,
1118            &RowLook {
1119                look: self.look,
1120                text_style: self.text_style,
1121                item_padding: self.item_padding,
1122                item_height: self.item_height,
1123                divider_height: None,
1124                enabled: self.enabled,
1125                kind: PanelKind::Menu,
1126            },
1127            &|_, row| row,
1128        );
1129        if self.children.len() > 1 {
1130            self.children[1] = panel;
1131        } else {
1132            self.children.push(panel);
1133        }
1134    }
1135}
1136
1137impl<Msg: Clone> Widget<Msg> for PopupMenuButton<Msg> {
1138    fn style(&self) -> Style {
1139        Style {
1140            flex_direction: FlexDirection::Column,
1141            ..Default::default()
1142        }
1143    }
1144
1145    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1146        &self.children
1147    }
1148
1149    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
1150
1151    fn on_click(&self) -> Option<Msg> {
1152        None
1153    }
1154
1155    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
1156        self.children
1157            .get(1)
1158            .map(|panel| (panel.as_ref(), Placement::Below))
1159    }
1160
1161    fn overlay_dismiss(&self) -> Option<Msg> {
1162        self.on_dismiss.clone()
1163    }
1164
1165    fn overlay_traps_focus(&self) -> bool {
1166        // An open menu **traps** keyboard focus in its items — Escape or an outside
1167        // click closes it through `on_dismiss` — the keyboard pattern menus are expected
1168        // to follow.
1169        self.open
1170    }
1171}
1172
1173/// How the rows of a floating menu look, shared by everything that builds one.
1174#[derive(Clone, Copy)]
1175pub(crate) struct RowLook {
1176    pub(crate) look: PanelStyle,
1177    pub(crate) text_style: Option<TextStyle>,
1178    pub(crate) item_padding: Option<Insets>,
1179    pub(crate) item_height: Option<f32>,
1180    /// The caller's height for the rules between groups (milestone 636).
1181    pub(crate) divider_height: Option<f32>,
1182    pub(crate) enabled: bool,
1183    /// Whose theme the panel and its rows answer to.
1184    pub(crate) kind: PanelKind,
1185}
1186
1187/// The panel of a floating menu: its rows on one surface, as wide as its widest row.
1188/// `decorate` is handed each row with its entry's index, and may wrap it: a menu bar puts
1189/// the hover that opens a submenu there, and the submenu itself (milestone 603).
1190pub(crate) fn menu_panel<Msg: Clone + 'static>(
1191    items: &[MenuItem<Msg>],
1192    row: &RowLook,
1193    decorate: &dyn Fn(usize, Box<dyn Widget<Msg>>) -> Box<dyn Widget<Msg>>,
1194) -> Box<dyn Widget<Msg>> {
1195    // **Whether the menu keeps a leading column is decided once, for the menu.** The
1196    // marks line up down one column and a row with nothing to put there still keeps
1197    // the room — the alternative is labels that go ragged as things are turned on and
1198    // off, which is the version every desktop menu decided against.
1199    let lead_column = items.iter().any(|item| item.lead.is_some());
1200    // No gap. The rows are contiguous strips of one surface; the two-pixel gutter
1201    // this used to leave showed the page through the middle of the menu.
1202    let mut list = Flex::column();
1203    let mut rows = Vec::with_capacity(items.len());
1204    for (index, item) in items.iter().enumerate() {
1205        rows.push(Measure {
1206            label: item.label.clone(),
1207            shortcut: item.shortcut.clone(),
1208            submenu: item.submenu,
1209        });
1210        let Some(message) = &item.message else {
1211            // A rule, and not a row: it is not a tap target tall, it takes no focus
1212            // and it answers nothing. As tall as the theme says (milestone 636).
1213            list = list.child(Rule {
1214                kind: row.kind,
1215                height: row.divider_height,
1216            });
1217            continue;
1218        };
1219        let built: Box<dyn Widget<Msg>> = Box::new(Item {
1220            label: item.label.clone(),
1221            children: item
1222                .child
1223                .clone()
1224                .map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
1225                .into_iter()
1226                .collect(),
1227            lead: item.lead,
1228            lead_column,
1229            shortcut: item.shortcut.clone(),
1230            submenu: item.submenu,
1231            expanded: item.expanded,
1232            keys: item.keys.clone(),
1233            enabled: row.enabled && item.enabled,
1234            text_style: row.text_style,
1235            background: row.look.background,
1236            padding: row.item_padding,
1237            height: row.item_height,
1238            kind: row.kind,
1239            message: message.clone(),
1240        });
1241        list = list.child(decorate(index, built));
1242    }
1243    Box::new(Panel {
1244        measure: Some(RowMeasure {
1245            rows,
1246            lead_column,
1247            text_style: row.text_style,
1248            item_padding: row.item_padding,
1249        }),
1250        ..Panel::new(row.kind, row.look, vec![Box::new(list)])
1251    })
1252}
1253
1254/// Reading a menu's panel back out of a frame — shared by the four widgets that float on
1255/// one, so each asserts the same thing the same way.
1256#[cfg(test)]
1257pub(crate) mod probe {
1258    use frus_core::{BorderRadius, Color, Primitive, Rect, Scene};
1259
1260    /// One rectangle as it was painted.
1261    #[derive(Clone, Copy, Debug, PartialEq)]
1262    pub(crate) struct Painted {
1263        pub(crate) rect: Rect,
1264        pub(crate) color: Color,
1265        pub(crate) radius: BorderRadius,
1266        pub(crate) border_width: f32,
1267        pub(crate) blur: f32,
1268        /// The rectangle nothing of it is drawn outside — a scroll viewport's, inside one.
1269        pub(crate) clip: Rect,
1270    }
1271
1272    /// The height the panel's shadows are cast from (milestone 606).
1273    pub(crate) fn height_of(scene: &Scene) -> f32 {
1274        crate::shadowprobe::height(scene.primitives())
1275    }
1276
1277    /// The colour they are cast in, at full strength.
1278    pub(crate) fn colour_of(scene: &Scene) -> Option<Color> {
1279        crate::shadowprobe::colour(scene.primitives())
1280    }
1281
1282    /// Every rectangle in the frame, in paint order, innermost layers included.
1283    fn painted(scene: &Scene) -> Vec<Painted> {
1284        fn walk(primitives: &[Primitive], out: &mut Vec<Painted>) {
1285            for p in primitives {
1286                match p {
1287                    Primitive::Rect {
1288                        rect,
1289                        color,
1290                        radius,
1291                        border_width,
1292                        blur,
1293                        clip,
1294                        ..
1295                    } => out.push(Painted {
1296                        rect: *rect,
1297                        color: *color,
1298                        radius: *radius,
1299                        border_width: *border_width,
1300                        blur: *blur,
1301                        clip: *clip,
1302                    }),
1303                    Primitive::Layer { primitives, .. } => walk(primitives, out),
1304                    _ => {}
1305                }
1306            }
1307        }
1308        let mut out = Vec::new();
1309        walk(scene.primitives(), &mut out);
1310        out
1311    }
1312
1313    /// The crisp rectangles: surfaces, highlights, outlines.
1314    pub(crate) fn crisp(scene: &Scene) -> Vec<Painted> {
1315        painted(scene)
1316            .into_iter()
1317            .filter(|r| r.blur == 0.0)
1318            .collect()
1319    }
1320
1321    /// The blurred ones: shadows.
1322    pub(crate) fn blurred(scene: &Scene) -> Vec<Painted> {
1323        painted(scene)
1324            .into_iter()
1325            .filter(|r| r.blur > 0.0)
1326            .collect()
1327    }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::probe::{blurred, colour_of, height_of};
1333    use super::*;
1334    use crate::{build_ui, Container, Point as P, Runtime, Size};
1335
1336    #[derive(Clone, Debug, PartialEq)]
1337    enum Msg {
1338        Close,
1339        A,
1340        B,
1341    }
1342
1343    fn anchor() -> Container<Msg> {
1344        Container::<Msg>::new().width(40.0).height(30.0)
1345    }
1346
1347    /// Every filled rectangle in the frame, innermost layers included, as
1348    /// `(rect, colour, radius, border width)`.
1349    fn rects(scene: &frus_core::Scene) -> Vec<(Rect, frus_core::Color, BorderRadius, f32)> {
1350        fn walk(
1351            primitives: &[frus_core::Primitive],
1352            out: &mut Vec<(Rect, frus_core::Color, BorderRadius, f32)>,
1353        ) {
1354            for p in primitives {
1355                match p {
1356                    frus_core::Primitive::Rect {
1357                        rect,
1358                        color,
1359                        radius,
1360                        border_width,
1361                        ..
1362                    } => out.push((*rect, *color, *radius, *border_width)),
1363                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1364                    _ => {}
1365                }
1366            }
1367        }
1368        let mut out = Vec::new();
1369        walk(scene.primitives(), &mut out);
1370        out
1371    }
1372
1373    fn open_menu() -> PopupMenuButton<Msg> {
1374        PopupMenuButton::new(anchor(), true, Msg::Close)
1375            .item("A", Msg::A)
1376            .item("B", Msg::B)
1377    }
1378
1379    fn frame(menu: &PopupMenuButton<Msg>, theme: &Theme) -> crate::Ui<Msg> {
1380        build_ui(menu, Size::new(400.0, 400.0), &Runtime::default(), theme)
1381    }
1382
1383    /// **A menu is one panel, not a stack of buttons.**
1384    ///
1385    /// It used to draw a filled, outlined, rounded rectangle **per row**, with a
1386    /// two-pixel gutter between them showing the page through the middle of the menu.
1387    /// That is not what a menu looks like anywhere, and the reference draws one surface
1388    /// with rows inside it (`popup_menu.dart:1837`).
1389    ///
1390    /// So: one opaque rectangle the width of the menu, in `surface_container`, and
1391    /// **not one border anywhere** — a panel that is off the page says so with its
1392    /// shadow, and a shadow with a hairline is the mash-up milestone 279 took out of the
1393    /// card.
1394    #[test]
1395    fn a_menu_is_one_panel_and_not_a_stack_of_buttons() {
1396        let theme = Theme::default();
1397        let ui = frame(&open_menu(), &theme);
1398        let painted = rects(ui.scene());
1399
1400        let panels: Vec<_> = painted
1401            .iter()
1402            .filter(|(rect, color, ..)| {
1403                rect.width >= WIDTH && *color == theme.scheme.surface_container
1404            })
1405            .collect();
1406        assert_eq!(
1407            panels.len(),
1408            1,
1409            "one surface, not one per row: {painted:#?}"
1410        );
1411        assert!(
1412            panels[0].2 != BorderRadius::ZERO,
1413            "and it is rounded: {:?}",
1414            panels[0].2
1415        );
1416
1417        assert!(
1418            painted.iter().all(|(.., border)| *border == 0.0),
1419            "nothing in an open menu draws an outline: {painted:#?}"
1420        );
1421    }
1422
1423    /// **A row is at least a tap target.** It was 38 pixels tall — ten under the
1424    /// number the accessibility scanners on both mobile platforms check for, on a widget
1425    /// whose entire purpose is to be tapped. The reference gives a menu item
1426    /// `kMinInteractiveDimension` (`popup_menu.dart:279`).
1427    ///
1428    /// The panel is measured too: two rows of 48 plus 8 above and 8 below.
1429    #[test]
1430    fn a_row_is_at_least_a_tap_target() {
1431        let theme = Theme::default();
1432        let ui = frame(&open_menu(), &theme);
1433        let panel = ui
1434            .scene()
1435            .primitives()
1436            .iter()
1437            .find_map(|p| find_panel(p, &theme))
1438            .expect("a panel");
1439        assert!(
1440            panel.height >= 2.0 * crate::theme::MIN_TAP_TARGET + 2.0 * PAD_Y,
1441            "two rows of a tap target each, plus the panel's own room: {panel:?}"
1442        );
1443    }
1444
1445    fn find_panel(p: &frus_core::Primitive, theme: &Theme) -> Option<Rect> {
1446        match p {
1447            frus_core::Primitive::Rect { rect, color, .. }
1448                if rect.width >= WIDTH && *color == theme.scheme.surface_container =>
1449            {
1450                Some(*rect)
1451            }
1452            frus_core::Primitive::Layer { primitives, .. } => {
1453                primitives.iter().find_map(|p| find_panel(p, theme))
1454            }
1455            _ => None,
1456        }
1457    }
1458
1459    /// **The panel answers to a theme, and to its caller over the theme** — the
1460    /// surface, the corner, the room inside it and how tall a row is. None of these were
1461    /// reachable at all: a menu picked every one of them for itself.
1462    #[test]
1463    fn a_menu_answers_to_its_theme_and_to_its_caller() {
1464        let mut theme = Theme::default();
1465        theme.widgets.menu.background = Some(frus_core::Color::rgb(0.2, 0.4, 0.6));
1466        theme.widgets.menu.radius = Some(3.0);
1467        theme.widgets.menu.item_height = Some(60.0);
1468        theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
1469
1470        let ui = frame(&open_menu(), &theme);
1471        let panel = rects(ui.scene())
1472            .into_iter()
1473            .find(|(rect, color, ..)| {
1474                rect.width >= WIDTH && *color == frus_core::Color::rgb(0.2, 0.4, 0.6)
1475            })
1476            .expect("the theme's surface");
1477        assert_eq!(panel.2, BorderRadius::uniform(3.0), "the theme's corner");
1478        assert_eq!(
1479            panel.0.height, 160.0,
1480            "two rows of 60 and 20 above and below"
1481        );
1482
1483        // The caller outranks it, all four.
1484        let told = open_menu()
1485            .background(frus_core::Color::rgb(0.9, 0.1, 0.1))
1486            .radius(9.0)
1487            .item_height(50.0)
1488            .menu_padding(Insets::ZERO);
1489        let ui = frame(&told, &theme);
1490        let panel = rects(ui.scene())
1491            .into_iter()
1492            .find(|(rect, color, ..)| {
1493                rect.width >= WIDTH && *color == frus_core::Color::rgb(0.9, 0.1, 0.1)
1494            })
1495            .expect("the caller's surface");
1496        assert_eq!(panel.2, BorderRadius::uniform(9.0));
1497        assert_eq!(panel.0.height, 100.0);
1498    }
1499
1500    /// **The order the builders are written in does not matter.** Every one of them
1501    /// rebuilds the panel, so `.item(..)` after `.background(..)` and `.background(..)`
1502    /// after `.item(..)` produce the same menu.
1503    ///
1504    /// This is the trap milestone 458 found in [`BottomSheet`](crate::BottomSheet), where
1505    /// the panel is built by `body` and anything said after it is silently dropped. It is
1506    /// worth a test here precisely because it is the kind of thing that is true when
1507    /// written and quietly stops being true later.
1508    #[test]
1509    fn the_builders_can_be_written_in_any_order() {
1510        let theme = Theme::default();
1511        let colour = frus_core::Color::rgb(0.9, 0.1, 0.1);
1512        let first = PopupMenuButton::new(anchor(), true, Msg::Close)
1513            .background(colour)
1514            .item("A", Msg::A)
1515            .item("B", Msg::B);
1516        let last = open_menu().background(colour);
1517        assert_eq!(
1518            rects(frame(&first, &theme).scene()),
1519            rects(frame(&last, &theme).scene())
1520        );
1521    }
1522
1523    /// Every run of text in the frame, as `(where it starts, what it says)`.
1524    fn texts(scene: &frus_core::Scene) -> Vec<(Point, String)> {
1525        fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Point, String)>) {
1526            for p in primitives {
1527                match p {
1528                    frus_core::Primitive::Text { position, text, .. } => {
1529                        out.push((*position, text.clone()))
1530                    }
1531                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1532                    _ => {}
1533                }
1534            }
1535        }
1536        let mut out = Vec::new();
1537        walk(scene.primitives(), &mut out);
1538        out
1539    }
1540
1541    /// Where a given run of text starts.
1542    fn text_at(ui: &crate::Ui<Msg>, words: &str) -> Point {
1543        texts(ui.scene())
1544            .into_iter()
1545            .find(|(_, text)| text == words)
1546            .unwrap_or_else(|| panic!("{words:?} is drawn"))
1547            .0
1548    }
1549
1550    /// The message a press at a point produces, if any.
1551    fn press(ui: &crate::Ui<Msg>, x: f32, y: f32) -> Option<Msg> {
1552        ui.hit(P::new(x, y)).and_then(|id| ui.msg_for(id))
1553    }
1554
1555    /// The middle of row `n`, in a menu whose anchor is 30 tall and whose rows are the
1556    /// default height.
1557    fn row_middle(n: f32) -> f32 {
1558        30.0 + PAD_Y + ROW_H * (n + 0.5)
1559    }
1560
1561    /// **The marks line up down one column**, and a row with nothing to put in it keeps
1562    /// the room all the same.
1563    ///
1564    /// The alternative — each row indenting itself only when it has a mark — is a menu
1565    /// whose labels move sideways as things are turned on and off, which is why no
1566    /// desktop menu has ever done it that way. It is decided once, for the menu, and a
1567    /// menu with no marks at all is not indented at all: every picture already in this
1568    /// repository is of one of those.
1569    #[test]
1570    fn the_marks_line_up_down_one_column() {
1571        let theme = Theme::default();
1572        let plain = frame(&open_menu(), &theme);
1573        let marked = frame(
1574            &PopupMenuButton::new(anchor(), true, Msg::Close)
1575                .checked_item("A", true, Msg::A)
1576                .item("B", Msg::B),
1577            &theme,
1578        );
1579
1580        let unmarked_row = text_at(&marked, "B").x;
1581        assert_eq!(
1582            text_at(&marked, "A").x,
1583            unmarked_row,
1584            "the ticked row and the row with nothing start in the same place"
1585        );
1586        assert_eq!(
1587            unmarked_row - text_at(&plain, "B").x,
1588            LEAD + LEAD_GAP,
1589            "and that place is the leading column further in than in a menu with no marks"
1590        );
1591    }
1592
1593    /// **A tick that is off still holds its place.** It draws nothing — there is one
1594    /// filled path in the menu when one of the two rows is on, and none when neither is
1595    /// — and the labels do not move between the two.
1596    #[test]
1597    fn a_tick_that_is_off_draws_nothing_and_moves_nothing() {
1598        let theme = Theme::default();
1599        let menu = |on: bool| {
1600            PopupMenuButton::new(anchor(), true, Msg::Close)
1601                .checked_item("A", on, Msg::A)
1602                .item("B", Msg::B)
1603        };
1604        let (off, on) = (frame(&menu(false), &theme), frame(&menu(true), &theme));
1605        assert_eq!(
1606            paths(off.scene()),
1607            0,
1608            "nothing is drawn for a tick that is off"
1609        );
1610        assert_eq!(paths(on.scene()), 1, "and one tick when it is on");
1611        assert_eq!(
1612            text_at(&off, "A").x,
1613            text_at(&on, "A").x,
1614            "the label does not move when the tick appears"
1615        );
1616    }
1617
1618    /// How many filled paths the frame holds — the marks, since a menu draws nothing else
1619    /// with one.
1620    fn paths(scene: &frus_core::Scene) -> usize {
1621        fn walk(primitives: &[frus_core::Primitive]) -> usize {
1622            primitives
1623                .iter()
1624                .map(|p| match p {
1625                    frus_core::Primitive::Path { .. } => 1,
1626                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives),
1627                    _ => 0,
1628                })
1629                .sum()
1630        }
1631        walk(scene.primitives())
1632    }
1633
1634    /// **A row can be a widget the caller drew**, laid out in exactly the room the words
1635    /// would have had — and it **grows**, which a row of words does not.
1636    #[test]
1637    fn a_row_can_be_a_widget_the_caller_drew() {
1638        let theme = Theme::default();
1639        let mark = frus_core::Color::rgb(0.9, 0.1, 0.1);
1640        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1641            .item_widget(
1642                Container::<Msg>::new().width(60.0).height(80.0).color(mark),
1643                Msg::A,
1644            )
1645            .item("B", Msg::B);
1646        let ui = frame(&menu, &theme);
1647        let drawn = rects(ui.scene())
1648            .into_iter()
1649            .find(|(_, color, ..)| *color == mark)
1650            .expect("the caller's own widget is drawn");
1651        assert_eq!(
1652            drawn.0.x,
1653            text_at(&ui, "B").x,
1654            "it starts where a label would have started"
1655        );
1656        assert_eq!(
1657            drawn.0.height, 80.0,
1658            "and it is given the height it asked for"
1659        );
1660
1661        // The row grew with it: the panel is the tall row, the ordinary row, and its own
1662        // room above and below.
1663        let panel = ui
1664            .scene()
1665            .primitives()
1666            .iter()
1667            .find_map(|p| find_panel(p, &theme))
1668            .expect("a panel");
1669        assert_eq!(panel.height, 80.0 + ROW_H + 2.0 * PAD_Y);
1670    }
1671
1672    /// **A rule is not a row.** Sixteen tall rather than a tap target, no message, and no
1673    /// place in the tab order — a separator that could be focused is a separator that
1674    /// swallows a keystroke.
1675    #[test]
1676    fn a_rule_is_not_a_row() {
1677        let theme = Theme::default();
1678        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1679            .item("A", Msg::A)
1680            .divider()
1681            .item("B", Msg::B);
1682        let ui = frame(&menu, &theme);
1683        let panel = ui
1684            .scene()
1685            .primitives()
1686            .iter()
1687            .find_map(|p| find_panel(p, &theme))
1688            .expect("a panel");
1689        assert_eq!(
1690            panel.height,
1691            2.0 * ROW_H + crate::divider::DIVIDER_SPACE + 2.0 * PAD_Y,
1692            "two rows, a rule of sixteen, and the panel's own room"
1693        );
1694        assert_eq!(
1695            press(&ui, 110.0, row_middle(0.0)),
1696            Some(Msg::A),
1697            "the row above it still answers"
1698        );
1699    }
1700
1701    /// **One row can be unavailable while the rest of the menu is not.** It was the whole
1702    /// menu or nothing, so an application with one action it could not offer had to leave
1703    /// it out — and an action that is missing tells a reader nothing about why.
1704    ///
1705    /// It is still drawn, in the greyed ink, and still announced.
1706    #[test]
1707    fn one_row_can_be_unavailable_while_the_rest_is_not() {
1708        let theme = Theme::default();
1709        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1710            .item("A", Msg::A)
1711            .entry(MenuItem::new("B", Msg::B).enabled(false));
1712        let ui = frame(&menu, &theme);
1713        assert_eq!(press(&ui, 110.0, row_middle(0.0)), Some(Msg::A));
1714        assert_eq!(
1715            press(&ui, 110.0, row_middle(1.0)),
1716            None,
1717            "the row that said it was unavailable answers nothing"
1718        );
1719        let greyed = texts(ui.scene()).iter().any(|(_, text)| text == "B");
1720        assert!(greyed, "and is still drawn rather than left out");
1721    }
1722
1723    /// **The keys are drawn on the right, muted, and read out after the label.** A
1724    /// shortcut is the only thing on a row that a reader who cannot see it has no other
1725    /// way to learn.
1726    #[test]
1727    fn the_keys_are_drawn_on_the_right_and_announced() {
1728        let theme = Theme::default();
1729        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1730            .entry(MenuItem::new("Paste", Msg::A).shortcut("Ctrl+V"));
1731        let ui = frame(&menu, &theme);
1732        let keys = text_at(&ui, "Ctrl+V");
1733        assert!(
1734            keys.x > text_at(&ui, "Paste").x,
1735            "the keys are past the label, not before it"
1736        );
1737
1738        let row = Item {
1739            label: Some("Paste".into()),
1740            children: Vec::new(),
1741            lead: None,
1742            lead_column: false,
1743            shortcut: Some("Ctrl+V".into()),
1744            submenu: false,
1745            expanded: false,
1746            keys: RowKeys::default(),
1747            enabled: true,
1748            text_style: None,
1749            background: None,
1750            padding: None,
1751            height: None,
1752            kind: PanelKind::Menu,
1753            message: Msg::A,
1754        };
1755        assert_eq!(
1756            Widget::<Msg>::semantics(&row)
1757                .expect("announced")
1758                .label
1759                .as_deref(),
1760            Some("Paste, Ctrl+V")
1761        );
1762    }
1763
1764    /// **A row that is on or off is announced as one**, with its state — there is no
1765    /// `menuitemcheckbox` in this framework's vocabulary and a checkbox is the role that
1766    /// carries the one thing such a row has to say.
1767    #[test]
1768    fn a_row_that_is_on_or_off_is_announced_as_one() {
1769        let row = |on: bool| Item {
1770            label: Some("Word wrap".into()),
1771            children: Vec::new(),
1772            lead: Some(Lead::Check(on)),
1773            lead_column: true,
1774            shortcut: None,
1775            submenu: false,
1776            expanded: false,
1777            keys: RowKeys::default(),
1778            enabled: true,
1779            text_style: None,
1780            background: None,
1781            padding: None,
1782            height: None,
1783            kind: PanelKind::Menu,
1784            message: Msg::A,
1785        };
1786        let on = Widget::<Msg>::semantics(&row(true)).expect("announced");
1787        assert_eq!(on.role, frus_core::Role::CheckBox);
1788        assert_eq!(on.toggled, frus_core::Toggled::True);
1789        assert!(on.clickable, "and it is still an action");
1790        assert_eq!(
1791            Widget::<Msg>::semantics(&row(false))
1792                .expect("announced")
1793                .toggled,
1794            frus_core::Toggled::False,
1795            "off is a state, not the absence of one"
1796        );
1797    }
1798
1799    /// **The menu grows to its widest row**, and does not shrink below the width it has
1800    /// always had.
1801    ///
1802    /// The width was a bare constant. That is fine for a list of one-word actions and
1803    /// wrong the moment a row carries a mark, a label and the keys that work it: what it
1804    /// does then is overlap, which no assertion about the tree catches. Two hundred and
1805    /// twenty is now a floor, which is why no picture in this repository moved.
1806    #[test]
1807    fn the_menu_grows_to_its_widest_row_and_never_shrinks() {
1808        let theme = Theme::default();
1809        let width = |menu: &PopupMenuButton<Msg>| {
1810            frame(menu, &theme)
1811                .scene()
1812                .primitives()
1813                .iter()
1814                .find_map(|p| find_panel(p, &theme))
1815                .expect("a panel")
1816                .width
1817        };
1818        assert_eq!(
1819            width(&open_menu()),
1820            WIDTH,
1821            "short labels keep the old width"
1822        );
1823        let long = PopupMenuButton::new(anchor(), true, Msg::Close)
1824            .item("Duplicate this record and everything under it", Msg::A);
1825        assert!(
1826            width(&long) > WIDTH,
1827            "a label that does not fit widens the menu instead of running off it"
1828        );
1829    }
1830
1831    /// **A press on the menu's own surface does not close it**, and outside it still
1832    /// does.
1833    ///
1834    /// The room above the first row, the gap a rule leaves, a row that said it was
1835    /// unavailable — none of those is a target, and each of them used to fall through to
1836    /// the window-wide region whose press dismisses the menu. The comment above
1837    /// `Panel::on_click` claimed otherwise for three hundred milestones: a widget with no
1838    /// message is not registered at all, so there was nothing there to stop anything.
1839    ///
1840    /// Verified twice over. Taking `Panel::opaque` back out fails this and the test for a
1841    /// row that is unavailable, and nothing else in 1 356; so does collapsing
1842    /// `WidgetId::barrier` back to the identity it used to borrow, since a target the
1843    /// round trip through `msg_for` cannot tell from the barrier is no target at all.
1844    #[test]
1845    fn a_press_on_the_menus_own_surface_does_not_close_it() {
1846        let theme = Theme::default();
1847        let ui = frame(&open_menu(), &theme);
1848        assert_eq!(
1849            press(&ui, 110.0, 30.0 + PAD_Y * 0.5),
1850            None,
1851            "the panel's own room above the first row swallows the press"
1852        );
1853        assert_eq!(
1854            press(&ui, 390.0, 290.0),
1855            Some(Msg::Close),
1856            "and the page beyond it still closes the menu"
1857        );
1858    }
1859
1860    /// **The shadow's colour is the caller's to say**, then the theme's, then the scheme's
1861    /// shadow — and a transparent one casts nothing (milestone 529). The panel drew its
1862    /// shadow from its height alone, in a colour nobody could change. The colour is the
1863    /// shadow's at full strength; the reference's three shadows take their share of it
1864    /// (milestone 606).
1865    #[test]
1866    fn the_shadow_is_cast_in_a_colour_that_can_be_told() {
1867        let theme = Theme::default();
1868        let cast = |menu: &PopupMenuButton<Msg>, theme: &Theme| blurred(frame(menu, theme).scene());
1869        let colour =
1870            |menu: &PopupMenuButton<Msg>, theme: &Theme| colour_of(frame(menu, theme).scene());
1871
1872        let shadows = cast(&open_menu(), &theme);
1873        assert_eq!(shadows.len(), 3, "{shadows:#?}");
1874        assert_eq!(
1875            height_of(frame(&open_menu(), &theme).scene()),
1876            3.0,
1877            "three high"
1878        );
1879        assert_eq!(colour(&open_menu(), &theme), Some(theme.scheme.shadow));
1880
1881        let shade = frus_core::Color::rgba(0.0, 0.2, 0.6, 0.5);
1882        assert_eq!(
1883            colour(&open_menu().shadow_color(shade), &theme),
1884            Some(shade)
1885        );
1886        let mut themed = Theme::default();
1887        themed.widgets.menu.shadow_color = Some(shade);
1888        assert_eq!(colour(&open_menu(), &themed), Some(shade), "the theme's");
1889        let own = frus_core::Color::rgba(0.6, 0.0, 0.0, 0.25);
1890        assert_eq!(
1891            colour(&open_menu().shadow_color(own), &themed),
1892            Some(own),
1893            "the caller's over the theme's"
1894        );
1895
1896        assert!(cast(
1897            &open_menu().shadow_color(frus_core::Color::TRANSPARENT),
1898            &theme
1899        )
1900        .is_empty());
1901        themed.widgets.menu.shadow_color = Some(frus_core::Color::TRANSPARENT);
1902        assert!(cast(&open_menu(), &themed).is_empty());
1903    }
1904
1905    #[test]
1906    fn closed_has_no_overlay() {
1907        let menu = PopupMenuButton::new(anchor(), false, Msg::Close).item("A", Msg::A);
1908        assert!(Widget::<Msg>::overlay(&menu).is_none());
1909        assert_eq!(Widget::<Msg>::children(&menu).len(), 1);
1910    }
1911
1912    #[test]
1913    fn open_floats_items_and_dismisses_outside() {
1914        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1915            .item("A", Msg::A)
1916            .item("B", Msg::B);
1917        assert!(Widget::<Msg>::overlay(&menu).is_some());
1918        assert_eq!(Widget::<Msg>::overlay_dismiss(&menu), Some(Msg::Close));
1919
1920        let ui = build_ui(
1921            &menu,
1922            Size::new(400.0, 300.0),
1923            &Runtime::default(),
1924            &Theme::default(),
1925        );
1926        // A click far from the anchor (the bottom-right corner) closes the menu.
1927        let corner = ui.hit(P::new(390.0, 290.0)).expect("hit de fermeture");
1928        assert_eq!(ui.msg_for(corner), Some(Msg::Close));
1929    }
1930}