Skip to main content

frust_material/menu/
mod.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/menus/` — `m3e_menus.dart` (the anchored menu),
3// `styles/m3e_menu_theme.dart` (every metric and both color resolutions) and
4// `enums/m3e_menu_color_style.dart`, retrieved 2026-08-19.
5// Upstream: <https://github.com/paadevelopments/material_3_expressive>
6// Porting decisions: the reference's `showM3EMenu` inserts its own Flutter
7// `OverlayEntry` and hand-rolls placement, a transparent dismiss layer and the
8// expand/collapse spring (`m3e_menu_popup.dart`, `utils/m3e_menu_placer.dart`,
9// `utils/m3e_menu_spring_motion.dart`); **none of that is ported** — this
10// catalog already merged one anchored host (`crate::overlay::anchored`), which
11// owns placement, light dismiss, Escape and the enter/exit ramp, so the menu
12// composes under it rather than beside it. See each submodule's own header.
13
14//! The Material 3 Expressive menu: the node tree, its two color styles, and
15//! the anchored menu that presents them.
16//!
17//! # Three layers
18//!
19//! 1. [`mod@items`] — the node vocabulary a caller composes ([`menu_entry`],
20//!    [`menu_selectable`], [`menu_toggleable`], [`MenuNode::Divider`],
21//!    [`menu_group`], [`menu_submenu`]) and what an activation reports back
22//!    ([`MenuSelection`]).
23//! 2. [`mod@panel`] — [`menu_panel`], the reusable item-list surface: it
24//!    renders the tree as a stack of elevated containers, runs the hover/press
25//!    machine and owns the submenu chain. A dropdown or a button-group popup
26//!    mounts this directly.
27//! 3. [`menu`] — the panel inside [`crate::overlay::anchored`], which is what
28//!    turns it into a menu: trigger-relative placement, light dismiss, Escape,
29//!    and the entrance/exit ramp.
30//!
31//! # The host owns the ramp
32//!
33//! [`crate::overlay::anchored`] composites the panel under its own fade and
34//! anchor-pivoted scale, so nothing here choreographs an entrance or an exit —
35//! and an app that wants the *exit* keeps the menu mounted and toggles
36//! [`MenuView::open`] rather than unmounting it (the kept-mounted pattern that
37//! host documents).
38//!
39//! # Standard and vibrant
40//!
41//! [`MenuColorStyle`] picks between the reference's two container/content
42//! resolutions (`m3e_menu_theme.dart:239-261`): **standard** is a
43//! `surfaceContainerLow` surface with `onSurface` ink and a `tertiaryContainer`
44//! selection; **vibrant** makes the whole surface `tertiaryContainer` and
45//! promotes the selection to `tertiary`. Both resolve from plain `ColorScheme`
46//! roles — none of the nine [`crate::MaterialTokens`] extension roles applies
47//! here — which is the same role-partition line [`mod@crate::divider`] draws.
48
49pub mod items;
50pub mod panel;
51
52pub use items::{
53    MenuAction, MenuEntry, MenuGroup, MenuIcon, MenuNode, MenuSelectable, MenuSelection,
54    MenuSubmenu, MenuToggleable, menu_entry, menu_group, menu_selectable, menu_submenu,
55    menu_toggleable, partition_surfaces,
56};
57pub use panel::{MenuPanelView, MenuPanelWidget, menu_panel};
58
59use std::rc::Rc;
60
61use frust::authoring::{BuildCtx, ChangeFlags, TypedArgCallback, View};
62use frust::{Color, Theme};
63
64use crate::interaction::DISABLED_CONTENT_OPACITY;
65use crate::overlay::{
66    AnchoredOverlayView, AnchoredOverlayWidget, OVERLAY_ANCHOR_GAP, OverlayAlign, OverlayAnchor,
67    OverlayPlacement, OverlaySide, anchored_overlay, with_alpha,
68};
69use crate::tokens::{MaterialDimensions, MaterialSpacing};
70
71// ---- Metrics (`m3e_menu_theme.dart:82`'s `M3EMenuTheme` defaults) ----------
72//
73// Every value below is that constructor's own default; the ones that land
74// exactly on a Material spacing/shape token resolve to it rather than repeating
75// a literal.
76
77/// A menu surface's minimum width, in logical px (`minWidth: 112`).
78pub const MENU_MIN_WIDTH: f64 = 112.0;
79/// A menu surface's maximum width (`maxWidth: 280`).
80pub const MENU_MAX_WIDTH: f64 = 280.0;
81/// The height past which a menu scrolls instead of growing (`maxHeight: 320`).
82pub const MENU_MAX_HEIGHT: f64 = 320.0;
83/// A row's minimum height (`entryHeight: 48`).
84pub const MENU_ROW_MIN_HEIGHT: f64 = 48.0;
85/// A leading/trailing icon's side length (`iconSize: 24`).
86pub const MENU_ICON_SIZE: f64 = 24.0;
87
88/// A surface's top/bottom padding (`verticalPadding: 8`).
89pub(crate) const MENU_SURFACE_V_PADDING: f64 = MaterialSpacing::SM;
90/// A surface's left/right padding (`contentHorizontalPadding: 8`).
91pub(crate) const MENU_SURFACE_H_PADDING: f64 = MaterialSpacing::SM;
92/// The gap between two elevated surfaces (`sectionGap: 8`).
93pub(crate) const MENU_SECTION_GAP: f64 = MaterialSpacing::SM;
94/// A row's left/right padding (`entryHorizontalPadding: 12`).
95pub(crate) const MENU_ROW_H_PADDING: f64 = MaterialSpacing::MD;
96/// The gap between a row's icon and its text (`iconGap: 12`).
97pub(crate) const MENU_ICON_GAP: f64 = MaterialSpacing::MD;
98/// The vertical space between rows inside a surface (`itemGap: 4`).
99pub(crate) const MENU_ITEM_GAP: f64 = MaterialSpacing::XS;
100/// A section label's left/right padding (`groupLabelHorizontalPadding: 12`).
101pub(crate) const MENU_GROUP_LABEL_H_PADDING: f64 = MaterialSpacing::MD;
102/// A section label's top/bottom padding (`groupLabelVerticalPadding: 8`).
103pub(crate) const MENU_GROUP_LABEL_V_PADDING: f64 = MaterialSpacing::SM;
104/// Unthemed-fallback surface corner radius (`containerRadius: 16`) — a themed
105/// pass resolves [`container_radius`] instead.
106pub(crate) const MENU_CONTAINER_RADIUS: f64 = MaterialDimensions::RADIUS_LARGE;
107/// Unthemed-fallback row-highlight corner radius (`itemRadius: 12`) — a themed
108/// pass resolves [`item_radius`] instead.
109pub(crate) const MENU_ITEM_RADIUS: f64 = MaterialDimensions::RADIUS_MEDIUM;
110/// The extra top/bottom padding a row with supporting text takes
111/// (`m3e_menu_item.dart:98`'s `vertical: supportingText == null ? 0 : 8`).
112pub(crate) const MENU_SUPPORTING_V_PADDING: f64 = MaterialSpacing::SM;
113/// A divider's own top/bottom padding (`m3e_menu_divider.dart:18`).
114pub(crate) const MENU_DIVIDER_V_PADDING: f64 = MaterialSpacing::XS;
115/// A divider's rule thickness (`m3e_menu_divider.dart:22`'s `height: 1`).
116pub(crate) const MENU_DIVIDER_THICKNESS: f64 = 1.0;
117/// The gap between a submenu row and the panel it opens (`anchorOffset: 4`) —
118/// the same 4dp the anchored host uses between a trigger and its panel.
119pub(crate) const MENU_SUBMENU_GAP: f64 = OVERLAY_ANCHOR_GAP;
120/// The implicit selected check's size relative to [`MENU_ICON_SIZE`]
121/// (`m3e_menu_node_builders.dart:58`'s `iconSize * 0.9`).
122pub(crate) const SELECTED_CHECK_SCALE: f64 = 0.9;
123
124/// A menu surface's corner radius, resolved from the theme's shape scale.
125///
126/// The reference's `containerRadius: 16` lands exactly on the `large` shape
127/// token, so the token is what a themed pass reads and
128/// [`MENU_CONTAINER_RADIUS`] is only the unthemed fallback — the
129/// **explicit > theme > fallback** ladder the workspace's theming conventions
130/// require, and the same shape [`crate::overlay::radius`] takes for a panel.
131pub(crate) fn container_radius(theme: Option<&Theme>) -> f64 {
132    theme.map_or(MENU_CONTAINER_RADIUS, |t| t.shape.large)
133}
134
135/// A menu row's highlight radius — the `medium` shape token, which the
136/// reference's `itemRadius: 12` lands on. See [`container_radius`].
137pub(crate) fn item_radius(theme: Option<&Theme>) -> f64 {
138    theme.map_or(MENU_ITEM_RADIUS, |t| t.shape.medium)
139}
140
141// ---- Colors (`m3e_menu_theme.dart:234`'s `colors`) ------------------------
142
143/// Unthemed-fallback `surfaceContainerLow`.
144const FALLBACK_SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
145/// Unthemed-fallback `onSurface`.
146const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
147/// Unthemed-fallback `onSurfaceVariant`.
148const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
149/// Unthemed-fallback `tertiaryContainer`.
150const FALLBACK_TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
151/// Unthemed-fallback `onTertiaryContainer`.
152const FALLBACK_ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
153/// Unthemed-fallback `tertiary`.
154const FALLBACK_TERTIARY: Color = Color::from_rgb8(0x7D, 0x52, 0x60);
155/// Unthemed-fallback `onTertiary`.
156const FALLBACK_ON_TERTIARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
157/// Unthemed-fallback `outlineVariant`.
158const FALLBACK_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
159/// Unthemed-fallback `error` — a destructive row's ink.
160const FALLBACK_ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
161
162/// The alpha a vibrant menu's divider takes
163/// (`m3e_menu_theme.dart:259`'s `onTertiaryContainer.withValues(alpha: 0.24)`).
164const VIBRANT_DIVIDER_ALPHA: f32 = 0.24;
165
166/// Standard versus vibrant container/content mapping
167/// (`enums/m3e_menu_color_style.dart`).
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum MenuColorStyle {
170    /// `surfaceContainerLow` surface, `onSurface`/`onSurfaceVariant` content,
171    /// `tertiaryContainer` selection — the default.
172    #[default]
173    Standard,
174    /// `tertiaryContainer` surface throughout, with a `tertiary` selection.
175    Vibrant,
176}
177
178/// One [`MenuColorStyle`]'s resolved roles (`M3EMenuColors`).
179#[derive(Clone, Copy, Debug, PartialEq)]
180pub struct MenuColors {
181    /// The elevated surface's fill.
182    pub container: Color,
183    /// An idle row's label ink.
184    pub content: Color,
185    /// An idle row's leading/trailing icon ink.
186    pub icon_content: Color,
187    /// Supporting text, shortcuts and section labels.
188    pub supporting_content: Color,
189    /// A selected row's fill.
190    pub selected_container: Color,
191    /// A selected row's label and icon ink.
192    pub selected_content: Color,
193    /// The hover/focus/pressed overlay ink.
194    pub state_layer: Color,
195    /// A divider's rule.
196    pub divider: Color,
197}
198
199impl MenuColors {
200    /// Resolve `style`'s eight roles from `theme`, or from the M3 baseline
201    /// light values when no theme is threaded into the pass.
202    pub fn resolve(theme: Option<&Theme>, style: MenuColorStyle) -> Self {
203        let (
204            surface_container_low,
205            on_surface,
206            on_surface_variant,
207            tertiary_container,
208            on_tertiary_container,
209            tertiary,
210            on_tertiary,
211            outline_variant,
212        ) = match theme {
213            Some(theme) => {
214                let s = theme.scheme();
215                (
216                    s.surface_container_low,
217                    s.on_surface,
218                    s.on_surface_variant,
219                    s.tertiary_container,
220                    s.on_tertiary_container,
221                    s.tertiary,
222                    s.on_tertiary,
223                    s.outline_variant,
224                )
225            }
226            None => (
227                FALLBACK_SURFACE_CONTAINER_LOW,
228                FALLBACK_ON_SURFACE,
229                FALLBACK_ON_SURFACE_VARIANT,
230                FALLBACK_TERTIARY_CONTAINER,
231                FALLBACK_ON_TERTIARY_CONTAINER,
232                FALLBACK_TERTIARY,
233                FALLBACK_ON_TERTIARY,
234                FALLBACK_OUTLINE_VARIANT,
235            ),
236        };
237        match style {
238            MenuColorStyle::Standard => MenuColors {
239                container: surface_container_low,
240                content: on_surface,
241                icon_content: on_surface_variant,
242                supporting_content: on_surface_variant,
243                selected_container: tertiary_container,
244                selected_content: on_tertiary_container,
245                state_layer: on_surface,
246                divider: outline_variant,
247            },
248            MenuColorStyle::Vibrant => MenuColors {
249                container: tertiary_container,
250                content: on_tertiary_container,
251                icon_content: on_tertiary_container,
252                supporting_content: on_tertiary_container,
253                selected_container: tertiary,
254                selected_content: on_tertiary,
255                state_layer: on_tertiary_container,
256                divider: with_alpha(on_tertiary_container, VIBRANT_DIVIDER_ALPHA),
257            },
258        }
259    }
260
261    /// A row label's ink (`entryForegroundColor`): disabled dims
262    /// [`Self::content`] to 38%, destructive takes `error`, selected takes
263    /// [`Self::selected_content`].
264    pub fn entry_foreground(&self, enabled: bool, destructive: bool, selected: bool) -> Color {
265        if !enabled {
266            return with_alpha(self.content, DISABLED_CONTENT_OPACITY);
267        }
268        if destructive {
269            return self.error();
270        }
271        if selected {
272            self.selected_content
273        } else {
274            self.content
275        }
276    }
277
278    /// A row icon's ink (`entryIconForegroundColor`) — the same ladder over
279    /// [`Self::icon_content`].
280    pub fn icon_foreground(&self, enabled: bool, destructive: bool, selected: bool) -> Color {
281        if !enabled {
282            return with_alpha(self.icon_content, DISABLED_CONTENT_OPACITY);
283        }
284        if destructive {
285            return self.error();
286        }
287        if selected {
288            self.selected_content
289        } else {
290            self.icon_content
291        }
292    }
293
294    /// Supporting/shortcut text ink (`supportingTextStyle`): a disabled row
295    /// dims **`content`**, not `supporting_content` — the reference's own
296    /// asymmetry (`m3e_menu_theme.dart:361`), kept.
297    pub fn supporting_foreground(&self, enabled: bool, selected: bool) -> Color {
298        if !enabled {
299            return with_alpha(self.content, DISABLED_CONTENT_OPACITY);
300        }
301        if selected {
302            self.selected_content
303        } else {
304            self.supporting_content
305        }
306    }
307
308    /// The destructive ink. The reference reads `scheme.error` directly, which
309    /// no `M3EMenuColors` field carries; this port keeps it out of the struct
310    /// for the same reason and resolves the M3 baseline light value, matching
311    /// every other unthemed fallback here.
312    fn error(&self) -> Color {
313        FALLBACK_ERROR
314    }
315}
316
317/// A declarative Material menu: a [`menu_panel`] inside the anchored host. See
318/// [`menu`].
319///
320/// The host is composed fresh in each pass from the props below rather than
321/// retained: [`AnchoredOverlayView`] type-erases its content at construction,
322/// so there is no way to reach back into the panel and re-set one of its own
323/// props once it is inside.
324pub struct MenuView<State: 'static> {
325    nodes: Vec<MenuNode>,
326    style: MenuColorStyle,
327    selected: Option<String>,
328    open: bool,
329    placement: OverlayPlacement,
330    anchor: OverlayAnchor,
331    on_select: TypedArgCallback<State, MenuSelection>,
332    on_dismiss: Option<OnDismiss<State>>,
333}
334
335/// A view-held, typed dismiss callback — the same shape the anchored host's own
336/// takes, held here because the host is composed fresh per pass.
337type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
338
339/// Build a menu over `nodes`, to be mounted while the app's own open flag is
340/// set — or kept mounted with the flag handed to [`MenuView::open`] for an exit
341/// ramp (see [`crate::overlay::anchored`] for both mount contracts).
342///
343/// `on_select(state, selection)` reports an activation; a submenu row opens its
344/// panel instead of reporting, exactly as upstream.
345///
346/// The default placement is the reference's `bottomStart`
347/// (`m3e_menus.dart:38`): below the trigger, leading edges flush, at the 4dp
348/// anchor gap.
349pub fn menu<State: 'static, F: Fn(&mut State, MenuSelection) + 'static>(
350    nodes: Vec<MenuNode>,
351    on_select: F,
352) -> MenuView<State> {
353    MenuView {
354        nodes,
355        style: MenuColorStyle::default(),
356        selected: None,
357        open: true,
358        placement: OverlayPlacement::default()
359            .align(OverlayAlign::Start)
360            .offset(OVERLAY_ANCHOR_GAP),
361        anchor: OverlayAnchor::new(),
362        on_select: Rc::new(on_select),
363        on_dismiss: None,
364    }
365}
366
367impl<State: 'static> MenuView<State> {
368    /// Anchor the menu to the rect `anchor` carries (see
369    /// [`crate::overlay::OverlayAnchor`]).
370    pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
371        self.anchor = anchor.clone();
372        self
373    }
374
375    /// Set the side the menu opens on (`bottom` by default).
376    pub fn side(mut self, side: OverlaySide) -> Self {
377        self.placement.side = side;
378        self
379    }
380
381    /// Set the cross-axis alignment (`start` by default, the reference's
382    /// `bottomStart`).
383    pub fn align(mut self, align: OverlayAlign) -> Self {
384        self.placement.align = align;
385        self
386    }
387
388    /// Set the gap between trigger and menu (`anchorOffset`, 4dp by default).
389    pub fn offset(mut self, offset: f64) -> Self {
390        self.placement.offset = offset;
391        self
392    }
393
394    /// Pick the standard or vibrant color resolution.
395    pub fn color_style(mut self, style: MenuColorStyle) -> Self {
396        self.style = style;
397        self
398    }
399
400    /// Hand the menu the app-confirmed selected value its
401    /// [`MenuSelectable`] rows check against.
402    pub fn selected(mut self, value: Option<String>) -> Self {
403        self.selected = value;
404        self
405    }
406
407    /// Hand a **kept-mounted** menu the app's open flag, so closing it plays
408    /// the host's exit ramp instead of vanishing — and closes the whole submenu
409    /// cascade on the spot.
410    pub fn open(mut self, open: bool) -> Self {
411        self.open = open;
412        self
413    }
414
415    /// Set the dismiss callback: a press outside the menu, or Escape once the
416    /// host holds focus.
417    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
418        self.on_dismiss = Some(Rc::new(on_dismiss));
419        self
420    }
421
422    /// Compose the host and its panel from the current props.
423    fn host(&self) -> AnchoredOverlayView<State> {
424        let on_select = self.on_select.clone();
425        let panel = menu_panel(self.nodes.clone(), move |state: &mut State, selection| {
426            on_select(state, selection)
427        })
428        .color_style(self.style)
429        .selected(self.selected.clone())
430        .open(self.open);
431        let mut host = anchored_overlay(panel)
432            .anchor(&self.anchor)
433            .placement(self.placement)
434            .open(self.open);
435        if let Some(on_dismiss) = self.on_dismiss.clone() {
436            host = host.on_dismiss(move |state: &mut State| on_dismiss(state));
437        }
438        host
439    }
440}
441
442impl<State: 'static> View<State> for MenuView<State> {
443    type Element = AnchoredOverlayWidget;
444
445    fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
446        View::build(&self.host(), ctx)
447    }
448
449    fn rebuild(
450        &self,
451        prev: &Self,
452        element: &mut AnchoredOverlayWidget,
453        ctx: &mut BuildCtx<'_>,
454    ) -> ChangeFlags {
455        View::rebuild(&self.host(), &prev.host(), element, ctx)
456    }
457
458    fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
459        View::teardown(&self.host(), element, ctx);
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use frust::authoring::{
467        Color, InputEvent, PaintScene, Point, PointerButton, PointerEvent, PointerPhase, Rect,
468        Size, text::TextContext,
469    };
470    use frust::{Brightness, FrameTime};
471    use frust_core::RenderRoot;
472    use std::any::Any;
473
474    #[test]
475    fn standard_resolves_the_reference_s_surface_roles() {
476        let theme = crate::baseline().with_brightness(Brightness::Light);
477        let s = theme.scheme();
478        let colors = MenuColors::resolve(Some(&theme), MenuColorStyle::Standard);
479        assert_eq!(colors.container, s.surface_container_low);
480        assert_eq!(colors.content, s.on_surface);
481        assert_eq!(colors.icon_content, s.on_surface_variant);
482        assert_eq!(colors.supporting_content, s.on_surface_variant);
483        assert_eq!(colors.selected_container, s.tertiary_container);
484        assert_eq!(colors.selected_content, s.on_tertiary_container);
485        assert_eq!(colors.state_layer, s.on_surface);
486        assert_eq!(colors.divider, s.outline_variant);
487    }
488
489    #[test]
490    fn vibrant_promotes_the_whole_surface_to_the_tertiary_family() {
491        let theme = crate::baseline().with_brightness(Brightness::Light);
492        let s = theme.scheme();
493        let colors = MenuColors::resolve(Some(&theme), MenuColorStyle::Vibrant);
494        assert_eq!(colors.container, s.tertiary_container);
495        assert_eq!(colors.content, s.on_tertiary_container);
496        assert_eq!(colors.icon_content, s.on_tertiary_container);
497        assert_eq!(colors.selected_container, s.tertiary);
498        assert_eq!(colors.selected_content, s.on_tertiary);
499        assert_eq!(colors.state_layer, s.on_tertiary_container);
500        assert_eq!(
501            colors.divider,
502            with_alpha(s.on_tertiary_container, VIBRANT_DIVIDER_ALPHA)
503        );
504    }
505
506    #[test]
507    fn the_two_styles_never_resolve_to_the_same_surface() {
508        let theme = crate::baseline();
509        let standard = MenuColors::resolve(Some(&theme), MenuColorStyle::Standard);
510        let vibrant = MenuColors::resolve(Some(&theme), MenuColorStyle::Vibrant);
511        assert_ne!(standard.container, vibrant.container);
512        assert_ne!(standard.content, vibrant.content);
513        assert_eq!(MenuColorStyle::default(), MenuColorStyle::Standard);
514    }
515
516    #[test]
517    fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
518        let light = crate::baseline().with_brightness(Brightness::Light);
519        for style in [MenuColorStyle::Standard, MenuColorStyle::Vibrant] {
520            assert_eq!(
521                MenuColors::resolve(None, style),
522                MenuColors::resolve(Some(&light), style),
523                "{style:?}"
524            );
525        }
526    }
527
528    #[test]
529    fn the_colors_follow_a_live_brightness_flip() {
530        let light = crate::baseline().with_brightness(Brightness::Light);
531        let dark = crate::baseline().with_brightness(Brightness::Dark);
532        assert_ne!(
533            MenuColors::resolve(Some(&light), MenuColorStyle::Standard).container,
534            MenuColors::resolve(Some(&dark), MenuColorStyle::Standard).container
535        );
536    }
537
538    #[test]
539    fn the_foreground_ladder_is_disabled_then_destructive_then_selected() {
540        let colors = MenuColors::resolve(None, MenuColorStyle::Standard);
541        assert_eq!(
542            colors.entry_foreground(false, false, false),
543            with_alpha(colors.content, DISABLED_CONTENT_OPACITY),
544            "disabled outranks everything"
545        );
546        assert_eq!(
547            colors.entry_foreground(false, true, true),
548            with_alpha(colors.content, DISABLED_CONTENT_OPACITY)
549        );
550        assert_eq!(
551            colors.entry_foreground(true, true, true),
552            FALLBACK_ERROR,
553            "destructive outranks selected"
554        );
555        assert_eq!(
556            colors.entry_foreground(true, false, true),
557            colors.selected_content
558        );
559        assert_eq!(colors.entry_foreground(true, false, false), colors.content);
560
561        // Icons take the same ladder over their own idle role.
562        assert_eq!(
563            colors.icon_foreground(true, false, false),
564            colors.icon_content
565        );
566        assert_eq!(
567            colors.icon_foreground(true, false, true),
568            colors.selected_content
569        );
570        assert_eq!(colors.icon_foreground(true, true, false), FALLBACK_ERROR);
571
572        // Supporting text dims `content`, not `supporting_content` — the
573        // reference's own asymmetry.
574        assert_eq!(
575            colors.supporting_foreground(false, false),
576            with_alpha(colors.content, DISABLED_CONTENT_OPACITY)
577        );
578        assert_eq!(
579            colors.supporting_foreground(true, false),
580            colors.supporting_content
581        );
582        assert_eq!(
583            colors.supporting_foreground(true, true),
584            colors.selected_content
585        );
586    }
587
588    #[test]
589    fn a_non_material_theme_still_resolves_every_role() {
590        // An app is free to thread a bare theme; nothing here may panic.
591        let bare = Theme::neutral();
592        let _ = MenuColors::resolve(Some(&bare), MenuColorStyle::Vibrant);
593    }
594
595    // ---- the anchored menu -----------------------------------------------
596
597    const WINDOW: Size = Size::new(400.0, 600.0);
598    const ANCHOR: Rect = Rect::new(40.0, 100.0, 160.0, 140.0);
599
600    /// What the mounted menu reported.
601    #[derive(Default)]
602    struct AppState {
603        dismissed: u32,
604        selections: Vec<MenuSelection>,
605    }
606
607    #[derive(Default)]
608    struct Recorder {
609        rrects: Vec<(Point, Size)>,
610    }
611
612    impl PaintScene for Recorder {
613        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
614        fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, _color: Color) {
615            self.rrects.push((origin, size));
616        }
617        fn draw_text(&mut self, _origin: Point, _text: &str) {}
618    }
619
620    /// A menu mounted the documented way: the top child of a full-area
621    /// [`frust::Stack`], with its own anchor cell already captured.
622    struct Harness {
623        root: RenderRoot<AppState, frust::StackView<AppState>>,
624        state: AppState,
625        tcx: TextContext,
626        anchor: OverlayAnchor,
627    }
628
629    impl Harness {
630        fn new(nodes: Vec<MenuNode>) -> Self {
631            let anchor = OverlayAnchor::new();
632            anchor.set(ANCHOR);
633            let mut h = Harness {
634                root: RenderRoot::new(),
635                state: AppState::default(),
636                tcx: TextContext::new(),
637                anchor,
638            };
639            h.pass(nodes);
640            h
641        }
642
643        fn pass(&mut self, nodes: Vec<MenuNode>) {
644            let anchor = self.anchor.clone();
645            let mut logic = move |_s: &mut AppState| {
646                frust::stack().child(
647                    menu(nodes.clone(), |s: &mut AppState, selection| {
648                        s.selections.push(selection)
649                    })
650                    .anchor(&anchor)
651                    .on_dismiss(|s: &mut AppState| s.dismissed += 1),
652                )
653            };
654            self.root.rebuild(&mut logic, &mut self.state);
655            self.root
656                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
657        }
658
659        fn paint(&mut self) -> Recorder {
660            let mut rec = Recorder::default();
661            self.root.paint(&mut rec, FrameTime::ZERO);
662            rec
663        }
664
665        fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
666            self.root.event(
667                &mut self.state,
668                &InputEvent::Pointer(PointerEvent {
669                    phase,
670                    position: Point::new(x, y),
671                    button: PointerButton::Primary,
672                }),
673            );
674        }
675
676        fn click(&mut self, x: f64, y: f64) {
677            self.pointer(PointerPhase::Down, x, y);
678            self.pointer(PointerPhase::Up, x, y);
679        }
680    }
681
682    /// The window-space centre of the menu's first row, given the placement
683    /// above: below the anchor, leading edges flush, at the anchor gap.
684    fn first_row_center() -> Point {
685        Point::new(
686            ANCHOR.x0 + MENU_MIN_WIDTH / 2.0,
687            ANCHOR.y1
688                + OVERLAY_ANCHOR_GAP
689                + MENU_SURFACE_V_PADDING
690                + (MENU_ITEM_GAP + MENU_ROW_MIN_HEIGHT) / 2.0,
691        )
692    }
693
694    #[test]
695    fn the_menu_places_its_panel_under_the_trigger_at_the_anchor_gap() {
696        let mut h = Harness::new(vec![menu_entry("Cut").into()]);
697        let rec = h.paint();
698        let surface = rec.rrects.first().expect("the menu surface");
699        assert_eq!(
700            surface.0,
701            Point::new(ANCHOR.x0, ANCHOR.y1 + OVERLAY_ANCHOR_GAP),
702            "the reference's `bottomStart`, at `anchorOffset`"
703        );
704    }
705
706    #[test]
707    fn a_press_on_a_row_reports_through_the_host_and_never_dismisses() {
708        let mut h = Harness::new(vec![menu_entry("Cut").into()]);
709        let c = first_row_center();
710        h.click(c.x, c.y);
711        assert_eq!(h.state.selections.len(), 1);
712        assert_eq!(h.state.selections[0].label, "Cut");
713        assert_eq!(
714            h.state.dismissed, 0,
715            "a row press is the panel's, not a dismiss"
716        );
717    }
718
719    #[test]
720    fn a_press_outside_the_panel_light_dismisses_the_whole_menu() {
721        let mut h = Harness::new(vec![
722            menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
723        ]);
724        // Open the cascade first, so the dismissal has a chain to take.
725        let c = first_row_center();
726        h.click(c.x, c.y);
727        assert!(h.state.selections.is_empty(), "opening is not selecting");
728
729        h.pointer(PointerPhase::Down, 5.0, 5.0);
730        assert_eq!(h.state.dismissed, 1);
731        assert!(
732            h.state.selections.is_empty(),
733            "the dismissing press never reached a row"
734        );
735    }
736
737    #[test]
738    fn a_closed_kept_mounted_menu_paints_nothing_once_the_host_settles() {
739        let anchor = OverlayAnchor::new();
740        anchor.set(ANCHOR);
741        let mut root: RenderRoot<AppState, MenuView<AppState>> = RenderRoot::new();
742        let mut state = AppState::default();
743        let mut tcx = TextContext::new();
744        let cell = anchor.clone();
745        let mut closed = move |_s: &mut AppState| {
746            menu(vec![menu_entry("Cut").into()], |_s: &mut AppState, _| {})
747                .anchor(&cell)
748                .open(false)
749        };
750        root.rebuild(&mut closed, &mut state);
751        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
752        let mut rec = Recorder::default();
753        root.paint(&mut rec, FrameTime::ZERO);
754        assert!(
755            rec.rrects.is_empty(),
756            "a host mounted closed shows no panel at all"
757        );
758    }
759
760    #[test]
761    fn the_metrics_are_the_reference_s_menu_theme_defaults() {
762        assert_eq!(MENU_MIN_WIDTH, 112.0);
763        assert_eq!(MENU_MAX_WIDTH, 280.0);
764        assert_eq!(MENU_MAX_HEIGHT, 320.0);
765        assert_eq!(MENU_ROW_MIN_HEIGHT, 48.0);
766        assert_eq!(MENU_ICON_SIZE, 24.0);
767        assert_eq!(MENU_SURFACE_V_PADDING, 8.0);
768        assert_eq!(MENU_SURFACE_H_PADDING, 8.0);
769        assert_eq!(MENU_SECTION_GAP, 8.0);
770        assert_eq!(MENU_ROW_H_PADDING, 12.0);
771        assert_eq!(MENU_ICON_GAP, 12.0);
772        assert_eq!(MENU_ITEM_GAP, 4.0);
773        assert_eq!(MENU_GROUP_LABEL_H_PADDING, 12.0);
774        assert_eq!(MENU_GROUP_LABEL_V_PADDING, 8.0);
775        assert_eq!(MENU_CONTAINER_RADIUS, 16.0);
776        assert_eq!(MENU_ITEM_RADIUS, 12.0);
777        assert_eq!(MENU_SUBMENU_GAP, 4.0);
778        assert_eq!(SELECTED_CHECK_SCALE, 0.9);
779        // The reference's own 0.38, which this crate already names.
780        assert_eq!(DISABLED_CONTENT_OPACITY, 0.38);
781    }
782}