Skip to main content

frust_material/menu/
panel.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/menus/components/` — `m3e_menu_content.dart` (the node walk
3// and section labels), `m3e_menu_item.dart` (a row's box, ink and slots),
4// `m3e_menu_divider.dart`, `m3e_menu_node_builders.dart` (the per-kind glyph
5// and activation rules) and `m3e_menu_popup.dart`'s `_buildSurfaces` (the
6// elevated-surface stack), retrieved 2026-08-19.
7// Porting decisions: the reference builds a Flutter widget subtree per row;
8// this port keeps rows as **data** and paints them, so one widget owns the
9// hover latch, the press machine and the submenu chain (the shape
10// `frust_shadcn`'s own menu list established, and what lets a row's ink be
11// per-state — no themed text role can express it). The reference's
12// `M3EMenuItemShape` (leading/middle/trailing corner shapes) is deliberately
13// **not** ported: all four of its variants return the same `itemBorderRadius`
14// (`m3e_menu_theme.dart:196-206`) and the popup passes `applyGroupShapes:
15// false` (`m3e_menu_popup.dart:388`), so it resolves to one radius here.
16// Placement and dismissal are the anchored host's, never this panel's.
17
18//! The menu panel: the reusable item-list surface every Material menu family
19//! paints — the anchored menu here, and the dropdown/button-group popups built
20//! on top of it.
21//!
22//! # A stack of surfaces, not one card
23//!
24//! [`items::partition_surfaces`](super::items::partition_surfaces) splits the
25//! node tree into elevated containers — one per
26//! [`MenuGroup`](super::MenuGroup), one for each run of consecutive non-group
27//! nodes — and the panel stacks them with
28//! [`MENU_SECTION_GAP`](super::MENU_SECTION_GAP) between. Each surface fills,
29//! rounds and lifts itself (`elevation` level 2, the M3 menu rung).
30//!
31//! # Rows are data
32//!
33//! A row is a [`Row`] record plus its shaped text runs and parsed icon paths,
34//! not a child widget: the ink is per-kind *and* per-state (disabled at 38%,
35//! destructive `error`, selected `onTertiaryContainer`, …), which no themed
36//! text role can express, so the panel shapes its own runs and re-brushes them
37//! at paint the way [`mod@crate::button`]'s label runs do. The **only** child
38//! pods are the nested submenu panels, which have to be pods so events can be
39//! routed into their own coordinate space.
40//!
41//! # Submenus
42//!
43//! The reference opens a submenu **on tap** and on nothing else —
44//! `M3EMenuNodeBuilders.submenu` wires `onOpenSubmenu` to `M3EMenuItem.onTap`
45//! (`m3e_menu_node_builders.dart:135`), with no hover trigger anywhere in the
46//! family. That rule is ported as-is; hover-open is layered *over* it as a
47//! desktop-pointer affordance (a touch stream carries no `Move`, so touch is
48//! tap-open and a mouse is hover-open, with tap working on both). Moving onto
49//! any other row closes whatever was open, and moving onto the open submenu
50//! itself keeps it — the pointer has to be able to travel there.
51//!
52//! Two behaviors this composition does not reproduce, the same pair the
53//! sibling shadcn port records:
54//!
55//! * **No collision handling for a submenu.** The anchored host places exactly
56//!   one rect — the top-level panel — so a nested panel has no area to flip or
57//!   clamp against and always opens to the trailing side of its row (the
58//!   reference's `preferRight` default, `m3e_menu_placer.dart:212`).
59//! * **No safe-triangle grace period.** The first move onto another row closes
60//!   the submenu; there is no diagonal-travel allowance.
61//!
62//! # Dismissal is the host's
63//!
64//! The panel never dismisses itself: light dismiss and Escape belong to
65//! [`crate::overlay::anchored`], which owns the enter/exit ramp too. What the
66//! panel does own is the **chain**: handing it `open == false` closes every
67//! open submenu on the spot, so a light dismiss takes the whole cascade with it
68//! (the reference's `_dismiss` → `_removeSubmenu`, `m3e_menu_popup.dart:198`).
69
70use frust::authoring::{
71    Action, Toggled,
72    text::{TextContext, TextLayout, TextOverflow, TextStyle},
73};
74use frust::authoring::{
75    Affine, AnyView, BezPath, BoxConstraints, Brush, BuildCtx, ChangeFlags, ChildPod, Color,
76    CursorIcon, ErasedArgCallback, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
77    PaintScene, Point, PointerEvent, PointerPhase, Rect, Role, ScrollDelta, SemanticsCtx, Size,
78    TypedArgCallback, View, Widget, any, build_child, erase_callback_arg, rebuild_children,
79    route_event_single, teardown_child, visit_children,
80};
81use frust::input::WHEEL_LINE_PX;
82use frust::{IconData, Theme};
83
84use super::items::{MenuAction, MenuNode, MenuSelection, partition_surfaces};
85use super::{
86    MENU_DIVIDER_THICKNESS, MENU_DIVIDER_V_PADDING, MENU_GROUP_LABEL_H_PADDING,
87    MENU_GROUP_LABEL_V_PADDING, MENU_ICON_GAP, MENU_ICON_SIZE, MENU_ITEM_GAP, MENU_MAX_HEIGHT,
88    MENU_MAX_WIDTH, MENU_MIN_WIDTH, MENU_ROW_H_PADDING, MENU_ROW_MIN_HEIGHT, MENU_SECTION_GAP,
89    MENU_SUBMENU_GAP, MENU_SUPPORTING_V_PADDING, MENU_SURFACE_H_PADDING, MENU_SURFACE_V_PADDING,
90    MenuColorStyle, MenuColors, SELECTED_CHECK_SCALE, container_radius, item_radius,
91};
92use crate::icons;
93use crate::interaction::InteractionState;
94use crate::overlay::{OverlayElevation, shadow};
95use crate::press::presses;
96
97/// The ink every run is *shaped* with; never painted — a run is re-brushed with
98/// its resolved per-kind/per-state color at paint time, and holding the shaping
99/// color constant keeps the shape cache from missing on a recolor (the contract
100/// [`mod@crate::button`]'s own runs document).
101const SHAPING_INK: Color = Color::BLACK;
102
103/// A shaped, cached text run, re-brushed at paint. Mirrors
104/// [`mod@crate::button`]'s `LabelRun` — the same lazily-shaped,
105/// measured-against-`(style, max_width)` cache, minus the overflow-observer
106/// seam a button needs and a menu row does not.
107struct Run {
108    content: String,
109    layout: Option<TextLayout>,
110    /// The `(style, max_width)` the cached `layout` was shaped for.
111    shaped_for: Option<(TextStyle, Option<f64>)>,
112    /// The `(style, width)` of the last natural (unconstrained) measurement.
113    natural: Option<(TextStyle, f64)>,
114}
115
116impl Run {
117    /// A run holding `content`, unshaped until the first measurement.
118    fn new(content: impl Into<String>) -> Self {
119        Run {
120            content: content.into(),
121            layout: None,
122            shaped_for: None,
123            natural: None,
124        }
125    }
126
127    /// The run's width with nothing constraining it.
128    fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
129        if let Some((cached, width)) = &self.natural
130            && cached == style
131        {
132            return *width;
133        }
134        let text_ctx = ctx.text_context::<TextContext>();
135        let width = text_ctx.layout(&self.content, style, None).size().width;
136        self.natural = Some((style.clone(), width));
137        width
138    }
139
140    /// Shape (or reuse) the run in `style`, fitted to `max_width`, and return
141    /// its measured size.
142    ///
143    /// A fitted run is a **single ellipsized line** — the reference's rows are
144    /// `maxLines: 1, overflow: ellipsis` throughout (`m3e_menu_item.dart:135`).
145    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: f64) -> Size {
146        let key = (style.clone(), Some(max_width));
147        if let Some(cached) = &self.layout
148            && self.shaped_for.as_ref() == Some(&key)
149        {
150            return cached.size();
151        }
152        let text_ctx = ctx.text_context::<TextContext>();
153        let laid = text_ctx.layout_bounded(
154            &self.content,
155            style,
156            Some(max_width.max(0.0) as f32),
157            Some(1),
158            TextOverflow::Ellipsis,
159        );
160        let size = laid.size();
161        self.layout = Some(laid);
162        self.shaped_for = Some(key);
163        size
164    }
165
166    /// The shaped size, or zero for a run no layout pass has reached yet.
167    fn size(&self) -> Size {
168        self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
169    }
170
171    /// Paint the run at `origin` in `color`, overriding the [`SHAPING_INK`] it
172    /// was shaped with. A never-shaped run paints nothing.
173    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
174        let Some(layout) = &self.layout else {
175            return;
176        };
177        for mut run in layout.to_scene_runs(origin) {
178            run.brush = Brush::Solid(color);
179            scene.draw_glyph_run(run);
180        }
181    }
182}
183
184/// A row's icon slot, parsed once at build into its design-box path.
185struct Glyph {
186    path: BezPath,
187    design: f64,
188    /// The side length to paint at — [`MENU_ICON_SIZE`], or the reference's
189    /// `iconSize * 0.9` for the implicit selected check.
190    extent: f64,
191}
192
193impl Glyph {
194    /// Parse `icon`'s path once, to be painted at `extent`.
195    fn new(icon: frust::IconSource, extent: f64) -> Self {
196        let (path, design) = IconData::from(icon).resolve();
197        Glyph {
198            path,
199            design,
200            extent,
201        }
202    }
203
204    /// Paint the glyph with its top-left at `origin`, in `color`.
205    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
206        let scale = if self.design > 0.0 {
207            self.extent / self.design
208        } else {
209            1.0
210        };
211        let scaled = Affine::scale(scale) * self.path.clone();
212        scene.fill_path(origin, &scaled, &Brush::Solid(color));
213    }
214}
215
216/// What a rendered row is, plus the payload its activation reports.
217enum RowKind {
218    /// A plain action row.
219    Entry,
220    /// A radio-style row carrying the value it would request.
221    Selectable(String),
222    /// A checkbox-style row carrying its *current* checked state.
223    Toggleable(bool),
224    /// A hairline rule.
225    Divider,
226    /// A row that opens a nested panel.
227    Submenu,
228}
229
230impl RowKind {
231    /// Whether this row is an *item* — everything but a divider. A disabled
232    /// item is still an item; it just refuses the press.
233    fn is_item(&self) -> bool {
234        !matches!(self, RowKind::Divider)
235    }
236}
237
238/// One rendered row: its kind, its resolved state, its shaped content, and the
239/// vertical band it occupies in the panel's own content space.
240struct Row {
241    kind: RowKind,
242    /// The depth-first index reported through `on_select`.
243    index: usize,
244    /// Which surface this row belongs to.
245    surface: usize,
246    enabled: bool,
247    destructive: bool,
248    /// Resolved selected state — a selectable's own flag OR-ed with a value
249    /// match, or a toggleable's `checked`.
250    selected: bool,
251    label: Run,
252    supporting: Option<Run>,
253    shortcut: Option<Run>,
254    leading: Option<Glyph>,
255    trailing: Option<Glyph>,
256    /// This row's submenu pod, when it has one.
257    sub: Option<usize>,
258    /// Top of the row's band, in content space.
259    top: f64,
260    /// The band's full height (the item box plus [`MENU_ITEM_GAP`]).
261    height: f64,
262}
263
264/// One elevated menu container: its optional section label and the span of
265/// [`Row`]s it holds.
266struct Surface {
267    label: Option<Run>,
268    /// Top of the surface, in content space.
269    top: f64,
270    height: f64,
271}
272
273/// The rows and submenu views a node tree renders as — computed identically by
274/// [`MenuPanelView::build`] and [`MenuPanelView::rebuild`], so the pod list and
275/// the row list can never drift.
276struct Plan {
277    surfaces: Vec<PlannedSurface>,
278    rows: Vec<PlannedRow>,
279}
280
281/// A planned surface: its section label and nothing else (geometry is layout's).
282struct PlannedSurface {
283    label: Option<String>,
284}
285
286/// A planned row: everything needed to build its [`Row`], plus the submenu
287/// children it opens.
288struct PlannedRow {
289    node: MenuNode,
290    index: usize,
291    surface: usize,
292    /// `(base index, children)` for a submenu row.
293    submenu: Option<(usize, Vec<MenuNode>)>,
294}
295
296/// Walk `nodes` into surfaces and rows, numbering from `base`.
297fn plan_tree(nodes: &[MenuNode], base: usize) -> Plan {
298    let mut surfaces = Vec::new();
299    let mut rows = Vec::new();
300    let mut index = base;
301    for group in partition_surfaces(nodes) {
302        let surface = surfaces.len();
303        surfaces.push(PlannedSurface {
304            label: group.section_label().map(str::to_string),
305        });
306        for node in group.children() {
307            let submenu = match node {
308                MenuNode::Submenu(item) => Some((index + 1, item.children().to_vec())),
309                _ => None,
310            };
311            index += node.span();
312            // A nested group inside a group is not a surface of its own — the
313            // reference partitions only the top level — so its children join
314            // this surface as plain rows.
315            match node {
316                MenuNode::Group(inner) => {
317                    let nested = plan_tree(inner.children(), index - node.span());
318                    for mut row in nested.rows {
319                        row.surface = surface;
320                        rows.push(row);
321                    }
322                }
323                _ => rows.push(PlannedRow {
324                    node: node.clone(),
325                    index: index - node.span(),
326                    surface,
327                    submenu,
328                }),
329            }
330        }
331    }
332    Plan { surfaces, rows }
333}
334
335/// A declarative menu panel. See [`menu_panel`].
336pub struct MenuPanelView<State: 'static> {
337    nodes: Vec<MenuNode>,
338    style: MenuColorStyle,
339    selected: Option<String>,
340    open: bool,
341    base: usize,
342    on_select: TypedArgCallback<State, MenuSelection>,
343}
344
345/// Build a menu panel over `nodes` — the item-list surface, without any
346/// anchoring or dismissal of its own.
347///
348/// This is what [`menu`](super::menu) mounts inside
349/// [`crate::overlay::anchored`], and what a dropdown or a button-group popup
350/// mounts inside its own host. An app building a plain anchored menu wants
351/// [`menu`](super::menu) instead.
352///
353/// `on_select(state, selection)` reports an activation; see [`MenuSelection`]
354/// for what a row requests and the controlled-component contract behind it.
355pub fn menu_panel<State: 'static, F: Fn(&mut State, MenuSelection) + 'static>(
356    nodes: Vec<MenuNode>,
357    on_select: F,
358) -> MenuPanelView<State> {
359    MenuPanelView {
360        nodes,
361        style: MenuColorStyle::default(),
362        selected: None,
363        open: true,
364        base: 0,
365        on_select: std::rc::Rc::new(on_select),
366    }
367}
368
369impl<State: 'static> MenuPanelView<State> {
370    /// Pick the standard or vibrant color resolution.
371    pub fn color_style(mut self, style: MenuColorStyle) -> Self {
372        self.style = style;
373        self
374    }
375
376    /// Hand the panel the app-confirmed selected value its
377    /// [`MenuSelectable`](super::MenuSelectable) rows check against.
378    pub fn selected(mut self, value: Option<String>) -> Self {
379        self.selected = value;
380        self
381    }
382
383    /// Tell the panel whether its host is open. `false` closes every open
384    /// submenu on the spot; the default is `true`.
385    pub fn open(mut self, open: bool) -> Self {
386        self.open = open;
387        self
388    }
389
390    /// Number this panel's rows from `base` rather than `0` — how a submenu
391    /// keeps the whole tree's depth-first numbering.
392    pub fn base(mut self, base: usize) -> Self {
393        self.base = base;
394        self
395    }
396
397    /// The submenu panel views this tree opens, in row order.
398    fn sub_views(&self, plan: &Plan) -> Vec<AnyView<State>> {
399        plan.rows
400            .iter()
401            .filter_map(|row| row.submenu.as_ref())
402            .map(|(base, children)| {
403                any(MenuPanelView {
404                    nodes: children.clone(),
405                    style: self.style,
406                    selected: self.selected.clone(),
407                    open: self.open,
408                    base: *base,
409                    on_select: self.on_select.clone(),
410                })
411            })
412            .collect()
413    }
414
415    /// Build the retained rows for `plan`, resolving each kind's implicit glyph
416    /// and selected state (`m3e_menu_node_builders.dart:46-147`).
417    fn build_rows(&self, plan: &Plan) -> Vec<Row> {
418        let mut pod = 0usize;
419        let mut rows = Vec::with_capacity(plan.rows.len());
420        for planned in &plan.rows {
421            let sub = planned.submenu.as_ref().map(|_| {
422                let index = pod;
423                pod += 1;
424                index
425            });
426            rows.push(self.build_row(planned, sub));
427        }
428        rows
429    }
430
431    /// One row's retained record.
432    fn build_row(&self, planned: &PlannedRow, sub: Option<usize>) -> Row {
433        let icon = |slot: &Option<super::MenuIcon>| slot.map(|i| Glyph::new(i.0, MENU_ICON_SIZE));
434        let (kind, label, supporting, shortcut, leading, trailing, enabled, destructive, selected) =
435            match &planned.node {
436                MenuNode::Entry(e) => (
437                    RowKind::Entry,
438                    Run::new(e.label.clone()),
439                    e.supporting.clone().map(Run::new),
440                    e.shortcut.clone().map(Run::new),
441                    icon(&e.leading),
442                    icon(&e.trailing),
443                    e.enabled,
444                    e.destructive,
445                    false,
446                ),
447                MenuNode::Selectable(e) => {
448                    let selected =
449                        e.selected || self.selected.as_deref().is_some_and(|v| v == e.value);
450                    // The reference falls back to a check glyph at 90% icon size
451                    // only while selected (`m3e_menu_node_builders.dart:55`).
452                    let leading = icon(&e.leading).or_else(|| {
453                        selected.then(|| {
454                            Glyph::new(icons::CHECK_ROUNDED, MENU_ICON_SIZE * SELECTED_CHECK_SCALE)
455                        })
456                    });
457                    (
458                        RowKind::Selectable(e.value.clone()),
459                        Run::new(e.label.clone()),
460                        e.supporting.clone().map(Run::new),
461                        e.shortcut.clone().map(Run::new),
462                        leading,
463                        icon(&e.trailing),
464                        e.enabled,
465                        false,
466                        selected,
467                    )
468                }
469                MenuNode::Toggleable(e) => {
470                    // The reference always draws a box, checked or blank
471                    // (`m3e_menu_node_builders.dart:88`).
472                    let leading = icon(&e.leading).or_else(|| {
473                        let source = if e.checked {
474                            icons::CHECK_BOX_ROUNDED
475                        } else {
476                            icons::CHECK_BOX_OUTLINE_BLANK_ROUNDED
477                        };
478                        Some(Glyph::new(source, MENU_ICON_SIZE))
479                    });
480                    (
481                        RowKind::Toggleable(e.checked),
482                        Run::new(e.label.clone()),
483                        e.supporting.clone().map(Run::new),
484                        e.shortcut.clone().map(Run::new),
485                        leading,
486                        icon(&e.trailing),
487                        e.enabled,
488                        false,
489                        e.checked,
490                    )
491                }
492                MenuNode::Submenu(e) => (
493                    RowKind::Submenu,
494                    Run::new(e.label.clone()),
495                    None,
496                    None,
497                    icon(&e.leading),
498                    // The chevron is fixed, never a caller's slot
499                    // (`m3e_menu_node_builders.dart:128`).
500                    Some(Glyph::new(icons::ARROW_RIGHT_ROUNDED, MENU_ICON_SIZE)),
501                    e.enabled,
502                    false,
503                    false,
504                ),
505                // A group is never planned as a row of its own — `plan_tree`
506                // turns it into a surface and flattens its children — so it
507                // shares the divider's empty record rather than needing an
508                // unreachable arm.
509                MenuNode::Divider | MenuNode::Group(_) => (
510                    RowKind::Divider,
511                    Run::new(String::new()),
512                    None,
513                    None,
514                    None,
515                    None,
516                    false,
517                    false,
518                    false,
519                ),
520            };
521        Row {
522            kind,
523            index: planned.index,
524            surface: planned.surface,
525            enabled,
526            destructive,
527            selected,
528            label,
529            supporting,
530            shortcut,
531            leading,
532            trailing,
533            sub,
534            top: 0.0,
535            height: 0.0,
536        }
537    }
538
539    /// The retained surfaces for `plan`.
540    fn build_surfaces(&self, plan: &Plan) -> Vec<Surface> {
541        plan.surfaces
542            .iter()
543            .map(|planned| Surface {
544                label: planned.label.clone().map(Run::new),
545                top: 0.0,
546                height: 0.0,
547            })
548            .collect()
549    }
550}
551
552/// The retained widget for a [`MenuPanelView`].
553pub struct MenuPanelWidget {
554    surfaces: Vec<Surface>,
555    rows: Vec<Row>,
556    /// The submenu panels, in row order — the only child pods this widget has.
557    pods: Vec<ChildPod>,
558    style: MenuColorStyle,
559    open: bool,
560    on_select: ErasedArgCallback<MenuSelection>,
561    /// The latched hovered row (the hover-flag half of the catalog's three-part
562    /// hover seam; `paint` self-corrects it from `PaintCtx::is_hovered`).
563    hovered: Option<usize>,
564    /// The row a live press is armed on.
565    pressed: Option<usize>,
566    /// Whether that press is currently *inside* its own row — a drag off the
567    /// row drops the pressed wash without ending the gesture, so a drag back
568    /// re-arms it (the `list_item` press machine's contract).
569    press_inside: bool,
570    captured: bool,
571    /// The row whose submenu is open.
572    open_sub: Option<usize>,
573    scroll: f64,
574    viewport: f64,
575    content_height: f64,
576    width: f64,
577}
578
579impl MenuPanelWidget {
580    /// The panel's resolved width, in logical px.
581    pub fn width(&self) -> f64 {
582        self.width
583    }
584
585    /// The row whose submenu is open, if any.
586    pub fn open_submenu(&self) -> Option<usize> {
587        self.open_sub
588    }
589
590    /// The panel's scroll offset, in logical px.
591    pub fn scroll(&self) -> f64 {
592        self.scroll
593    }
594
595    /// The `(top, height)` band row `row` occupies in content space.
596    #[cfg(test)]
597    fn band(&self, row: usize) -> (f64, f64) {
598        let row = &self.rows[row];
599        (row.top, row.height)
600    }
601
602    /// Close this panel's own open submenu.
603    ///
604    /// The *cascade* closes with it without any recursion here: a nested panel
605    /// is built from this one's `open` flag, so the same rebuild that clears
606    /// this hands every descendant `open == false` and each clears its own (the
607    /// reference's `_dismiss` → `_removeSubmenu`, one level per panel).
608    fn close_chain(&mut self) {
609        self.open_sub = None;
610    }
611
612    /// Open `row`'s submenu, if it has one and is enabled.
613    fn open_submenu_at(&mut self, row: usize) -> bool {
614        let opens = self
615            .rows
616            .get(row)
617            .is_some_and(|r| r.enabled && r.sub.is_some());
618        if opens {
619            self.open_sub = Some(row);
620        }
621        opens
622    }
623
624    /// The pod holding the open submenu's panel, if one is open.
625    fn open_sub_pod(&self) -> Option<usize> {
626        let row = self.open_sub?;
627        self.rows.get(row)?.sub
628    }
629
630    /// The open submenu's placed rect, in this panel's own space.
631    fn open_sub_rect(&self) -> Option<Rect> {
632        let pod = self.open_sub_pod()?;
633        let pod = self.pods.get(pod)?;
634        Some(Rect::from_origin_size(pod.origin(), pod.size()))
635    }
636
637    /// Keep the scroll offset inside the scrollable range.
638    fn clamp_scroll(&mut self) {
639        let max = (self.content_height - self.viewport).max(0.0);
640        self.scroll = self.scroll.clamp(0.0, max);
641    }
642
643    /// Place the submenu pods for the current scroll offset.
644    ///
645    /// Called from `layout` and again from the scroll arm: an `EventCtx` can
646    /// request a redraw but not a relayout, so the pods are repositioned in
647    /// place rather than waiting for a pass a mere scroll never triggers.
648    fn apply_scroll(&mut self) {
649        for row in self.rows.iter() {
650            let Some(pod) = row.sub.and_then(|i| self.pods.get_mut(i)) else {
651                continue;
652            };
653            // Trailing side of the panel, the nested surface's *first row*
654            // lined up with the triggering row (its own vertical padding is
655            // what the offset cancels) — the reference's side placement,
656            // top-aligned with the anchor row (`m3e_menu_placer.dart:249`).
657            pod.set_origin(Point::new(
658                self.width + MENU_SUBMENU_GAP,
659                row.top - self.scroll - MENU_SURFACE_V_PADDING,
660            ));
661        }
662    }
663
664    /// The row under panel-local `pos`, if any.
665    fn row_at(&self, pos: Point) -> Option<usize> {
666        if pos.x < 0.0 || pos.x >= self.width || pos.y < 0.0 || pos.y >= self.viewport {
667            return None;
668        }
669        let y = pos.y + self.scroll;
670        self.rows
671            .iter()
672            .position(|row| y >= row.top && y < row.top + row.height)
673    }
674
675    /// Whether row `i` can be pressed at all.
676    fn activatable(&self, i: usize) -> bool {
677        self.rows
678            .get(i)
679            .is_some_and(|row| row.enabled && row.kind.is_item())
680    }
681
682    /// Report row `i`'s activation, or open its submenu.
683    fn activate(&mut self, ctx: &mut EventCtx, i: usize) {
684        let Some(row) = self.rows.get(i) else {
685            return;
686        };
687        let action = match &row.kind {
688            RowKind::Entry => MenuAction::Press,
689            RowKind::Selectable(value) => MenuAction::Select(value.clone()),
690            // Controlled: the row reports the state it *asks* for and leaves
691            // its own `checked` alone until the caller feeds one back down.
692            RowKind::Toggleable(checked) => MenuAction::Toggle(!checked),
693            RowKind::Submenu => {
694                self.open_submenu_at(i);
695                ctx.request_redraw();
696                return;
697            }
698            RowKind::Divider => return,
699        };
700        let selection = MenuSelection {
701            index: row.index,
702            label: row.label.content.clone(),
703            action,
704        };
705        (self.on_select)(ctx, selection);
706    }
707
708    /// The `Widget::event` pointer arm: hover latching (with submenu
709    /// open/close), press arming, and release-to-activate.
710    fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
711        match p.phase {
712            PointerPhase::Move if !self.captured => {
713                // A pointer travelling over the open submenu must not close it.
714                if self.open_sub_rect().is_some_and(|r| r.contains(p.position)) {
715                    return EventResult::Ignored;
716                }
717                let row = self.row_at(p.position);
718                // Claimed from the uncaptured move arm on every qualifying
719                // pass, per the catalog's hover seam.
720                if row.is_some() {
721                    ctx.claim_hover();
722                }
723                let hovered = row.filter(|i| self.activatable(*i));
724                if hovered.is_some() {
725                    ctx.set_cursor(CursorIcon::Pointer);
726                }
727                if self.hovered != hovered {
728                    self.hovered = hovered;
729                    match hovered {
730                        // Hover-open, layered over the reference's tap-open
731                        // rule (see the module docs).
732                        Some(i) => {
733                            if !self.open_submenu_at(i) {
734                                self.open_sub = None;
735                            }
736                        }
737                        // A divider, a disabled row or the panel's own padding
738                        // is "anything else": it closes whatever was open
739                        // rather than leaving a stale panel hanging.
740                        None => self.open_sub = None,
741                    }
742                    ctx.request_redraw();
743                }
744                EventResult::Ignored
745            }
746            PointerPhase::Move => {
747                // Captured: the pressed wash follows the pointer in and out of
748                // the armed row without ending the gesture.
749                let inside = self.pressed.is_some() && self.row_at(p.position) == self.pressed;
750                if self.press_inside != inside {
751                    self.press_inside = inside;
752                    ctx.request_redraw();
753                }
754                EventResult::Handled
755            }
756            PointerPhase::Down => {
757                if !presses(p) {
758                    return EventResult::Ignored;
759                }
760                let Some(row) = self.row_at(p.position).filter(|i| self.activatable(*i)) else {
761                    // The panel's own background: left for the host, which
762                    // swallows a press inside its content without dismissing.
763                    return EventResult::Ignored;
764                };
765                self.pressed = Some(row);
766                self.press_inside = true;
767                self.captured = true;
768                ctx.capture_pointer();
769                ctx.request_redraw();
770                EventResult::Handled
771            }
772            PointerPhase::Up => {
773                if !self.captured {
774                    return EventResult::Ignored;
775                }
776                let released = self.row_at(p.position);
777                let armed = self.pressed;
778                self.pressed = None;
779                self.press_inside = false;
780                self.captured = false;
781                // Fire on up-inside only, the catalog's press contract.
782                if let Some(row) = armed.filter(|row| released == Some(*row)) {
783                    self.activate(ctx, row);
784                }
785                ctx.request_redraw();
786                EventResult::Handled
787            }
788            // A `Cancel` arm touches no app state — only the flags the next
789            // move re-establishes.
790            PointerPhase::Cancel => {
791                if !self.captured {
792                    return EventResult::Ignored;
793                }
794                self.pressed = None;
795                self.press_inside = false;
796                self.captured = false;
797                ctx.request_redraw();
798                EventResult::Handled
799            }
800        }
801    }
802
803    /// The `Widget::event` scroll arm: the same `offset + dy` wheel convention
804    /// every scrollable in this catalog shares, clamped to the content range.
805    fn handle_scroll(
806        &mut self,
807        ctx: &mut EventCtx,
808        position: Point,
809        delta: &ScrollDelta,
810    ) -> EventResult {
811        if position.y < 0.0 || position.y >= self.viewport {
812            return EventResult::Ignored;
813        }
814        let dy = match delta {
815            ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
816            ScrollDelta::Pixels(_, y) => *y,
817        };
818        let before = self.scroll;
819        self.scroll += dy;
820        self.clamp_scroll();
821        if (self.scroll - before).abs() <= f64::EPSILON {
822            return EventResult::Ignored;
823        }
824        self.apply_scroll();
825        ctx.request_redraw();
826        EventResult::Handled
827    }
828}
829
830impl<State: 'static> View<State> for MenuPanelView<State> {
831    type Element = MenuPanelWidget;
832
833    fn build(&self, ctx: &mut BuildCtx<'_>) -> MenuPanelWidget {
834        let plan = plan_tree(&self.nodes, self.base);
835        let pods = self
836            .sub_views(&plan)
837            .iter()
838            .map(|view| build_child(view, ctx))
839            .collect();
840        MenuPanelWidget {
841            surfaces: self.build_surfaces(&plan),
842            rows: self.build_rows(&plan),
843            pods,
844            style: self.style,
845            open: self.open,
846            on_select: erase_callback_arg(&self.on_select),
847            hovered: None,
848            pressed: None,
849            press_inside: false,
850            captured: false,
851            open_sub: None,
852            scroll: 0.0,
853            viewport: 0.0,
854            content_height: 0.0,
855            width: 0.0,
856        }
857    }
858
859    fn rebuild(
860        &self,
861        prev: &Self,
862        element: &mut MenuPanelWidget,
863        ctx: &mut BuildCtx<'_>,
864    ) -> ChangeFlags {
865        let plan = plan_tree(&self.nodes, self.base);
866        let mut flags = rebuild_children(
867            &prev.sub_views(&plan_tree(&prev.nodes, prev.base)),
868            &self.sub_views(&plan),
869            &mut element.pods,
870            ctx,
871            |view: &AnyView<State>| view,
872            |_| None,
873        );
874        // Rows carry shaped runs and parsed icon paths, so they are rebuilt
875        // only on a real content change — a rebuild that re-supplies the same
876        // tree keeps every cached layout.
877        if prev.nodes != self.nodes
878            || prev.selected != self.selected
879            || prev.style != self.style
880            || prev.base != self.base
881        {
882            element.surfaces = self.build_surfaces(&plan);
883            element.rows = self.build_rows(&plan);
884            element.style = self.style;
885            element.hovered = None;
886            element.pressed = None;
887            element.press_inside = false;
888            element.captured = false;
889            element.close_chain();
890            element.scroll = 0.0;
891            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
892        }
893        if element.open != self.open {
894            element.open = self.open;
895            if !self.open {
896                // A dismissal takes the whole cascade with it.
897                element.close_chain();
898                flags |= ChangeFlags::PAINT;
899            }
900        }
901        element.on_select = erase_callback_arg(&self.on_select);
902        flags
903    }
904
905    fn teardown(&self, element: &mut MenuPanelWidget, ctx: &mut BuildCtx<'_>) {
906        let plan = plan_tree(&self.nodes, self.base);
907        for (view, pod) in self.sub_views(&plan).iter().zip(element.pods.iter_mut()) {
908            teardown_child(view, pod, ctx);
909        }
910    }
911}
912
913/// Everything a paint pass resolves from the theme once, then hands down to
914/// each surface and row — so a single pass reads the theme exactly once and no
915/// two rows can disagree about a role or a radius.
916struct Chrome {
917    colors: MenuColors,
918    /// The level-2 elevation rung's `(blur, y_offset, color)`, if any.
919    shadow: Option<(f64, f64, Color)>,
920    container_radius: f64,
921    item_radius: f64,
922}
923
924/// The type styles a panel's four text roles resolve to
925/// (`m3e_menu_theme.dart:329-389`): `labelLarge` for a row label and a section
926/// label, `labelMedium` for supporting and shortcut text.
927struct Styles {
928    label: TextStyle,
929    supporting: TextStyle,
930}
931
932impl Styles {
933    /// Resolve both roles from `theme`, or from the M3 token literals when no
934    /// theme is threaded into the pass.
935    fn resolve(theme: Option<&Theme>) -> Self {
936        let (mut label, mut supporting) = match theme {
937            Some(theme) => (
938                theme.type_scale.label_large.clone(),
939                theme.type_scale.label_medium.clone(),
940            ),
941            None => {
942                let mut large = TextStyle::new(14.0, SHAPING_INK);
943                large.letter_spacing = 0.1;
944                let mut medium = TextStyle::new(12.0, SHAPING_INK);
945                medium.letter_spacing = 0.5;
946                (large, medium)
947            }
948        };
949        label.color = SHAPING_INK;
950        supporting.color = SHAPING_INK;
951        Styles { label, supporting }
952    }
953}
954
955impl Widget for MenuPanelWidget {
956    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
957        let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
958
959        // Pass 1 — natural widths, so the panel can size itself the way the
960        // reference's placer does (min/max clamped, never anchor-driven here:
961        // the host places a panel by the size this returns).
962        let mut natural: f64 = 0.0;
963        for i in 0..self.rows.len() {
964            natural = natural.max(self.row_natural_width(ctx, i, &styles));
965        }
966        for surface in self.surfaces.iter_mut() {
967            if let Some(label) = surface.label.as_mut() {
968                let width = label.natural_width(ctx, &styles.label);
969                natural = natural.max(width + 2.0 * MENU_GROUP_LABEL_H_PADDING);
970            }
971        }
972        let cap = if bc.max().width.is_finite() {
973            bc.max().width.min(MENU_MAX_WIDTH)
974        } else {
975            MENU_MAX_WIDTH
976        };
977        self.width = (natural + 2.0 * MENU_SURFACE_H_PADDING)
978            .max(MENU_MIN_WIDTH.min(cap))
979            .min(cap);
980
981        // Pass 2 — shape every run against the resolved width and stack the
982        // surfaces.
983        let mut y = 0.0;
984        for surface in 0..self.surfaces.len() {
985            if surface > 0 {
986                y += MENU_SECTION_GAP;
987            }
988            let top = y;
989            y += MENU_SURFACE_V_PADDING;
990            if let Some(label) = self.surfaces[surface].label.as_mut() {
991                let width = (self.width - 2.0 * MENU_GROUP_LABEL_H_PADDING).max(0.0);
992                let height = label.shape(ctx, &styles.label, width).height;
993                y += height + 2.0 * MENU_GROUP_LABEL_V_PADDING;
994            }
995            for i in 0..self.rows.len() {
996                if self.rows[i].surface != surface {
997                    continue;
998                }
999                let height = self.row_layout(ctx, i, &styles);
1000                self.rows[i].top = y;
1001                self.rows[i].height = height;
1002                y += height;
1003            }
1004            y += MENU_SURFACE_V_PADDING;
1005            self.surfaces[surface].top = top;
1006            self.surfaces[surface].height = y - top;
1007        }
1008        self.content_height = y;
1009
1010        let height_cap = if bc.max().height.is_finite() {
1011            bc.max().height.min(MENU_MAX_HEIGHT)
1012        } else {
1013            MENU_MAX_HEIGHT
1014        };
1015        self.viewport = self.content_height.min(height_cap);
1016        self.clamp_scroll();
1017
1018        // The submenu panels size themselves, outside this panel's own box.
1019        let loose = BoxConstraints::loose(Size::new(MENU_MAX_WIDTH, MENU_MAX_HEIGHT));
1020        for pod in self.pods.iter_mut() {
1021            pod.layout_child(ctx, &loose);
1022        }
1023        self.apply_scroll();
1024        bc.constrain(Size::new(self.width, self.viewport))
1025    }
1026
1027    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1028        // Authoritative hover read: a pointer that left this panel sends it
1029        // nothing, so the latched row is cleared from path membership.
1030        if !ctx.is_hovered() {
1031            self.hovered = None;
1032        }
1033        let theme = Theme::from_paint_ctx(ctx);
1034        let chrome = Chrome {
1035            colors: MenuColors::resolve(theme, self.style),
1036            // The M3 menu rung, which the overlay seam already names.
1037            shadow: shadow(theme, OverlayElevation::Level2),
1038            container_radius: container_radius(theme),
1039            item_radius: item_radius(theme),
1040        };
1041        let origin = ctx.origin();
1042
1043        // Only a panel that actually scrolls clips: a clip tight to the box
1044        // would cut the surfaces' own elevation shadows out of it, which is the
1045        // trap the reference's shadow padding avoids
1046        // (`m3e_menu_popup.dart:352`).
1047        let clipped = self.content_height > self.viewport + f64::EPSILON;
1048        if clipped {
1049            scene.push_clip(origin, Size::new(self.width, self.viewport));
1050        }
1051
1052        for surface in 0..self.surfaces.len() {
1053            self.paint_surface(surface, origin, &chrome, scene);
1054        }
1055
1056        if clipped {
1057            scene.pop_clip();
1058        }
1059
1060        // The open submenu paints last and outside the clip, so it sits over
1061        // whatever is beside it.
1062        if let Some(pod) = self.open_sub_pod()
1063            && let Some(pod) = self.pods.get_mut(pod)
1064        {
1065            pod.paint_child(ctx, scene);
1066        }
1067    }
1068
1069    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1070        if event.is_broadcast() {
1071            for pod in self.pods.iter_mut() {
1072                pod.event_child(ctx, event);
1073            }
1074            return EventResult::Ignored;
1075        }
1076        // The open submenu owns the pass first: it is the innermost panel, and
1077        // anything it does not take falls back to this one.
1078        if let Some(pod) = self.open_sub_pod()
1079            && route_event_single(&mut self.pods[pod], ctx, event) == EventResult::Handled
1080        {
1081            return EventResult::Handled;
1082        }
1083        match event {
1084            InputEvent::Scroll { position, delta } => self.handle_scroll(ctx, *position, delta),
1085            InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
1086            // Keys are the host's: Escape dismisses the whole chain there,
1087            // rather than this panel unwinding it one level at a time.
1088            _ => EventResult::Ignored,
1089        }
1090    }
1091
1092    fn semantics(&self, ctx: &mut SemanticsCtx) {
1093        let rows = &self.rows;
1094        let pods = &self.pods;
1095        let open_sub = self.open_sub_pod();
1096        ctx.push_container(
1097            Role::Menu,
1098            |_node| {},
1099            |ctx| {
1100                for row in rows.iter() {
1101                    let role = match row.kind {
1102                        RowKind::Divider => continue,
1103                        RowKind::Selectable(_) => Role::MenuItemRadio,
1104                        RowKind::Toggleable(_) => Role::MenuItemCheckBox,
1105                        RowKind::Entry | RowKind::Submenu => Role::MenuItem,
1106                    };
1107                    // Per-row bounds would need a `ChildPod` per row to descend
1108                    // through; rows are painted internally, so every node below
1109                    // shares the whole panel's bounds — the same v1 limitation
1110                    // `frust_glyph`'s own menu accepts.
1111                    ctx.push_node(role, |node| {
1112                        node.set_label(row.label.content.as_str());
1113                        if row.enabled {
1114                            node.add_action(Action::Click);
1115                        }
1116                        if matches!(row.kind, RowKind::Selectable(_) | RowKind::Toggleable(_)) {
1117                            node.set_toggled(if row.selected {
1118                                Toggled::True
1119                            } else {
1120                                Toggled::False
1121                            });
1122                        }
1123                    });
1124                }
1125                // Only the open submenu is reachable by input, so only it is
1126                // published — the input-parity carve-out for a container that
1127                // gates its children (`docs/CODE_STANDARDS.md`).
1128                if let Some(pod) = open_sub.and_then(|i| pods.get(i)) {
1129                    pod.semantics_child(ctx);
1130                }
1131            },
1132        );
1133    }
1134
1135    visit_children!(pods);
1136}
1137
1138impl MenuPanelWidget {
1139    /// The natural (unconstrained) width row `i` wants, including its own
1140    /// horizontal padding and every slot's advance.
1141    fn row_natural_width(&mut self, ctx: &mut LayoutCtx, i: usize, styles: &Styles) -> f64 {
1142        if !self.rows[i].kind.is_item() {
1143            return 0.0;
1144        }
1145        let mut text = {
1146            let row = &mut self.rows[i];
1147            row.label.natural_width(ctx, &styles.label)
1148        };
1149        if let Some(supporting) = self.rows[i].supporting.as_mut() {
1150            text = text.max(supporting.natural_width(ctx, &styles.supporting));
1151        }
1152        let mut width = 2.0 * MENU_ROW_H_PADDING + text;
1153        if self.rows[i].leading.is_some() {
1154            width += MENU_ICON_SIZE + MENU_ICON_GAP;
1155        }
1156        if let Some(shortcut) = self.rows[i].shortcut.as_mut() {
1157            width += MENU_ICON_GAP + shortcut.natural_width(ctx, &styles.supporting);
1158        }
1159        if self.rows[i].trailing.is_some() {
1160            width += MENU_ICON_GAP + MENU_ICON_SIZE;
1161        }
1162        width
1163    }
1164
1165    /// The width available to row `i`'s label column, once its slots are paid
1166    /// for.
1167    fn row_text_width(&self, i: usize) -> f64 {
1168        let row = &self.rows[i];
1169        let mut width = self.width - 2.0 * MENU_SURFACE_H_PADDING - 2.0 * MENU_ROW_H_PADDING;
1170        if row.leading.is_some() {
1171            width -= MENU_ICON_SIZE + MENU_ICON_GAP;
1172        }
1173        if row.trailing.is_some() {
1174            width -= MENU_ICON_GAP + MENU_ICON_SIZE;
1175        }
1176        if let Some(shortcut) = &row.shortcut {
1177            width -= MENU_ICON_GAP + shortcut.size().width;
1178        }
1179        width.max(0.0)
1180    }
1181
1182    /// Shape row `i` and return the full height of its band.
1183    fn row_layout(&mut self, ctx: &mut LayoutCtx, i: usize, styles: &Styles) -> f64 {
1184        if !self.rows[i].kind.is_item() {
1185            return 2.0 * MENU_DIVIDER_V_PADDING + MENU_DIVIDER_THICKNESS;
1186        }
1187        // The shortcut is shaped first: it is what the label column's own width
1188        // is measured against.
1189        if let Some(shortcut) = self.rows[i].shortcut.as_mut() {
1190            let natural = shortcut.natural_width(ctx, &styles.supporting);
1191            shortcut.shape(ctx, &styles.supporting, natural);
1192        }
1193        let text_width = self.row_text_width(i);
1194        let mut content = {
1195            let label = &mut self.rows[i].label;
1196            label.shape(ctx, &styles.label, text_width).height
1197        };
1198        let has_supporting = self.rows[i].supporting.is_some();
1199        if let Some(supporting) = self.rows[i].supporting.as_mut() {
1200            content += supporting.shape(ctx, &styles.supporting, text_width).height;
1201        }
1202        let padding = if has_supporting {
1203            2.0 * MENU_SUPPORTING_V_PADDING
1204        } else {
1205            0.0
1206        };
1207        MENU_ITEM_GAP + (content + padding).max(MENU_ROW_MIN_HEIGHT)
1208    }
1209
1210    /// Paint surface `s`: its container, its section label, and its rows.
1211    fn paint_surface(&self, s: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1212        let top = origin.y + self.surfaces[s].top - self.scroll;
1213        let box_origin = Point::new(origin.x, top);
1214        let box_size = Size::new(self.width, self.surfaces[s].height);
1215        if let Some((blur, y_offset, color)) = chrome.shadow {
1216            scene.draw_shadow(
1217                Point::new(box_origin.x, box_origin.y + y_offset),
1218                box_size,
1219                chrome.container_radius,
1220                blur,
1221                color,
1222            );
1223        }
1224        scene.fill_rounded_rect(
1225            box_origin,
1226            box_size,
1227            chrome.container_radius,
1228            chrome.colors.container,
1229        );
1230        if let Some(label) = &self.surfaces[s].label {
1231            label.paint(
1232                Point::new(
1233                    box_origin.x + MENU_GROUP_LABEL_H_PADDING,
1234                    box_origin.y + MENU_SURFACE_V_PADDING + MENU_GROUP_LABEL_V_PADDING,
1235                ),
1236                chrome.colors.supporting_content,
1237                scene,
1238            );
1239        }
1240        for i in 0..self.rows.len() {
1241            if self.rows[i].surface == s {
1242                self.paint_row(i, origin, chrome, scene);
1243            }
1244        }
1245    }
1246
1247    /// Paint row `i`: its ink, its state layer, and every filled slot.
1248    fn paint_row(&self, i: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1249        let colors = &chrome.colors;
1250        let row = &self.rows[i];
1251        let top = origin.y + row.top - self.scroll;
1252        if !row.kind.is_item() {
1253            // `m3e_menu_divider.dart`: a 1px rule inset by the row padding.
1254            scene.fill_rect(
1255                Point::new(
1256                    origin.x + MENU_SURFACE_H_PADDING + MENU_ROW_H_PADDING,
1257                    top + MENU_DIVIDER_V_PADDING,
1258                ),
1259                Size::new(
1260                    (self.width - 2.0 * MENU_SURFACE_H_PADDING - 2.0 * MENU_ROW_H_PADDING).max(0.0),
1261                    MENU_DIVIDER_THICKNESS,
1262                ),
1263                colors.divider,
1264            );
1265            return;
1266        }
1267        let item_origin = Point::new(origin.x + MENU_SURFACE_H_PADDING, top + MENU_ITEM_GAP / 2.0);
1268        let item_size = Size::new(
1269            (self.width - 2.0 * MENU_SURFACE_H_PADDING).max(0.0),
1270            row.height - MENU_ITEM_GAP,
1271        );
1272        if row.selected {
1273            scene.fill_rounded_rect(
1274                item_origin,
1275                item_size,
1276                chrome.item_radius,
1277                colors.selected_container,
1278            );
1279        }
1280        let mut state = InteractionState::new();
1281        // An open sub-trigger stays washed while its panel is up, so the
1282        // cascade reads as one connected surface.
1283        state.hovered = self.hovered == Some(i) || self.open_sub == Some(i);
1284        state.pressed = self.pressed == Some(i) && self.press_inside;
1285        let opacity = if row.enabled {
1286            state.resolve_opacity()
1287        } else {
1288            0.0
1289        };
1290        if opacity > 0.0 {
1291            scene.fill_rounded_rect(
1292                item_origin,
1293                item_size,
1294                chrome.item_radius,
1295                crate::overlay::with_alpha(colors.state_layer, opacity),
1296            );
1297        }
1298        let ink = colors.entry_foreground(row.enabled, row.destructive, row.selected);
1299        let icon_ink = colors.icon_foreground(row.enabled, row.destructive, row.selected);
1300
1301        let mut x = item_origin.x + MENU_ROW_H_PADDING;
1302        let middle = item_origin.y + item_size.height / 2.0;
1303        if let Some(leading) = &row.leading {
1304            leading.paint(
1305                Point::new(x, middle - leading.extent / 2.0),
1306                icon_ink,
1307                scene,
1308            );
1309            x += MENU_ICON_SIZE + MENU_ICON_GAP;
1310        }
1311        let label_size = row.label.size();
1312        let supporting_size = row.supporting.as_ref().map_or(Size::ZERO, Run::size);
1313        let text_top = middle - (label_size.height + supporting_size.height) / 2.0;
1314        row.label.paint(Point::new(x, text_top), ink, scene);
1315        if let Some(supporting) = &row.supporting {
1316            supporting.paint(
1317                Point::new(x, text_top + label_size.height),
1318                colors.supporting_foreground(row.enabled, row.selected),
1319                scene,
1320            );
1321        }
1322        let mut right = item_origin.x + item_size.width - MENU_ROW_H_PADDING;
1323        if let Some(trailing) = &row.trailing {
1324            right -= MENU_ICON_SIZE;
1325            trailing.paint(
1326                Point::new(right, middle - trailing.extent / 2.0),
1327                icon_ink,
1328                scene,
1329            );
1330            right -= MENU_ICON_GAP;
1331        }
1332        if let Some(shortcut) = &row.shortcut {
1333            let size = shortcut.size();
1334            shortcut.paint(
1335                Point::new(right - size.width, middle - size.height / 2.0),
1336                colors.supporting_foreground(row.enabled, row.selected),
1337                scene,
1338            );
1339        }
1340    }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::*;
1346    use crate::menu::items::{
1347        menu_entry, menu_group, menu_selectable, menu_submenu, menu_toggleable,
1348    };
1349    use crate::menu::{MENU_CONTAINER_RADIUS, MENU_ITEM_RADIUS, MENU_SECTION_GAP};
1350    use frust::authoring::scene::GlyphRun;
1351    use frust::authoring::{BuildCtx, PointerButton, Rect as KRect, text::TextContext};
1352    use frust::{Brightness, FrameTime};
1353    use std::any::Any;
1354
1355    const AREA: Size = Size::new(600.0, 800.0);
1356
1357    /// What the panel reported, in order.
1358    #[derive(Default)]
1359    struct AppState {
1360        selections: Vec<MenuSelection>,
1361    }
1362
1363    #[derive(Default)]
1364    struct Recorder {
1365        rects: Vec<(Point, Size, Color)>,
1366        rrects: Vec<(Point, Size, f64, Color)>,
1367        /// Origins of every filled icon path.
1368        paths: Vec<Point>,
1369        runs: usize,
1370        shadows: usize,
1371        clips: usize,
1372    }
1373
1374    impl PaintScene for Recorder {
1375        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
1376            self.rects.push((origin, size, color));
1377        }
1378        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1379            self.rrects.push((origin, size, radius, color));
1380        }
1381        fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1382            self.paths.push(origin);
1383        }
1384        fn draw_text(&mut self, _origin: Point, _text: &str) {}
1385        fn draw_glyph_run(&mut self, _run: GlyphRun) {
1386            self.runs += 1;
1387        }
1388        fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _std: f64, _c: Color) {
1389            self.shadows += 1;
1390        }
1391        fn push_clip(&mut self, _origin: Point, _size: Size) {
1392            self.clips += 1;
1393        }
1394        fn pop_clip(&mut self) {}
1395    }
1396
1397    /// The tree every structural test below renders: one of each node kind.
1398    fn every_kind() -> Vec<MenuNode> {
1399        vec![
1400            menu_entry("Cut").shortcut("Ctrl X").into(),
1401            MenuNode::Divider,
1402            menu_group(vec![
1403                menu_selectable("Grid", "grid").selected(true).into(),
1404                menu_selectable("List", "list").into(),
1405            ])
1406            .label("View")
1407            .into(),
1408            menu_toggleable("Word wrap", true).into(),
1409            menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
1410        ]
1411    }
1412
1413    fn view(nodes: Vec<MenuNode>) -> MenuPanelView<AppState> {
1414        menu_panel(nodes, |state: &mut AppState, selection| {
1415            state.selections.push(selection)
1416        })
1417    }
1418
1419    fn build(view: &MenuPanelView<AppState>) -> MenuPanelWidget {
1420        let mut counter = 0u64;
1421        View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1422    }
1423
1424    fn layout(w: &mut MenuPanelWidget, area: Size) -> Size {
1425        let mut tcx = TextContext::new();
1426        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1427        w.layout(&mut lctx, &BoxConstraints::loose(area))
1428    }
1429
1430    /// Build + lay out in one step: the state every event/paint test starts in.
1431    fn ready(nodes: Vec<MenuNode>) -> MenuPanelWidget {
1432        let mut w = build(&view(nodes));
1433        layout(&mut w, AREA);
1434        w
1435    }
1436
1437    /// Rebuild `w` from `prev` to `next`, the way a live app's pass does.
1438    fn rebuild(
1439        w: &mut MenuPanelWidget,
1440        prev: &MenuPanelView<AppState>,
1441        next: &MenuPanelView<AppState>,
1442    ) {
1443        let mut counter = 0u64;
1444        View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1445    }
1446
1447    fn paint(w: &mut MenuPanelWidget) -> Recorder {
1448        let theme = crate::baseline().with_brightness(Brightness::Light);
1449        let mut rec = Recorder::default();
1450        let size = Size::new(w.width, w.viewport);
1451        let mut ctx =
1452            PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1453        w.paint(&mut ctx, &mut rec);
1454        rec
1455    }
1456
1457    fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1458        InputEvent::Pointer(PointerEvent {
1459            phase,
1460            position: Point::new(x, y),
1461            button: PointerButton::Primary,
1462        })
1463    }
1464
1465    fn dispatch(w: &mut MenuPanelWidget, state: &mut AppState, event: &InputEvent) -> EventResult {
1466        let any: &mut dyn Any = state;
1467        let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.viewport));
1468        w.event(&mut ctx, event)
1469    }
1470
1471    /// The centre of row `row`, in panel-local coordinates.
1472    fn row_center(w: &MenuPanelWidget, row: usize) -> Point {
1473        let (top, height) = w.band(row);
1474        Point::new(w.width / 2.0, top + height / 2.0 - w.scroll)
1475    }
1476
1477    /// Press and release row `row`.
1478    fn click(w: &mut MenuPanelWidget, state: &mut AppState, row: usize) {
1479        let c = row_center(w, row);
1480        dispatch(w, state, &pointer(PointerPhase::Down, c.x, c.y));
1481        dispatch(w, state, &pointer(PointerPhase::Up, c.x, c.y));
1482    }
1483
1484    /// Press and release inside the open submenu's first row.
1485    fn click_submenu_row(w: &mut MenuPanelWidget, state: &mut AppState) {
1486        let rect = w.open_sub_rect().expect("an open submenu is placed");
1487        let p = Point::new(
1488            rect.x0 + rect.width() / 2.0,
1489            rect.y0 + MENU_SURFACE_V_PADDING + MENU_ROW_MIN_HEIGHT / 2.0,
1490        );
1491        dispatch(w, state, &pointer(PointerPhase::Down, p.x, p.y));
1492        dispatch(w, state, &pointer(PointerPhase::Up, p.x, p.y));
1493    }
1494
1495    // ---- structure --------------------------------------------------------
1496
1497    #[test]
1498    fn all_six_kinds_render_across_the_surfaces_they_partition_into() {
1499        let mut w = ready(every_kind());
1500        // Entry, divider, two selectables, toggleable, submenu row.
1501        assert_eq!(w.rows.len(), 6);
1502        assert_eq!(
1503            w.surfaces.len(),
1504            3,
1505            "flat run, the labelled group, the tail"
1506        );
1507        assert!(w.surfaces[1].label.is_some(), "the group's section label");
1508
1509        let rec = paint(&mut w);
1510        // One container per surface, each at the container radius, each lifted.
1511        let containers = rec
1512            .rrects
1513            .iter()
1514            .filter(|(_, _, radius, _)| *radius == MENU_CONTAINER_RADIUS)
1515            .count();
1516        assert_eq!(containers, 3);
1517        assert_eq!(rec.shadows, 3, "every surface takes the level-2 rung");
1518        // The divider is the one plain rect, at the hairline thickness.
1519        assert_eq!(rec.rects.len(), 1);
1520        assert_eq!(rec.rects[0].1.height, MENU_DIVIDER_THICKNESS);
1521        // Icons: the selected `Grid` check, the toggleable's box, the chevron.
1522        assert_eq!(rec.paths.len(), 3);
1523        // Every label, the section label and the shortcut shape real runs.
1524        assert!(rec.runs >= 7, "runs: {}", rec.runs);
1525    }
1526
1527    #[test]
1528    fn the_surfaces_stack_with_the_section_gap_between_them() {
1529        let w = ready(every_kind());
1530        for pair in w.surfaces.windows(2) {
1531            assert_eq!(
1532                pair[1].top - (pair[0].top + pair[0].height),
1533                MENU_SECTION_GAP
1534            );
1535        }
1536        assert_eq!(w.surfaces[0].top, 0.0);
1537    }
1538
1539    #[test]
1540    fn a_group_label_reserves_its_own_band_above_the_group_s_rows() {
1541        let labelled = ready(vec![
1542            menu_group(vec![menu_entry("a").into()])
1543                .label("Section")
1544                .into(),
1545        ]);
1546        let bare = ready(vec![menu_group(vec![menu_entry("a").into()]).into()]);
1547        assert!(
1548            labelled.surfaces[0].height > bare.surfaces[0].height,
1549            "{} vs {}",
1550            labelled.surfaces[0].height,
1551            bare.surfaces[0].height
1552        );
1553        assert!(labelled.rows[0].top > bare.rows[0].top);
1554    }
1555
1556    #[test]
1557    fn a_row_is_at_least_the_reference_s_entry_height() {
1558        let w = ready(vec![menu_entry("Cut").into()]);
1559        assert_eq!(w.rows[0].height, MENU_ITEM_GAP + MENU_ROW_MIN_HEIGHT);
1560        // Supporting text never shrinks a row below the same floor.
1561        let tall = ready(vec![
1562            menu_entry("Cut").supporting("Removes the selection").into(),
1563        ]);
1564        assert!(tall.rows[0].height >= w.rows[0].height);
1565    }
1566
1567    #[test]
1568    fn the_panel_width_clamps_into_the_reference_s_min_max_band() {
1569        let narrow = ready(vec![menu_entry("a").into()]);
1570        assert_eq!(
1571            narrow.width(),
1572            MENU_MIN_WIDTH,
1573            "short labels take the floor"
1574        );
1575        let wide = ready(vec![
1576            menu_entry("a label far longer than any menu should ever try to show inline")
1577                .shortcut("Ctrl Shift X")
1578                .into(),
1579        ]);
1580        assert_eq!(wide.width(), MENU_MAX_WIDTH, "long ones take the ceiling");
1581    }
1582
1583    #[test]
1584    fn a_narrow_area_wins_over_the_reference_s_own_minimum() {
1585        let mut w = build(&view(vec![menu_entry("a").into()]));
1586        let size = layout(&mut w, Size::new(80.0, 800.0));
1587        assert_eq!(size.width, 80.0, "the panel never overflows its area");
1588    }
1589
1590    #[test]
1591    fn a_tall_tree_caps_at_the_max_height_and_clips_while_scrolling() {
1592        let nodes: Vec<MenuNode> = (0..20)
1593            .map(|i| menu_entry(format!("row {i}")).into())
1594            .collect();
1595        let mut w = ready(nodes);
1596        assert_eq!(w.viewport, MENU_MAX_HEIGHT);
1597        assert!(w.content_height > MENU_MAX_HEIGHT);
1598        assert_eq!(paint(&mut w).clips, 1, "a scrolling panel clips");
1599        assert_eq!(
1600            paint(&mut ready(vec![menu_entry("a").into()])).clips,
1601            0,
1602            "…and one that fits never clips its own shadow out"
1603        );
1604
1605        let mut state = AppState::default();
1606        let scrolled = dispatch(
1607            &mut w,
1608            &mut state,
1609            &InputEvent::Scroll {
1610                position: Point::new(10.0, 10.0),
1611                delta: ScrollDelta::Pixels(0.0, 120.0),
1612            },
1613        );
1614        assert_eq!(scrolled, EventResult::Handled);
1615        assert_eq!(w.scroll(), 120.0);
1616        // …and never past the end of the content.
1617        dispatch(
1618            &mut w,
1619            &mut state,
1620            &InputEvent::Scroll {
1621                position: Point::new(10.0, 10.0),
1622                delta: ScrollDelta::Pixels(0.0, 10_000.0),
1623            },
1624        );
1625        assert_eq!(w.scroll(), w.content_height - w.viewport);
1626    }
1627
1628    // ---- selection semantics ---------------------------------------------
1629
1630    #[test]
1631    fn an_entry_reports_a_press_at_its_depth_first_index() {
1632        let mut w = ready(every_kind());
1633        let mut state = AppState::default();
1634        click(&mut w, &mut state, 0);
1635        assert_eq!(state.selections.len(), 1);
1636        assert_eq!(state.selections[0].index, 0);
1637        assert_eq!(state.selections[0].label, "Cut");
1638        assert_eq!(state.selections[0].action, MenuAction::Press);
1639    }
1640
1641    #[test]
1642    fn a_selectable_reports_its_value_and_never_flips_its_own_state() {
1643        let nodes = vec![
1644            menu_selectable("Grid", "grid").into(),
1645            menu_selectable("List", "list").into(),
1646        ];
1647        let mut w = build(&view(nodes.clone()).selected(Some("grid".into())));
1648        layout(&mut w, AREA);
1649        assert!(w.rows[0].selected, "the panel's value checks the row");
1650        assert!(!w.rows[1].selected);
1651
1652        let mut state = AppState::default();
1653        click(&mut w, &mut state, 1);
1654        assert_eq!(state.selections[0].value(), Some("list"));
1655        assert!(
1656            w.rows[0].selected && !w.rows[1].selected,
1657            "controlled: the panel does not move the check itself"
1658        );
1659
1660        // The app confirms, and the next rebuild moves it.
1661        rebuild(
1662            &mut w,
1663            &view(nodes.clone()).selected(Some("grid".into())),
1664            &view(nodes).selected(Some("list".into())),
1665        );
1666        layout(&mut w, AREA);
1667        assert!(!w.rows[0].selected && w.rows[1].selected);
1668    }
1669
1670    #[test]
1671    fn a_toggleable_requests_the_opposite_of_its_current_state() {
1672        let mut w = ready(vec![
1673            menu_toggleable("Wrap", false).into(),
1674            menu_toggleable("Minimap", true).into(),
1675        ]);
1676        let mut state = AppState::default();
1677        click(&mut w, &mut state, 0);
1678        click(&mut w, &mut state, 1);
1679        assert_eq!(state.selections[0].checked(), Some(true));
1680        assert_eq!(state.selections[1].checked(), Some(false));
1681        assert!(
1682            !w.rows[0].selected && w.rows[1].selected,
1683            "controlled: the boxes stay as the caller last drew them"
1684        );
1685    }
1686
1687    #[test]
1688    fn a_disabled_row_and_a_divider_report_nothing() {
1689        let mut w = ready(vec![
1690            menu_entry("Paste").enabled(false).into(),
1691            MenuNode::Divider,
1692        ]);
1693        let mut state = AppState::default();
1694        let c = row_center(&w, 0);
1695        assert_eq!(
1696            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
1697            EventResult::Ignored
1698        );
1699        click(&mut w, &mut state, 1);
1700        assert!(state.selections.is_empty());
1701    }
1702
1703    #[test]
1704    fn a_release_outside_the_armed_row_reports_nothing() {
1705        let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
1706        let mut state = AppState::default();
1707        let first = row_center(&w, 0);
1708        let second = row_center(&w, 1);
1709        dispatch(
1710            &mut w,
1711            &mut state,
1712            &pointer(PointerPhase::Down, first.x, first.y),
1713        );
1714        dispatch(
1715            &mut w,
1716            &mut state,
1717            &pointer(PointerPhase::Up, second.x, second.y),
1718        );
1719        assert!(state.selections.is_empty(), "fires on up-inside only");
1720    }
1721
1722    #[test]
1723    fn only_a_primary_press_arms_a_row() {
1724        let mut w = ready(vec![menu_entry("a").into()]);
1725        let mut state = AppState::default();
1726        let c = row_center(&w, 0);
1727        let secondary = InputEvent::Pointer(PointerEvent {
1728            phase: PointerPhase::Down,
1729            position: c,
1730            button: PointerButton::Secondary,
1731        });
1732        assert_eq!(
1733            dispatch(&mut w, &mut state, &secondary),
1734            EventResult::Ignored
1735        );
1736        assert_eq!(
1737            dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y)),
1738            EventResult::Ignored
1739        );
1740        assert!(state.selections.is_empty());
1741    }
1742
1743    #[test]
1744    fn a_cancel_clears_the_press_without_reporting() {
1745        let mut w = ready(vec![menu_entry("a").into()]);
1746        let mut state = AppState::default();
1747        let c = row_center(&w, 0);
1748        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1749        assert_eq!(
1750            dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y)),
1751            EventResult::Handled
1752        );
1753        assert!(state.selections.is_empty());
1754        assert_eq!(
1755            dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y)),
1756            EventResult::Ignored,
1757            "the gesture is over"
1758        );
1759    }
1760
1761    #[test]
1762    fn a_press_on_the_panel_s_own_background_is_left_for_the_host() {
1763        let mut w = ready(vec![menu_entry("a").into()]);
1764        let mut state = AppState::default();
1765        assert_eq!(
1766            dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 4.0, 1.0)),
1767            EventResult::Ignored,
1768            "the surface padding is not a row"
1769        );
1770    }
1771
1772    // ---- submenus ---------------------------------------------------------
1773
1774    #[test]
1775    fn a_tap_on_a_submenu_row_opens_its_panel_and_reports_nothing() {
1776        let mut w = ready(vec![
1777            menu_entry("Cut").into(),
1778            menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
1779        ]);
1780        let mut state = AppState::default();
1781        assert_eq!(w.open_submenu(), None);
1782        click(&mut w, &mut state, 1);
1783        assert_eq!(w.open_submenu(), Some(1), "the reference's tap-open rule");
1784        assert!(state.selections.is_empty(), "opening is not selecting");
1785
1786        // The nested panel is placed on the trailing side of its row.
1787        let rect = w.open_sub_rect().expect("an open submenu is placed");
1788        assert_eq!(rect.x0, w.width() + MENU_SUBMENU_GAP);
1789        assert_eq!(rect.y0, w.band(1).0 - MENU_SURFACE_V_PADDING);
1790        assert!(rect.width() > 0.0 && rect.height() > 0.0);
1791    }
1792
1793    #[test]
1794    fn hovering_a_submenu_row_opens_it_and_hovering_elsewhere_closes_it() {
1795        let mut w = ready(vec![
1796            menu_entry("Cut").into(),
1797            menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
1798        ]);
1799        let mut state = AppState::default();
1800        let sub = row_center(&w, 1);
1801        assert_eq!(
1802            dispatch(
1803                &mut w,
1804                &mut state,
1805                &pointer(PointerPhase::Move, sub.x, sub.y)
1806            ),
1807            EventResult::Ignored,
1808            "watching a move is not consuming it"
1809        );
1810        assert_eq!(w.open_submenu(), Some(1));
1811
1812        // Travelling onto the open panel keeps it.
1813        let inside = w.open_sub_rect().expect("placed").center();
1814        dispatch(
1815            &mut w,
1816            &mut state,
1817            &pointer(PointerPhase::Move, inside.x, inside.y),
1818        );
1819        assert_eq!(
1820            w.open_submenu(),
1821            Some(1),
1822            "the pointer has to be able to get there"
1823        );
1824
1825        // Any other row closes it.
1826        let other = row_center(&w, 0);
1827        dispatch(
1828            &mut w,
1829            &mut state,
1830            &pointer(PointerPhase::Move, other.x, other.y),
1831        );
1832        assert_eq!(w.open_submenu(), None);
1833    }
1834
1835    #[test]
1836    fn a_submenu_row_reports_its_children_at_the_indices_that_follow_it() {
1837        let mut w = ready(vec![
1838            menu_entry("Cut").into(),
1839            menu_submenu(
1840                "Share",
1841                vec![menu_entry("Copy link").into(), menu_entry("Email").into()],
1842            )
1843            .into(),
1844            menu_entry("Paste").into(),
1845        ]);
1846        let mut state = AppState::default();
1847        click(&mut w, &mut state, 1);
1848        // Index 1 is the submenu row itself, 2 and 3 its children, 4 the tail.
1849        click_submenu_row(&mut w, &mut state);
1850        assert_eq!(state.selections.len(), 1, "the nested row reported");
1851        assert_eq!(state.selections[0].index, 2);
1852        assert_eq!(state.selections[0].label, "Copy link");
1853        assert_eq!(
1854            w.rows[2].index, 4,
1855            "the tail keeps counting past the subtree"
1856        );
1857    }
1858
1859    #[test]
1860    fn a_disabled_submenu_row_opens_nothing() {
1861        let mut w = ready(vec![
1862            menu_submenu("Share", vec![menu_entry("Copy link").into()])
1863                .enabled(false)
1864                .into(),
1865        ]);
1866        let mut state = AppState::default();
1867        let c = row_center(&w, 0);
1868        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1869        click(&mut w, &mut state, 0);
1870        assert_eq!(w.open_submenu(), None);
1871    }
1872
1873    #[test]
1874    fn closing_the_panel_closes_the_whole_chain() {
1875        let nodes = vec![
1876            menu_submenu(
1877                "Share",
1878                vec![menu_submenu("More", vec![menu_entry("Deep").into()]).into()],
1879            )
1880            .into(),
1881        ];
1882        let mut w = ready(nodes.clone());
1883        let mut state = AppState::default();
1884        click(&mut w, &mut state, 0);
1885        assert_eq!(w.open_submenu(), Some(0));
1886        // Open the nested one too, through the child panel itself.
1887        click_submenu_row(&mut w, &mut state);
1888        assert_eq!(nested(&mut w, 0), Some(0), "the cascade is two deep");
1889
1890        // The host's dismissal reaches the panel as `open == false`.
1891        rebuild(
1892            &mut w,
1893            &view(nodes.clone()).open(true),
1894            &view(nodes).open(false),
1895        );
1896        assert_eq!(w.open_submenu(), None);
1897        assert_eq!(
1898            nested(&mut w, 0),
1899            None,
1900            "every depth closed, not just the first"
1901        );
1902    }
1903
1904    /// The open submenu of the panel behind pod `pod`.
1905    ///
1906    /// Two downcasts, not one: a submenu pod holds an `AnyView`'s element,
1907    /// which is itself a `Box<dyn Widget>` around the panel.
1908    fn nested(w: &mut MenuPanelWidget, pod: usize) -> Option<usize> {
1909        let boxed = w.pods[pod]
1910            .widget_mut()
1911            .downcast_mut::<Box<dyn Widget>>()
1912            .expect("an erased pod");
1913        (**boxed)
1914            .downcast_mut::<MenuPanelWidget>()
1915            .expect("the submenu panel")
1916            .open_submenu()
1917    }
1918
1919    // ---- chrome -----------------------------------------------------------
1920
1921    #[test]
1922    fn a_pressed_row_takes_a_state_layer_and_a_selected_one_its_own_fill() {
1923        let mut w = ready(vec![menu_selectable("Grid", "grid").selected(true).into()]);
1924        let colors = MenuColors::resolve(
1925            Some(&crate::baseline().with_brightness(Brightness::Light)),
1926            MenuColorStyle::Standard,
1927        );
1928        let washes = |rec: &Recorder| -> Vec<Color> {
1929            rec.rrects
1930                .iter()
1931                .filter(|(_, _, radius, _)| *radius == MENU_ITEM_RADIUS)
1932                .map(|(_, _, _, color)| *color)
1933                .collect()
1934        };
1935        let rec = paint(&mut w);
1936        assert_eq!(
1937            washes(&rec),
1938            vec![colors.selected_container],
1939            "the selected fill, and no wash yet"
1940        );
1941
1942        // The press is what a paint pass can observe: a `Down` ends the hover
1943        // link, so the hover latch is cleared by `paint`'s own self-correction
1944        // while the pressed flag stands.
1945        let mut state = AppState::default();
1946        let c = row_center(&w, 0);
1947        dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1948        let rec = paint(&mut w);
1949        let painted = washes(&rec);
1950        assert_eq!(painted.len(), 2, "the fill plus the press wash over it");
1951        assert_eq!(painted[0], colors.selected_container);
1952        assert_eq!(
1953            painted[1].components[3],
1954            crate::interaction::PRESSED_OPACITY
1955        );
1956    }
1957
1958    #[test]
1959    fn a_drag_off_the_armed_row_drops_its_wash_and_a_drag_back_re_arms_it() {
1960        let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
1961        let mut state = AppState::default();
1962        let first = row_center(&w, 0);
1963        let second = row_center(&w, 1);
1964        dispatch(
1965            &mut w,
1966            &mut state,
1967            &pointer(PointerPhase::Down, first.x, first.y),
1968        );
1969        assert!(w.press_inside);
1970        dispatch(
1971            &mut w,
1972            &mut state,
1973            &pointer(PointerPhase::Move, second.x, second.y),
1974        );
1975        assert!(!w.press_inside, "the wash follows the pointer out");
1976        assert_eq!(w.pressed, Some(0), "…but the gesture is still armed");
1977        dispatch(
1978            &mut w,
1979            &mut state,
1980            &pointer(PointerPhase::Move, first.x, first.y),
1981        );
1982        assert!(w.press_inside, "and back in");
1983        dispatch(
1984            &mut w,
1985            &mut state,
1986            &pointer(PointerPhase::Up, first.x, first.y),
1987        );
1988        assert_eq!(state.selections.len(), 1);
1989    }
1990
1991    #[test]
1992    fn a_move_latches_the_hovered_row_for_the_next_paint() {
1993        let mut w = ready(vec![menu_entry("a").into(), menu_entry("b").into()]);
1994        let mut state = AppState::default();
1995        let second = row_center(&w, 1);
1996        dispatch(
1997            &mut w,
1998            &mut state,
1999            &pointer(PointerPhase::Move, second.x, second.y),
2000        );
2001        assert_eq!(w.hovered, Some(1));
2002        // The panel's own padding is not a row.
2003        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, 2.0, 1.0));
2004        assert_eq!(w.hovered, None);
2005    }
2006
2007    #[test]
2008    fn a_disabled_row_never_washes() {
2009        let mut w = ready(vec![menu_entry("Paste").enabled(false).into()]);
2010        let mut state = AppState::default();
2011        let c = row_center(&w, 0);
2012        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
2013        let rec = paint(&mut w);
2014        assert!(
2015            !rec.rrects
2016                .iter()
2017                .any(|(_, _, radius, _)| *radius == MENU_ITEM_RADIUS),
2018            "no fill and no wash"
2019        );
2020    }
2021
2022    #[test]
2023    fn the_radii_resolve_off_the_theme_s_shape_scale() {
2024        // The reference's `containerRadius: 16` / `itemRadius: 12` land exactly
2025        // on the `large` / `medium` shape tokens, which is what lets a themed
2026        // pass read the token and the constants stand as bare fallbacks. The
2027        // radius-filtered assertions above depend on that coincidence holding.
2028        let theme = crate::baseline();
2029        assert_eq!(container_radius(Some(&theme)), theme.shape.large);
2030        assert_eq!(item_radius(Some(&theme)), theme.shape.medium);
2031        assert_eq!(container_radius(None), MENU_CONTAINER_RADIUS);
2032        assert_eq!(item_radius(None), MENU_ITEM_RADIUS);
2033        assert_eq!(theme.shape.large, MENU_CONTAINER_RADIUS);
2034        assert_eq!(theme.shape.medium, MENU_ITEM_RADIUS);
2035    }
2036
2037    #[test]
2038    fn the_vibrant_style_paints_its_own_container() {
2039        let mut standard = ready(vec![menu_entry("a").into()]);
2040        let mut vibrant =
2041            build(&view(vec![menu_entry("a").into()]).color_style(MenuColorStyle::Vibrant));
2042        layout(&mut vibrant, AREA);
2043        let a = paint(&mut standard).rrects[0].3;
2044        let b = paint(&mut vibrant).rrects[0].3;
2045        assert_ne!(a, b);
2046        let theme = crate::baseline().with_brightness(Brightness::Light);
2047        assert_eq!(a, theme.scheme().surface_container_low);
2048        assert_eq!(b, theme.scheme().tertiary_container);
2049    }
2050
2051    #[test]
2052    fn an_unthemed_pass_paints_the_baseline_light_roles() {
2053        // Nothing may panic without a theme threaded in, and the fallbacks are
2054        // the same values the themed pass resolves.
2055        let mut w = ready(vec![menu_entry("a").into()]);
2056        let mut rec = Recorder::default();
2057        let size = Size::new(w.width, w.viewport);
2058        let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO);
2059        w.paint(&mut ctx, &mut rec);
2060        assert_eq!(
2061            rec.rrects[0].3,
2062            MenuColors::resolve(None, MenuColorStyle::Standard).container
2063        );
2064    }
2065
2066    #[test]
2067    fn the_semantics_tree_is_a_menu_of_role_typed_rows() {
2068        use frust_core::RenderRoot;
2069        let mut root: RenderRoot<AppState, MenuPanelView<AppState>> = RenderRoot::new();
2070        let mut state = AppState::default();
2071        let mut tcx = TextContext::new();
2072        let mut logic = |_s: &mut AppState| {
2073            view(vec![
2074                menu_entry("Cut").into(),
2075                MenuNode::Divider,
2076                menu_selectable("Grid", "grid").selected(true).into(),
2077                menu_toggleable("Wrap", false).into(),
2078            ])
2079        };
2080        root.rebuild(&mut logic, &mut state);
2081        root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
2082        let nodes = root.semantics().nodes;
2083        let roles: Vec<_> = nodes.iter().map(|(_, n)| n.role()).collect();
2084        assert!(roles.contains(&Role::Menu));
2085        assert_eq!(
2086            roles.iter().filter(|r| **r == Role::MenuItem).count(),
2087            1,
2088            "the divider contributes nothing"
2089        );
2090        assert!(roles.contains(&Role::MenuItemRadio));
2091        assert!(roles.contains(&Role::MenuItemCheckBox));
2092        let radio = nodes
2093            .iter()
2094            .find(|(_, n)| n.role() == Role::MenuItemRadio)
2095            .expect("a radio row");
2096        assert_eq!(radio.1.label(), Some("Grid"));
2097        assert_eq!(radio.1.toggled(), Some(Toggled::True));
2098    }
2099
2100    #[test]
2101    fn a_rebuild_that_re_supplies_the_same_tree_keeps_its_shaped_runs() {
2102        let nodes = every_kind();
2103        let mut w = ready(nodes.clone());
2104        let before = w.rows[0].label.size();
2105        assert!(before.width > 0.0);
2106        rebuild(&mut w, &view(nodes.clone()), &view(nodes));
2107        assert_eq!(
2108            w.rows[0].label.size(),
2109            before,
2110            "no re-shape, so the cached layout survives"
2111        );
2112    }
2113
2114    #[test]
2115    fn a_rebuild_with_a_new_tree_replaces_the_rows_and_drops_the_chain() {
2116        let nodes = vec![menu_submenu("Share", vec![menu_entry("Copy link").into()]).into()];
2117        let mut w = ready(nodes.clone());
2118        let mut state = AppState::default();
2119        click(&mut w, &mut state, 0);
2120        assert_eq!(w.open_submenu(), Some(0));
2121
2122        rebuild(
2123            &mut w,
2124            &view(nodes),
2125            &view(vec![menu_entry("Only this").into()]),
2126        );
2127        layout(&mut w, AREA);
2128        assert_eq!(w.rows.len(), 1);
2129        assert_eq!(w.open_submenu(), None);
2130        assert!(w.pods.is_empty(), "the submenu pod went with its row");
2131    }
2132
2133    #[test]
2134    fn a_broadcast_reaches_every_submenu_pod_and_is_never_consumed() {
2135        let mut w = ready(vec![
2136            menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
2137        ]);
2138        let mut state = AppState::default();
2139        let result = dispatch(&mut w, &mut state, &InputEvent::Housekeeping);
2140        assert_eq!(result, EventResult::Ignored);
2141    }
2142
2143    #[test]
2144    fn a_key_event_is_left_for_the_host_to_dismiss_on() {
2145        use frust::authoring::{Key, KeyEvent, Modifiers, NamedKey};
2146        let mut w = ready(vec![menu_entry("a").into()]);
2147        let mut state = AppState::default();
2148        let escape = InputEvent::Key(KeyEvent {
2149            key: Key::Named(NamedKey::Escape),
2150            modifiers: Modifiers::default(),
2151            repeat: false,
2152        });
2153        assert_eq!(dispatch(&mut w, &mut state, &escape), EventResult::Ignored);
2154    }
2155
2156    #[test]
2157    fn a_paint_with_no_hover_link_clears_the_latched_row() {
2158        let mut w = ready(vec![menu_entry("a").into()]);
2159        let mut state = AppState::default();
2160        let c = row_center(&w, 0);
2161        dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
2162        assert_eq!(w.hovered, Some(0));
2163        // `PaintCtx::for_test` reports no hover path, which is authoritative.
2164        paint(&mut w);
2165        assert_eq!(w.hovered, None);
2166    }
2167
2168    #[test]
2169    fn every_painted_rect_stays_inside_the_panel_s_own_width() {
2170        let mut w = ready(every_kind());
2171        let rec = paint(&mut w);
2172        let panel = KRect::from_origin_size(Point::ORIGIN, Size::new(w.width, w.viewport));
2173        for (origin, size, _, _) in rec.rrects.iter() {
2174            assert!(origin.x >= panel.x0 - 0.001, "{origin:?}");
2175            assert!(origin.x + size.width <= panel.x1 + 0.001, "{origin:?}");
2176        }
2177    }
2178}