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