Skip to main content

frust_material/
fab_menu.rs

1//! The Material 3 Expressive **FAB menu**: a trigger FAB that reveals a
2//! vertical stack of large menu items on tap, replacing the older speed-dial
3//! pattern (m3.material.io/blog/building-with-m3-expressive; Compose
4//! `FloatingActionButtonMenu`).
5//!
6//! [`fab_menu`] takes the trigger's icon, the controlled `open` flag, and a
7//! list of [`FabMenuItem`]s (built with [`fab_menu_item`]: icon + label +
8//! `on_select`). It is a **controlled component** (see `docs/CODE_STANDARDS.md`'s
9//! Interaction Semantics), mirroring [`mod@super::split_button`]'s `open` prop: the
10//! widget never flips `open` itself. A single `on_toggle` callback fires on
11//! every gesture that requests an open/close flip — a tap on the closed
12//! trigger, a tap on the open trigger ("tap-again"), a completed tap on a menu
13//! item (after that item's own `on_select` runs), and a tap on the scrim
14//! (anywhere else in the widget's area while open) — and the app is expected
15//! to toggle its own `open` state in that handler, exactly like
16//! [`mod@super::split_button`]'s `on_open` convention. Passing a different
17//! `icon` view on that rebuild (e.g. a close glyph while `open`) is how an app
18//! reaches upstream's `M3EFabMenu.closeIcon`/`expandIcon`/`collapseIcon`
19//! quartet — this module takes one `icon` slot and lets the caller's own
20//! `open`-conditional branch decide its content, rather than adding four
21//! icon props of its own.
22//!
23//! # Attribution
24//!
25//! Ported from `paadevelopments/material_3_expressive` v1.0.8 (MIT, © 2026 Paa
26//! Developments), `lib/components/fab_menu/` (`m3e_fab_menu.dart`,
27//! `enums/m3e_fab_menu_position.dart`, `models/m3e_fab_menu_item.dart`,
28//! `styles/m3e_fab_menu_theme.dart`). See `plugins/material/NOTICE`'s Module
29//! Attribution Header Convention.
30//!
31//! # Reveal geometry
32//!
33//! The trigger sits `EDGE_MARGIN` from the widget's own bottom-right corner
34//! (this widget fills its box constraints, like [`super::sheet`], so it is
35//! meant to be the top layer of a full-area [`frust::Stack`]) — a v1
36//! divergence from upstream, whose `M3EFabMenu` self-sizes and is externally
37//! positioned by the caller (e.g. `Scaffold.floatingActionButton`); this
38//! module inherited the fills-its-box shape from the pre-rework v1 and keeps
39//! it, since retrofitting external caller-positioning is out of this task's
40//! scope. Items stack upward from the trigger, right-aligned to its trailing
41//! edge, closest item first (index `0` sits nearest the trigger) — the
42//! *reverse* of upstream's own item-list order (upstream's `Column` lays its
43//! `items` top-down with the *last* list entry landing nearest the FAB,
44//! `m3e_fab_menu.dart:317-336`); see the Cascade stagger section below for
45//! how this reindexing keeps "nearest fires first" true under either
46//! convention. [`FAB_TO_ITEMS_GAP`]/[`ITEM_GAP`]/[`ITEM_HEIGHT`]/[`ITEM_ICON`]/
47//! [`ITEM_PAD_X`]/[`ITEM_ICON_LABEL_GAP`] are `M3EFabMenuTheme`'s own default
48//! geometry tokens (`menuOffset`/`itemGap`/`itemHeight`/`iconSize`/
49//! `itemHorizontalPadding`/`iconLabelGap`, `m3e_fab_menu_theme.dart:10-16`),
50//! transcribed exactly rather than the pre-rework module's approximations.
51//!
52//! # Trigger morph (shape + size)
53//!
54//! On open, the trigger shrinks from [`MAIN_CONTAINER_CLOSED`] (80dp) to
55//! [`MAIN_CONTAINER_OPEN`] (56dp) while its outline morphs
56//! [`TRIGGER_CLOSED_SHAPE`] (a rounded square) into [`TRIGGER_OPEN_SHAPE`] (a
57//! circle, doubling as the visual "close" affordance) — upstream's own class
58//! doc: "the FAB shrinks (80→56) and morphs rounded-square ↔ circle"
59//! (`m3e_fab_menu.dart:19-24`). Both endpoints are named catalog shapes
60//! ([`crate::shapes::ShapeKind::Square`]/[`crate::shapes::ShapeKind::Circle`]), morphed
61//! through this crate's [`crate::shapes::Morph`] feature-point engine — the same
62//! engine [`mod@super::loading_indicator`] consumes, and the reason
63//! [`mod@super::fab`]'s `corner_radius` override seam exists at all
64//! (`fab.rs`'s own module doc names this morph as the override's intended
65//! consumer). **This module does not call into that seam.** `fab.rs`'s
66//! `corner_radius` is a single scalar override on its
67//! generic `FabWidget`, which sizes its icon against its own internal
68//! [`crate::FabSize`] tier rather than the box it is laid out at —
69//! [`mod@super::toolbar`]'s own fab-seam-degrade note documents the resulting
70//! off-center icon at a non-native container size, the exact failure mode a
71//! trigger that shrinks from 80dp down to a 56dp-native tier would hit.
72//! Composing `fab()` would also mean routing pointer input through a second,
73//! independent `Widget::event` (its own press/haptics/on_press machinery)
74//! underneath this module's own hand-rolled trigger/item/scrim arbitration —
75//! a second interaction surface this module does not want. Hand-rolling the
76//! trigger's paint instead means every geometry value (container size, icon
77//! size, icon origin) is computed from the *actual* current box every frame,
78//! so there is no seam to degrade: [`icon_size_for`] scales the icon
79//! proportionally with the container (24dp at the 56dp open end, ~34dp at the
80//! 80dp closed end — upstream's own `FittedBox` uniformly rescales the whole
81//! rendered FAB, icon included, `m3e_fab_menu.dart:257-279`), and layout
82//! centers it against whatever box that frame actually laid out.
83//!
84//! The morph is a **layout** value (the container square itself changes
85//! size), not merely a paint value — an in-flight frame calls
86//! [`frust::authoring::PaintCtx::request_layout`], the same layout-skip
87//! discipline [`mod@super::toolbar`]'s own FAB-morph section documents at
88//! length. `Theme::motion.reduce_motion` snaps the morph straight to its
89//! resting shape instead of animating through it.
90//!
91//! The morph spring rings around its closed target the way an item's does
92//! (see the Cascade stagger section's retreat clamp below) but is left
93//! **unclamped in both directions**: at [`FAB_SHAPE_SPRING`]'s damping its
94//! first rebound past zero peaks near `+0.0021`, worth ~0.05px of container
95//! size — nothing to see, and this value drives an opaque container's *size*
96//! rather than an opacity, so it has no bare-content failure mode to begin
97//! with. The measurement is pinned by a test rather than assumed.
98//!
99//! # Cascade stagger
100//!
101//! Each item reveals with its own spring ([`ITEM_SPRING`] — upstream's
102//! `_expandMotion`,
103//! `MaterialSpringMotion.expressiveSpatialDefault().copyWith(damping: 0.55)`,
104//! `m3e_fab_menu.dart:84-88` — exactly this crate's own
105//! [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`] token), each
106//! delayed by [`cascade_delay`] before it launches — upstream's
107//! `_expandStaggerMs` (30ms, `m3e_fab_menu.dart:96`) times each item's
108//! distance from the FAB (`fromFab = count - 1 - i`,
109//! `m3e_fab_menu.dart:192-194`). Reindexed for this module's own item-order
110//! convention (index `0` nearest the FAB, the *opposite* of upstream's list
111//! order — see the Reveal geometry section above), "nearest fires first"
112//! falls straight out of the index itself: item `0` launches immediately,
113//! item `n-1` launches `(n-1) * 30ms` later. **Closing reverses the
114//! cascade** — the item that revealed last retreats first — a v1 addition,
115//! not upstream's own behavior: upstream hides every item *instantly*
116//! (`_close`, `m3e_fab_menu.dart:210-226`, snapping every controller to `0`
117//! with no animation at all), a simplification of its `OverlayPortal.hide()`
118//! unmounting the item subtree outright rather than a deliberate design
119//! choice this port preserves. [`FabMenuWidget::advance_cascade`] drives the
120//! whole timeline off one shared clock (`cascade_epoch`, seeded from the
121//! first `paint` after the cascade starts — a `rebuild` has no frame clock
122//! to seed with, the same idiom [`mod@super::loading_indicator`]'s cycle
123//! timer uses) rather than real timers, since a retained `Widget` has none to
124//! spawn. `Theme::motion.reduce_motion` snaps every item straight to its
125//! resting opacity, skipping the stagger entirely.
126//!
127//! An item's reveal value is an **opacity**, and three rules keep a
128//! partly-revealed item from reading as a bare icon+label row with no pill
129//! behind it — the failure this component actually shipped:
130//!
131//! 1. **The item fades as one unit.** Container, icon, and label composite
132//!    through a single [`frust::authoring::PaintScene::push_layer`] at the
133//!    item's own progress, with the container painted at full alpha inside
134//!    it. Fading only the container leaves the two child pods painting
135//!    opaque at every progress, so a near-zero-progress item shows its
136//!    content over an invisible pill.
137//! 2. **A widget built at rest never flings** — [`View::build`] seeds *both*
138//!    directions (resting value + `cascade_launched`), not just the open one.
139//!    An unseeded closed mount lets the first `paint`'s
140//!    [`FabMenuWidget::advance_cascade`] fling every item from rest with the
141//!    retreat velocity, which dips below zero and rebounds back above it.
142//! 3. **The retreat clamps *and stops* at zero.** [`ITEM_SPRING`] is
143//!    under-damped, so a closing item's spring rings around its `0.0` target
144//!    instead of stopping at it; [`AnimationController::value_clamped`] hides
145//!    only the negative half of that ring, so each rebound re-enters `t > 0`
146//!    and flashes the item back on after the collapse has visually settled.
147//!    `advance_cascade` snaps a retreating spring to rest the first frame it
148//!    reaches zero — Flutter's controllers settle at a bound rather than
149//!    oscillating through it. Only the retreat: the reveal's overshoot past
150//!    `1.0` is real motion, and the clamped alpha read absorbs it.
151//!
152//! # Item colors ("contrasting… primary-container family")
153//!
154//! Each item's container cycles through the three M3 "container" roles —
155//! `primary_container`, `secondary_container`, `tertiary_container` (by
156//! `index % 3`) — so adjacent items read as visually distinct without a
157//! per-item color prop. Item labels use [`ThemeTextColor::OnSurface`]
158//! uniformly regardless of which container family the item sits on, the same
159//! choice [`mod@super::button_group`] documents (selection/emphasis is conveyed by
160//! the container fill, not the label color, to avoid adding a new
161//! `on_*_container` text role to `text.rs` — a file outside this module's
162//! scope). Every item's shape is a full pill (`ITEM_HEIGHT / 2` radius,
163//! [`item_radius`]) — upstream's item container is unconditionally a
164//! `StadiumBorder()` (`m3e_fab_menu.dart:382`), never a themed corner-radius
165//! token, so this is a fixed geometric fact rather than a `Theme` read.
166//! **Not ported**: item elevation (upstream's `itemElevation:
167//! M3EElevation.level3`, `m3e_fab_menu_theme.dart:17`) and outline/gradient
168//! fills/foregrounds (`itemOutlineColor`/`itemBackgroundGradient`/
169//! `itemForegroundGradient`, all unset by default) — a future addition, out
170//! of this task's scope (this crate's design-system plugins are
171//! `MaterialTokens`-only; no gradient theme extension exists here to carry
172//! one).
173//!
174//! # Scrim
175//!
176//! The dismiss barrier occupies the whole widget area whenever `open` — no
177//! animated fade, matching upstream's own non-animated
178//! `_buildDismissBarrier` (mounted/unmounted with the overlay portal,
179//! `m3e_fab_menu.dart:305-315`). Its fill color is [`SCRIM_ALPHA`] over
180//! `colors.scrim` — upstream's own `M3EFabMenuTheme.scrimOpacity` **defaults
181//! to `0.0`** (`m3e_fab_menu_theme.dart:11,80-81`), i.e. the barrier is
182//! invisible by default even though it still blocks/dismisses on tap; this
183//! is a deliberate divergence from the pre-rework v1's own 32% guess (which
184//! matched [`super::sheet`]/[`super::dialog`]'s scrim rather than this
185//! component's real upstream default), now transcribed faithfully.
186//!
187//! # Hit-testing while closed
188//!
189//! While `open` is `false`, only the trigger's own rect is interactive — a
190//! `Down` anywhere else in the widget's (full-area) bounds returns
191//! [`EventResult::Ignored`], which `Stack::event`'s reverse-order routing
192//! (`frust::authoring::route_event`) then hands to whatever sits below in the Z-order.
193//! Once `open` is `true`, every other point in the area is treated as the
194//! scrim (dismiss-on-tap) — see `Target::Scrim`.
195//!
196//! # No `StateLayer` (v1)
197//!
198//! Unlike [`mod@super::fab`], this widget paints no hover/press `StateLayer`
199//! overlay on the trigger or the items — a future addition can mirror
200//! `fab.rs`'s.
201//!
202//! # Keyboard operability
203//!
204//! **Escape-to-dismiss now works, once the menu has focus.** A `Down` on the
205//! trigger, or (while `open`) on an item or the scrim, claims focus via
206//! `EventCtx::request_focus` — the same opt-in
207//! [`mod@crate::dialog`]/[`crate::sheet`]/
208//! `frust_cupertino::alert_dialog`/`frust_cupertino::action_sheet` (the
209//! four modal widgets `docs/ARCHITECTURE.md`'s Semantics section names) use;
210//! this widget now joins that set. Once focused, a focus-routed
211//! `Key(Escape)` fires the same `on_toggle` callback a scrim/item/trigger tap
212//! would, but only while `open` — a closed menu has nothing to dismiss, so
213//! Escape is ignored outright regardless of focus. As with the other four
214//! modal widgets, there is still no hook to auto-focus the menu on open; a
215//! caller must complete one pointer interaction with the open menu before
216//! Escape does anything.
217
218use std::sync::OnceLock;
219use std::time::Duration;
220
221use frust::authoring::{Action, Role};
222use frust::authoring::{
223    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
224    Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
225};
226use frust::{AnimationController, FrameTime, SpringDesc, Theme};
227use kurbo::{Affine, BezPath, Point, Rect, Size};
228use peniko::{Brush, Color};
229
230use frust::authoring::{ThemeTextColor, ThemeTextType};
231
232use super::press::presses;
233use crate::shapes::{Morph, ShapeKind};
234use crate::tokens::MaterialSpring;
235
236/// Trigger container size when the menu is closed, in logical px — upstream's
237/// `M3EFabMenuTheme.closedFabContainer` default (`m3e_fab_menu_theme.dart:18,58`).
238const MAIN_CONTAINER_CLOSED: f64 = 80.0;
239/// Trigger container size when the menu is open, in logical px — upstream's
240/// `M3EFabMenuTheme.openFabContainer` default (`m3e_fab_menu_theme.dart:19,61`),
241/// matching [`super::fab`]'s medium-tier `MEDIUM_CONTAINER`.
242const MAIN_CONTAINER_OPEN: f64 = 56.0;
243/// Icon size inside the trigger FAB at the fully-*open* end of the morph, in
244/// logical px — matches [`super::fab`]'s `MEDIUM_ICON`. [`icon_size_for`]
245/// scales this up toward the closed end, proportionally with the container —
246/// see the [module docs](self)' Trigger morph section.
247const MAIN_ICON: f64 = 24.0;
248
249/// Distance from the host area's right/bottom edges to the trigger FAB, in
250/// logical px.
251///
252/// **Community-approximate**: a FAB's screen-edge margin is a common M3
253/// layout convention (16dp), not an independently-verified published value.
254const EDGE_MARGIN: f64 = 16.0;
255
256/// Fixed height of a menu-item row, in logical px — upstream's
257/// `M3EFabMenuTheme.itemHeight` default (`m3e_fab_menu_theme.dart:13,43`).
258const ITEM_HEIGHT: f64 = 56.0;
259/// Icon size inside a menu-item row, in logical px — upstream's
260/// `M3EFabMenuTheme.iconSize` default (`m3e_fab_menu_theme.dart:15,49`).
261const ITEM_ICON: f64 = 24.0;
262/// Horizontal padding inside a menu-item row, in logical px — upstream's
263/// `M3EFabMenuTheme.itemHorizontalPadding` default
264/// (`m3e_fab_menu_theme.dart:14,46`).
265const ITEM_PAD_X: f64 = 20.0;
266/// Gap between a menu-item's icon and its label, in logical px — upstream's
267/// `M3EFabMenuTheme.iconLabelGap` default (`m3e_fab_menu_theme.dart:16,51`).
268const ITEM_ICON_LABEL_GAP: f64 = 12.0;
269/// Vertical gap between two stacked menu items, in logical px — upstream's
270/// `M3EFabMenuTheme.itemGap` default (`m3e_fab_menu_theme.dart:12,40`).
271const ITEM_GAP: f64 = 12.0;
272/// Vertical gap between the trigger FAB and the nearest (index `0`) menu item,
273/// in logical px — upstream's `M3EFabMenuTheme.menuOffset` default
274/// (`m3e_fab_menu_theme.dart:10,31-34`).
275const FAB_TO_ITEMS_GAP: f64 = 12.0;
276
277/// Unthemed-fallback trigger-FAB container fill (a theme resolves
278/// `colors.primary_container`) — matches [`super::fab`]'s `CONTAINER`.
279const MAIN_CONTAINER_COLOR: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
280
281/// Unthemed-fallback item container fill, `index % 3 == 0` family (a theme
282/// resolves `colors.primary_container`) — matches `color_scheme_light`'s
283/// light default.
284const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
285/// Unthemed-fallback item container fill, `index % 3 == 1` family (a theme
286/// resolves `colors.secondary_container`).
287const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
288/// Unthemed-fallback item container fill, `index % 3 == 2` family (a theme
289/// resolves `colors.tertiary_container`).
290const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
291
292/// Scrim opacity behind the open menu — upstream's `M3EFabMenuTheme.
293/// scrimOpacity` default (`m3e_fab_menu_theme.dart:11,80-81`). See the
294/// [module docs](self)' Scrim section for why this is `0.0`.
295const SCRIM_ALPHA: f32 = 0.0;
296/// Unthemed-fallback scrim base color (a theme resolves `colors.scrim`).
297const SCRIM_FALLBACK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
298
299/// Unthemed-fallback shadow y-offset for the trigger FAB, matching
300/// `crate::tokens::elevation().level3`'s `y_offset` exactly — matches
301/// [`super::fab`]'s `FALLBACK_SHADOW_Y_OFFSET`.
302const FALLBACK_SHADOW_Y_OFFSET: f64 = 4.0;
303/// Unthemed-fallback shadow blur std-dev — matches [`super::fab`]'s
304/// `FALLBACK_SHADOW_BLUR`.
305const FALLBACK_SHADOW_BLUR: f64 = 6.0;
306/// Unthemed-fallback shadow color — matches [`super::fab`]'s
307/// `FALLBACK_SHADOW_COLOR`.
308const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
309
310/// The trigger's closed-state outline — see the [module docs](self)'
311/// Trigger morph section.
312const TRIGGER_CLOSED_SHAPE: ShapeKind = ShapeKind::Square;
313/// The trigger's open-state outline — see the [module docs](self)' Trigger
314/// morph section.
315const TRIGGER_OPEN_SHAPE: ShapeKind = ShapeKind::Circle;
316
317/// Closed-end corner-radius fraction of the container size, for the
318/// shadow's own scalar `radius` parameter only — [`PaintScene::draw_shadow`]
319/// takes a rounded-rect radius, not a path, so the true
320/// [`TRIGGER_CLOSED_SHAPE`]/[`TRIGGER_OPEN_SHAPE`] outline the fill paints
321/// has no exact shadow counterpart; this is a shadow-only stand-in
322/// (matching the pre-rework module's own `shape.large`-derived 16/56
323/// fraction). Shadows blur enough that the approximation is imperceptible.
324const SHADOW_RADIUS_FRACTION_CLOSED: f64 = 16.0 / 56.0;
325
326/// The trigger's shape/size morph spring — upstream's `_fabShapeMotion`:
327/// `MaterialSpringMotion.expressiveSpatialDefault().copyWith(damping: 0.7)`
328/// (`m3e_fab_menu.dart:90-94`) —
329/// [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`]'s stiffness
330/// (380) with the damping ratio overridden to `0.7`.
331const FAB_SHAPE_SPRING: SpringDesc = SpringDesc {
332    mass: 1.0,
333    stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
334    damping_ratio: 0.7,
335};
336
337/// The per-item cascade-reveal spring — upstream's `_expandMotion`:
338/// `MaterialSpringMotion.expressiveSpatialDefault().copyWith(damping: 0.55)`
339/// (`m3e_fab_menu.dart:84-88`), which is exactly this crate's own
340/// [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`] token.
341const ITEM_SPRING: SpringDesc = SpringDesc {
342    mass: 1.0,
343    stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.stiffness,
344    damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.damping_ratio,
345};
346
347/// Per-item cascade stagger step, in milliseconds — upstream's
348/// `_expandStaggerMs` (`m3e_fab_menu.dart:96`).
349const EXPAND_STAGGER_MS: u64 = 30;
350
351/// Nominal period seeding a spring-driven [`AnimationController`]; the
352/// motion is spring-driven via `fling`, so this only backs construction.
353const ANIM_PERIOD: Duration = Duration::from_millis(300);
354
355/// Launch velocity (value-units/sec) for every spring `fling` in this module
356/// (the trigger morph and each item's own cascade spring) — a named nudge
357/// rather than a literal `0`, the same convention [`super::fab`]'s
358/// `PRESS_SPRING`/[`super::button_group`]'s `SQUISH_SPRING` launches use.
359const FLING_VELOCITY: f64 = 3.0;
360
361/// Return `color` with its alpha channel replaced by `alpha`.
362fn with_alpha(color: Color, alpha: f32) -> Color {
363    let c = color.components;
364    Color::new([c[0], c[1], c[2], alpha])
365}
366
367/// Linear interpolation from `a` to `b` at `t` (unclamped — callers clamp at
368/// their own use site, matching [`frust::AnimationController::value`]'s own
369/// overshoot contract).
370fn lerp(a: f64, b: f64, t: f64) -> f64 {
371    a + (b - a) * t
372}
373
374/// Coerce a possibly-infinite constraint dimension to a finite value (this
375/// widget fills its area like [`super::sheet`] — a navigator page or a
376/// full-screen [`frust::Stack`] gives it bounded constraints).
377fn finite_or_zero(v: f64) -> f64 {
378    if v.is_finite() { v } else { 0.0 }
379}
380
381/// The trigger icon's size at morph progress `t`'s container size —
382/// proportional scaling with the container, upstream's own `FittedBox`
383/// uniformly rescaling the whole rendered FAB (icon included) — see the
384/// [module docs](self)' Trigger morph section.
385fn icon_size_for(container: f64) -> f64 {
386    MAIN_ICON * (container / MAIN_CONTAINER_OPEN)
387}
388
389/// The trigger's shadow radius at morph progress `t` — see
390/// [`SHADOW_RADIUS_FRACTION_CLOSED`]'s doc for why this is an approximation
391/// rather than the true morphed outline.
392fn trigger_shadow_radius(container: f64, t: f64) -> f64 {
393    let closed = container * SHADOW_RADIUS_FRACTION_CLOSED;
394    let open = container / 2.0;
395    lerp(closed, open, t.clamp(0.0, 1.0))
396}
397
398/// The trigger's closed↔open outline morph, built once and shared — see
399/// [`Morph`]'s own cost note.
400fn trigger_morph() -> &'static Morph {
401    static CACHE: OnceLock<Morph> = OnceLock::new();
402    CACHE.get_or_init(|| {
403        Morph::new(
404            TRIGGER_CLOSED_SHAPE.polygon().clone(),
405            TRIGGER_OPEN_SHAPE.polygon().clone(),
406        )
407    })
408}
409
410/// The trigger's outline at morph progress `t` (clamped to `0.0..=1.0` — see
411/// [`Morph::path`]'s overshoot caveat), scaled and positioned to exactly
412/// fill `rect` (local widget coordinates, e.g. [`FabMenuWidget::fab_rect`]).
413/// Every catalog shape's raw outline is already normalized into
414/// `(0,0)..(1,1)` (the same fact [`mod@super::loading_indicator`]'s Geometry
415/// section documents), so this is a plain non-uniform scale plus translate —
416/// no rotation, no re-centering.
417fn trigger_path(t: f64, rect: Rect) -> BezPath {
418    let raw = trigger_morph().path(t.clamp(0.0, 1.0));
419    let scale = Affine::new([rect.width(), 0.0, 0.0, rect.height(), 0.0, 0.0]);
420    Affine::translate(rect.origin().to_vec2()) * (scale * raw)
421}
422
423/// Item `i`'s cascade start delay for the current direction, given `count`
424/// total items — see the [module docs](self)' Cascade stagger section.
425fn cascade_delay(i: usize, count: usize, opening: bool) -> Duration {
426    let step = if opening {
427        i
428    } else {
429        count.saturating_sub(1).saturating_sub(i)
430    };
431    Duration::from_millis(step as u64 * EXPAND_STAGGER_MS)
432}
433
434/// Cancel any in-flight motion on `anim` and jump it straight to `open`'s
435/// resting value (`1.0`/`0.0`) — the `Theme.motion.reduce_motion` snap path,
436/// and the seed a widget built already-open uses to settle with no
437/// animate-in frame. `now` is only a clock-seed for
438/// [`AnimationController::advance`]'s contract; a zero-`Duration` motion
439/// resolves regardless of the delta it computes, so any value (including
440/// [`FrameTime::ZERO`]) is safe here. Returns whether this actually moved
441/// the value.
442fn snap_anim(anim: &mut AnimationController, open: bool, now: FrameTime) -> bool {
443    let target = if open { 1.0 } else { 0.0 };
444    let moved = anim.value() != target;
445    *anim = AnimationController::new(Duration::ZERO);
446    if open {
447        anim.forward();
448    } else {
449        anim.reverse();
450    }
451    anim.advance(now);
452    moved
453}
454
455/// The trigger-FAB container fill. Themed: `colors.primary_container`.
456/// Unthemed: [`MAIN_CONTAINER_COLOR`].
457fn resolve_main_color(theme: Option<&Theme>) -> Color {
458    match theme {
459        Some(theme) => theme.scheme().primary_container,
460        None => MAIN_CONTAINER_COLOR,
461    }
462}
463
464/// The resolved `(blur_std_dev, y_offset, color)` shadow parameters at M3
465/// elevation level 3 — identical resolution to [`super::fab`]'s.
466fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
467    match theme {
468        Some(theme) => {
469            let level = theme.elevation.level3;
470            let shadow = level.shadow(theme.brightness);
471            let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
472            (shadow.blur_std_dev, shadow.y_offset, color)
473        }
474        None => (
475            FALLBACK_SHADOW_BLUR,
476            FALLBACK_SHADOW_Y_OFFSET,
477            FALLBACK_SHADOW_COLOR,
478        ),
479    }
480}
481
482/// The resolved scrim fill ([`SCRIM_ALPHA`] over `colors.scrim`). Themed:
483/// `colors.scrim`.
484fn resolve_scrim_base(theme: Option<&Theme>) -> Color {
485    match theme {
486        Some(theme) => theme.scheme().scrim,
487        None => SCRIM_FALLBACK,
488    }
489}
490
491/// The `index`-th item's container fill, cycling `primary_container` →
492/// `secondary_container` → `tertiary_container` (`index % 3`). Themed: the
493/// matching `colors.*_container` role. Unthemed: [`PRIMARY_CONTAINER`]/
494/// [`SECONDARY_CONTAINER`]/[`TERTIARY_CONTAINER`].
495fn resolve_item_color(theme: Option<&Theme>, index: usize) -> Color {
496    match theme {
497        Some(theme) => {
498            let s = theme.scheme();
499            match index % 3 {
500                0 => s.primary_container,
501                1 => s.secondary_container,
502                _ => s.tertiary_container,
503            }
504        }
505        None => match index % 3 {
506            0 => PRIMARY_CONTAINER,
507            1 => SECONDARY_CONTAINER,
508            _ => TERTIARY_CONTAINER,
509        },
510    }
511}
512
513/// An item's corner radius — always a full pill (`ITEM_HEIGHT / 2`), never a
514/// themed token. See the [module docs](self)' Item colors section.
515fn item_radius() -> f64 {
516    ITEM_HEIGHT / 2.0
517}
518
519/// Build a menu item's visible label view, themed [`ThemeTextColor::OnSurface`]
520/// — see the [module docs](self) for why every item uses this role uniformly —
521/// its family following the live theme's `titleMedium` role (the M3 FAB menu
522/// item label token).
523fn label_view<State: 'static>(label: String) -> AnyView<State> {
524    frust::authoring::any::<State, _>(
525        frust::text(label)
526            .themed_role(ThemeTextColor::OnSurface)
527            .themed_family(ThemeTextType::TitleMedium),
528    )
529}
530
531/// Which interactive region an in-flight press targets.
532#[derive(Clone, Copy, Debug, PartialEq, Eq)]
533enum Target {
534    /// The trigger FAB.
535    Fab,
536    /// The `usize`-th menu item (only reachable while `open`).
537    Item(usize),
538    /// Anywhere else in the widget's area while `open` — the modal scrim.
539    Scrim,
540}
541
542/// One item's icon + label + selection callback. Built with [`fab_menu_item`].
543pub struct FabMenuItem<State: 'static> {
544    icon: AnyView<State>,
545    label: String,
546    on_select: std::rc::Rc<dyn Fn(&mut State)>,
547}
548
549/// Create a FAB-menu item: `icon` + `label` (both painted in the item's row —
550/// see the [module docs](self) for the item's fixed row anatomy), running
551/// `on_select` when tapped (followed by the menu's own `on_toggle`, requesting
552/// a close — see [`fab_menu`]'s docs).
553pub fn fab_menu_item<State: 'static, F: Fn(&mut State) + 'static>(
554    icon: AnyView<State>,
555    label: impl Into<String>,
556    on_select: F,
557) -> FabMenuItem<State> {
558    FabMenuItem {
559        icon,
560        label: label.into(),
561        on_select: std::rc::Rc::new(on_select),
562    }
563}
564
565/// A declarative M3X FAB menu. See the [module docs](self).
566pub struct FabMenuView<State: 'static> {
567    icon: AnyView<State>,
568    /// The controlled expanded state — see the [module docs](self).
569    open: bool,
570    items: Vec<FabMenuItem<State>>,
571    /// The accessible name for the trigger button (both open/closed states).
572    label: Option<String>,
573    on_toggle: std::rc::Rc<dyn Fn(&mut State)>,
574}
575
576/// Create a FAB menu with a `icon` trigger, the controlled `open` flag, and
577/// `items` (built with [`fab_menu_item`]). `on_toggle` fires with `&mut State`
578/// on every gesture requesting an open/close flip — see the
579/// [module docs](self) for the full list of such gestures, and chain
580/// [`FabMenuView::label`] to set the trigger's accessible name (defaults to
581/// `"Menu"`).
582pub fn fab_menu<State: 'static, F: Fn(&mut State) + 'static>(
583    icon: AnyView<State>,
584    open: bool,
585    items: Vec<FabMenuItem<State>>,
586    on_toggle: F,
587) -> FabMenuView<State> {
588    FabMenuView {
589        icon,
590        open,
591        items,
592        label: None,
593        on_toggle: std::rc::Rc::new(on_toggle),
594    }
595}
596
597/// PascalCase alias for [`fab_menu`], matching the container view-fn
598/// vocabulary.
599#[allow(non_snake_case)]
600pub fn FabMenu<State: 'static, F: Fn(&mut State) + 'static>(
601    icon: AnyView<State>,
602    open: bool,
603    items: Vec<FabMenuItem<State>>,
604    on_toggle: F,
605) -> FabMenuView<State> {
606    fab_menu(icon, open, items, on_toggle)
607}
608
609impl<State: 'static> FabMenuView<State> {
610    /// Set the trigger's accessible name (both open/closed states).
611    pub fn label(mut self, label: impl Into<String>) -> Self {
612        self.label = Some(label.into());
613        self
614    }
615}
616
617/// The retained per-item state: icon + label pods, plus the erased callback.
618struct FabMenuItemPod {
619    icon: ChildPod,
620    label: ChildPod,
621    on_select: frust::authoring::ErasedCallback,
622}
623
624impl frust::authoring::VisitPods for FabMenuItemPod {
625    fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
626        visitor(&self.icon);
627        visitor(&self.label);
628    }
629}
630
631/// The retained widget for a [`FabMenuView`]. See the [module docs](self).
632pub struct FabMenuWidget {
633    icon: ChildPod,
634    items: Vec<FabMenuItemPod>,
635    /// Retained item label strings, for rebuild diffing + semantics nodes.
636    item_labels: Vec<String>,
637    /// The controlled expanded state (never self-mutated).
638    open: bool,
639    /// The accessible trigger label, `"Menu"` when unset.
640    label: Option<String>,
641    /// The trigger's shape/size morph spring (`0.0` closed, `1.0` open) —
642    /// upstream's `_fabShapeCtrl`. Drives a *layout* value — see the
643    /// [module docs](self)' Trigger morph section.
644    fab_morph: AnimationController,
645    /// Each item's own cascade-reveal spring — upstream's `_itemCtrls`.
646    item_anim: Vec<AnimationController>,
647    /// Whether item `i`'s own fling has launched for the in-flight cascade —
648    /// see [`cascade_delay`]/[`Self::advance_cascade`].
649    cascade_launched: Vec<bool>,
650    /// Frame time the in-flight cascade began, seeded on the first `paint`
651    /// after [`Self::drive_reveal`] (a `rebuild` has no frame clock to seed
652    /// with) — `None` means "not yet seeded".
653    cascade_epoch: Option<FrameTime>,
654    /// The trigger's rect, in local space, from [`Widget::layout`].
655    fab_rect: Rect,
656    /// Each item's rect, in local space, index `0` nearest the trigger.
657    item_rects: Vec<Rect>,
658    /// The armed target of an in-flight press (`None` when idle).
659    armed: Option<Target>,
660    /// Whether the armed pointer is currently inside the armed target's rect
661    /// (always `true` for [`Target::Scrim`] — see [`Widget::event`]).
662    pressed_inside: bool,
663    on_toggle: frust::authoring::ErasedCallback,
664}
665
666impl FabMenuWidget {
667    /// (Re)launch the trigger morph toward the current `open` target and
668    /// (re)arm the item cascade — see the [module docs](self)' Cascade
669    /// stagger section. Each item's own spring continues from wherever it
670    /// currently sits (interrupting an in-flight cascade reverses smoothly
671    /// rather than snapping); only the stagger *timeline* (launch delays,
672    /// launched-flags, epoch) resets.
673    fn drive_reveal(&mut self) {
674        let velocity = if self.open {
675            FLING_VELOCITY
676        } else {
677            -FLING_VELOCITY
678        };
679        self.fab_morph.fling(velocity, FAB_SHAPE_SPRING);
680        self.cascade_launched = vec![false; self.item_anim.len()];
681        self.cascade_epoch = None;
682    }
683
684    /// Advance the cascade timeline to frame time `now`: launches any item
685    /// whose stagger delay has elapsed and hasn't fired yet (continuing from
686    /// its current value), then advances every item spring, stopping a
687    /// *retreating* one dead at `0.0` (see the [module docs](self)' Cascade
688    /// stagger section). Returns whether anything moved (paint's "needs
689    /// another frame" signal).
690    fn advance_cascade(&mut self, now: FrameTime) -> bool {
691        let epoch = *self.cascade_epoch.get_or_insert(now);
692        let elapsed = now.saturating_sub(epoch);
693        let n = self.item_anim.len();
694        let mut moved = false;
695        for i in 0..n {
696            if !self.cascade_launched[i] && elapsed >= cascade_delay(i, n, self.open) {
697                let velocity = if self.open {
698                    FLING_VELOCITY
699                } else {
700                    -FLING_VELOCITY
701                };
702                self.item_anim[i].fling(velocity, ITEM_SPRING);
703                self.cascade_launched[i] = true;
704            }
705            moved |= self.item_anim[i].advance(now);
706            // Retreat clamp: [`ITEM_SPRING`] is under-damped, so a closing
707            // item's spring rings *around* its `0.0` target rather than
708            // stopping at it. `value_clamped` hides only the negative half of
709            // that ring — every rebound re-enters `t > 0` and flashes the item
710            // back on. Stop it at the bound the first frame it gets there, the
711            // way Flutter's own controllers settle at a bound instead of
712            // oscillating through it. The reveal direction is deliberately not
713            // clamped: its overshoot past `1.0` is real motion the alpha read
714            // already absorbs.
715            if !self.open && self.item_anim[i].is_animating() && self.item_anim[i].value() <= 0.0 {
716                snap_anim(&mut self.item_anim[i], false, now);
717            }
718        }
719        moved
720    }
721}
722
723impl<State: 'static> View<State> for FabMenuView<State> {
724    type Element = FabMenuWidget;
725
726    fn build(&self, ctx: &mut BuildCtx<'_>) -> FabMenuWidget {
727        let items = self
728            .items
729            .iter()
730            .map(|item| FabMenuItemPod {
731                icon: frust::authoring::build_child(&item.icon, ctx),
732                label: frust::authoring::build_child(&label_view::<State>(item.label.clone()), ctx),
733                on_select: frust::authoring::erase_callback(&item.on_select),
734            })
735            .collect();
736
737        let n = self.items.len();
738        let mut fab_morph = AnimationController::new(ANIM_PERIOD);
739        let mut item_anim: Vec<AnimationController> = (0..n)
740            .map(|_| AnimationController::new(ANIM_PERIOD))
741            .collect();
742        // Seed the resting state in *both* directions (see the [`snap_anim`]
743        // doc): a menu built already-open shows its items without needing a
744        // frame to spring into them, and a menu built closed is marked
745        // fully-launched so it never flings at all — see the [module docs](self)'
746        // Cascade stagger section for why an unseeded closed mount blinks.
747        snap_anim(&mut fab_morph, self.open, FrameTime::ZERO);
748        for a in item_anim.iter_mut() {
749            snap_anim(a, self.open, FrameTime::ZERO);
750        }
751        let cascade_launched = vec![true; n];
752
753        FabMenuWidget {
754            icon: frust::authoring::build_child(&self.icon, ctx),
755            items,
756            item_labels: self.items.iter().map(|i| i.label.clone()).collect(),
757            open: self.open,
758            label: self.label.clone(),
759            fab_morph,
760            item_anim,
761            cascade_launched,
762            cascade_epoch: None,
763            fab_rect: Rect::ZERO,
764            item_rects: Vec::new(),
765            armed: None,
766            pressed_inside: false,
767            on_toggle: frust::authoring::erase_callback(&self.on_toggle),
768        }
769    }
770
771    fn rebuild(
772        &self,
773        prev: &Self,
774        element: &mut FabMenuWidget,
775        ctx: &mut BuildCtx<'_>,
776    ) -> ChangeFlags {
777        element.on_toggle = frust::authoring::erase_callback(&self.on_toggle);
778        let mut flags = ChangeFlags::NONE;
779
780        flags |= frust::authoring::rebuild_child(&prev.icon, &self.icon, &mut element.icon, ctx);
781
782        if prev.open != self.open {
783            element.open = self.open;
784            element.drive_reveal();
785            // A closed-triggered rebuild invalidates any in-flight item/scrim
786            // press — nothing left to release onto.
787            if !self.open {
788                element.armed = None;
789                element.pressed_inside = false;
790            }
791            flags |= ChangeFlags::PAINT;
792        }
793
794        if prev.label != self.label {
795            element.label = self.label.clone();
796        }
797
798        if prev.items.len() != self.items.len() {
799            // Structural change: tear down every old item pod, build fresh
800            // ones for the new list (mirrors `super::fab`'s icon/label
801            // teardown-then-rebuild path for a structural swap).
802            for (old_item, pod) in prev.items.iter().zip(element.items.iter_mut()) {
803                frust::authoring::teardown_child(&old_item.icon, &mut pod.icon, ctx);
804                let old_label_view = label_view::<State>(old_item.label.clone());
805                frust::authoring::teardown_child(&old_label_view, &mut pod.label, ctx);
806            }
807            element.items = self
808                .items
809                .iter()
810                .map(|item| FabMenuItemPod {
811                    icon: frust::authoring::build_child(&item.icon, ctx),
812                    label: frust::authoring::build_child(
813                        &label_view::<State>(item.label.clone()),
814                        ctx,
815                    ),
816                    on_select: frust::authoring::erase_callback(&item.on_select),
817                })
818                .collect();
819            element.item_labels = self.items.iter().map(|i| i.label.clone()).collect();
820            element.armed = None;
821            element.pressed_inside = false;
822            // New item count: fresh cascade state, matching upstream's own
823            // `didUpdateWidget` (`m3e_fab_menu.dart:123-136`) — every item
824            // settles instantly at the *current* open state, no animate-in.
825            let n = self.items.len();
826            element.item_anim = (0..n)
827                .map(|_| AnimationController::new(ANIM_PERIOD))
828                .collect();
829            element.cascade_epoch = None;
830            // Both directions, exactly like `build`'s own seed: a fresh item
831            // set mounted while *closed* must rest at zero rather than fling
832            // toward it.
833            for a in element.item_anim.iter_mut() {
834                snap_anim(a, element.open, FrameTime::ZERO);
835            }
836            element.cascade_launched = vec![true; n];
837            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
838        } else {
839            for (i, (prev_item, next_item)) in prev.items.iter().zip(self.items.iter()).enumerate()
840            {
841                let pod = &mut element.items[i];
842                flags |= frust::authoring::rebuild_child(
843                    &prev_item.icon,
844                    &next_item.icon,
845                    &mut pod.icon,
846                    ctx,
847                );
848                if prev_item.label != next_item.label {
849                    let prev_view = label_view::<State>(prev_item.label.clone());
850                    let next_view = label_view::<State>(next_item.label.clone());
851                    flags |= frust::authoring::rebuild_child(
852                        &prev_view,
853                        &next_view,
854                        &mut pod.label,
855                        ctx,
856                    );
857                    element.item_labels[i] = next_item.label.clone();
858                }
859                pod.on_select = frust::authoring::erase_callback(&next_item.on_select);
860            }
861        }
862
863        flags
864    }
865
866    fn teardown(&self, element: &mut FabMenuWidget, ctx: &mut BuildCtx<'_>) {
867        frust::authoring::teardown_child(&self.icon, &mut element.icon, ctx);
868        for (item, pod) in self.items.iter().zip(element.items.iter_mut()) {
869            frust::authoring::teardown_child(&item.icon, &mut pod.icon, ctx);
870            let label_view = label_view::<State>(item.label.clone());
871            frust::authoring::teardown_child(&label_view, &mut pod.label, ctx);
872        }
873    }
874}
875
876impl Widget for FabMenuWidget {
877    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
878        let area_w = finite_or_zero(bc.max().width);
879        let area_h = finite_or_zero(bc.max().height);
880
881        // Trigger FAB: bottom-right anchored, sized by the morph's current
882        // value — see the [module docs](self)' Trigger morph section.
883        let fab_t = self.fab_morph.value_clamped();
884        let container = lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, fab_t);
885        let icon_size = icon_size_for(container);
886
887        let icon_layout_size = self
888            .icon
889            .layout_child(ctx, &BoxConstraints::tight(Size::new(icon_size, icon_size)));
890        let fab_origin = Point::new(
891            (area_w - EDGE_MARGIN - container).max(0.0),
892            (area_h - EDGE_MARGIN - container).max(0.0),
893        );
894        self.fab_rect = Rect::from_origin_size(fab_origin, Size::new(container, container));
895        self.icon.set_origin(Point::new(
896            fab_origin.x + (container - icon_layout_size.width) / 2.0,
897            fab_origin.y + (container - icon_layout_size.height) / 2.0,
898        ));
899
900        // Items stack upward from the trigger, right-aligned to its trailing
901        // edge, index 0 nearest the trigger.
902        self.item_rects.clear();
903        let right_edge = self.fab_rect.x1;
904        let mut bottom = self.fab_rect.y0 - FAB_TO_ITEMS_GAP;
905        let radius = item_radius();
906        for pod in self.items.iter_mut() {
907            let icon_size = pod
908                .icon
909                .layout_child(ctx, &BoxConstraints::tight(Size::new(ITEM_ICON, ITEM_ICON)));
910            let label_size = pod.label.layout_child(
911                ctx,
912                &BoxConstraints::loose(Size::new(f64::INFINITY, ITEM_HEIGHT)),
913            );
914            let content_w = icon_size.width + ITEM_ICON_LABEL_GAP + label_size.width;
915            let item_w = (content_w + ITEM_PAD_X * 2.0).max(radius * 2.0);
916            let top = bottom - ITEM_HEIGHT;
917            let rect = Rect::new((right_edge - item_w).max(0.0), top, right_edge, bottom);
918
919            pod.icon.set_origin(Point::new(
920                rect.x0 + ITEM_PAD_X,
921                rect.y0 + (ITEM_HEIGHT - icon_size.height) / 2.0,
922            ));
923            pod.label.set_origin(Point::new(
924                rect.x0 + ITEM_PAD_X + icon_size.width + ITEM_ICON_LABEL_GAP,
925                rect.y0 + (ITEM_HEIGHT - label_size.height) / 2.0,
926            ));
927
928            self.item_rects.push(rect);
929            bottom = top - ITEM_GAP;
930        }
931
932        bc.constrain(Size::new(area_w, area_h))
933    }
934
935    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
936        // Resolve every theme-derived value up front — `theme` borrows `ctx`
937        // immutably, which would otherwise conflict with the mutable
938        // `pod.*.paint_child(ctx, ...)` calls and `ctx.request_layout()`
939        // below.
940        let theme = Theme::from_paint_ctx(ctx);
941        let scrim_base = resolve_scrim_base(theme);
942        let item_colors: Vec<Color> = (0..self.items.len())
943            .map(|i| resolve_item_color(theme, i))
944            .collect();
945        let main_color = resolve_main_color(theme);
946        let (blur, y_offset, shadow_color) = resolve_shadow(theme);
947        let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
948        let now = ctx.frame_time();
949
950        // The trigger morph drives a *layout* value (the container square),
951        // so an in-flight frame needs an explicit relayout, not merely
952        // another frame — the layout-skip class `mod@super::toolbar`'s own
953        // FAB-morph section documents. The settling frame both stops
954        // animating *and* lands on the target, so gate on "did the value
955        // move" rather than the animating flag alone.
956        let fab_moved = if reduce_motion {
957            snap_anim(&mut self.fab_morph, self.open, now)
958        } else {
959            let before = self.fab_morph.value();
960            let animating = self.fab_morph.advance(now);
961            animating || self.fab_morph.value() != before
962        };
963        if fab_moved {
964            ctx.request_layout();
965        }
966
967        // The item cascade is a paint-only value (opacity) — see the
968        // [module docs](self)' Cascade stagger section.
969        let n = self.item_anim.len();
970        if reduce_motion {
971            for a in self.item_anim.iter_mut() {
972                snap_anim(a, self.open, now);
973            }
974            self.cascade_launched = vec![true; n];
975        } else {
976            self.advance_cascade(now);
977        }
978
979        let origin = ctx.origin();
980
981        if self.open {
982            let scrim = with_alpha(scrim_base, SCRIM_ALPHA);
983            scene.fill_rect(origin, ctx.size(), scrim);
984        }
985
986        let item_radius = item_radius();
987        for (i, (pod, rect)) in self
988            .items
989            .iter_mut()
990            .zip(self.item_rects.iter())
991            .enumerate()
992        {
993            let t = self.item_anim[i].value_clamped();
994            if t <= 0.0 {
995                continue;
996            }
997            // The item fades as ONE unit — container, icon, and label
998            // composited through a single layer at the item's own progress,
999            // with the container itself painted at full alpha inside it.
1000            // Fading only the container (the pods paint opaque regardless)
1001            // is what made a low-`t` item read as a bare icon+label row with
1002            // no pill behind it. See the [module docs](self)' Cascade stagger
1003            // section.
1004            let item_origin = Point::new(origin.x + rect.x0, origin.y + rect.y0);
1005            scene.push_layer(item_origin, rect.size(), t as f32);
1006            scene.fill_rounded_rect(item_origin, rect.size(), item_radius, item_colors[i]);
1007            pod.icon.paint_child(ctx, scene);
1008            pod.label.paint_child(ctx, scene);
1009            scene.pop_layer();
1010        }
1011
1012        // Trigger FAB: always painted, on top of the scrim/items — the
1013        // shape morph itself, see the [module docs](self)' Trigger morph
1014        // section.
1015        let fab_t = self.fab_morph.value_clamped();
1016        let shadow_radius = trigger_shadow_radius(self.fab_rect.width(), fab_t);
1017        scene.draw_shadow(
1018            Point::new(
1019                origin.x + self.fab_rect.x0,
1020                origin.y + self.fab_rect.y0 + y_offset,
1021            ),
1022            self.fab_rect.size(),
1023            shadow_radius,
1024            blur,
1025            shadow_color,
1026        );
1027        let path = trigger_path(fab_t, self.fab_rect);
1028        scene.fill_path(origin, &path, &Brush::Solid(main_color));
1029        self.icon.paint_child(ctx, scene);
1030
1031        if !reduce_motion {
1032            let still_animating = self.fab_morph.is_animating()
1033                || self.item_anim.iter().any(AnimationController::is_animating)
1034                || self.cascade_launched.iter().any(|launched| !launched);
1035            if still_animating {
1036                ctx.request_frame();
1037            }
1038        }
1039    }
1040
1041    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1042        // Escape (once focused) requests a close through the same `on_toggle`
1043        // path a scrim/item/trigger tap uses — but only while open (see the
1044        // module docs' Keyboard operability note); a closed menu has nothing
1045        // to dismiss, so Escape is ignored outright regardless of focus.
1046        if let InputEvent::Key(key_event) = event {
1047            if self.open && key_event.key == Key::Named(NamedKey::Escape) {
1048                (self.on_toggle)(ctx);
1049                ctx.request_redraw();
1050                return EventResult::Handled;
1051            }
1052            return EventResult::Ignored;
1053        }
1054        let InputEvent::Pointer(p) = event else {
1055            return EventResult::Ignored;
1056        };
1057        match p.phase {
1058            PointerPhase::Down => {
1059                if self.fab_rect.contains(p.position) {
1060                    // Claim focus on every trigger press (see the module
1061                    // docs' Keyboard operability note). Load-bearing: the
1062                    // root treats a Down that bubbles no claim as a blur
1063                    // (`release_focus_session` drops focus + IME state), so
1064                    // the re-claim is what keeps the session alive while this
1065                    // menu is up. Re-claiming while already focused is a
1066                    // change-guarded no-op (no generation bump) — do not add
1067                    // a claim-once guard, it kills the session on the second
1068                    // tap (claim-once-hygiene review, 2026-08-06).
1069                    ctx.request_focus();
1070                    // Only a primary press arms the trigger. A secondary press
1071                    // is a context gesture: still swallowed while the menu is
1072                    // open (its scrim is a modal barrier), simply ignored while
1073                    // it is closed.
1074                    if !presses(p) {
1075                        return if self.open {
1076                            EventResult::Handled
1077                        } else {
1078                            EventResult::Ignored
1079                        };
1080                    }
1081                    self.armed = Some(Target::Fab);
1082                    self.pressed_inside = true;
1083                    ctx.capture_pointer();
1084                    ctx.request_redraw();
1085                    return EventResult::Handled;
1086                }
1087                if !self.open {
1088                    return EventResult::Ignored;
1089                }
1090                // Same opt-in while open: a press on an item or the scrim
1091                // also claims focus on every Down — see the trigger arm's
1092                // comment above for why.
1093                ctx.request_focus();
1094                // The open menu's scrim is a modal barrier: it swallows a
1095                // secondary press like any other, but arms neither an item nor
1096                // a scrim dismiss from it.
1097                if !presses(p) {
1098                    return EventResult::Handled;
1099                }
1100                if let Some(i) = self.item_rects.iter().position(|r| r.contains(p.position)) {
1101                    self.armed = Some(Target::Item(i));
1102                } else {
1103                    self.armed = Some(Target::Scrim);
1104                }
1105                self.pressed_inside = true;
1106                ctx.capture_pointer();
1107                ctx.request_redraw();
1108                EventResult::Handled
1109            }
1110            PointerPhase::Move => {
1111                let Some(target) = self.armed else {
1112                    return EventResult::Ignored;
1113                };
1114                self.pressed_inside = match target {
1115                    Target::Fab => self.fab_rect.contains(p.position),
1116                    Target::Item(i) => self
1117                        .item_rects
1118                        .get(i)
1119                        .is_some_and(|r| r.contains(p.position)),
1120                    // The scrim dismiss fires on any release, wherever the
1121                    // gesture ends up — see the module docs.
1122                    Target::Scrim => true,
1123                };
1124                ctx.request_redraw();
1125                EventResult::Handled
1126            }
1127            PointerPhase::Up => {
1128                let Some(target) = self.armed.take() else {
1129                    return EventResult::Ignored;
1130                };
1131                if self.pressed_inside {
1132                    match target {
1133                        Target::Fab => (self.on_toggle)(ctx),
1134                        Target::Item(i) => {
1135                            if let Some(pod) = self.items.get_mut(i) {
1136                                (pod.on_select)(ctx);
1137                            }
1138                            (self.on_toggle)(ctx);
1139                        }
1140                        Target::Scrim => (self.on_toggle)(ctx),
1141                    }
1142                }
1143                self.pressed_inside = false;
1144                ctx.request_redraw();
1145                EventResult::Handled
1146            }
1147            PointerPhase::Cancel => {
1148                if self.armed.is_none() {
1149                    return EventResult::Ignored;
1150                }
1151                self.armed = None;
1152                self.pressed_inside = false;
1153                ctx.request_redraw();
1154                EventResult::Handled
1155            }
1156        }
1157    }
1158
1159    fn semantics(&self, ctx: &mut SemanticsCtx) {
1160        let trigger_label = self.label.as_deref().unwrap_or("Menu");
1161        if self.open {
1162            ctx.push_container(
1163                Role::Group,
1164                |_| {},
1165                |ctx| {
1166                    ctx.push_node(Role::Button, |node| {
1167                        node.set_label(trigger_label);
1168                        node.set_expanded(true);
1169                        node.add_action(Action::Click);
1170                        node.add_action(Action::Expand);
1171                    });
1172                    ctx.push_container(
1173                        Role::Menu,
1174                        |_| {},
1175                        |ctx| {
1176                            for label in &self.item_labels {
1177                                ctx.push_node(Role::MenuItem, |node| {
1178                                    node.set_label(label.as_str());
1179                                    node.add_action(Action::Click);
1180                                });
1181                            }
1182                        },
1183                    );
1184                },
1185            );
1186        } else {
1187            ctx.push_node(Role::Button, |node| {
1188                node.set_label(trigger_label);
1189                node.set_expanded(false);
1190                node.add_action(Action::Click);
1191                node.add_action(Action::Expand);
1192            });
1193        }
1194    }
1195
1196    frust::authoring::visit_children!(items, icon);
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202    use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
1203    use frust_core::RenderRoot;
1204    use frust_widgets::test_support::leaf_any;
1205    use kurbo::{PathEl, Shape};
1206    use std::any::Any;
1207
1208    /// A minimal `State`-generic icon stand-in (mirrors `fab.rs`'s
1209    /// `icon_stub`: `test_support::leaf_any` only implements `View<()>`, but
1210    /// this module's tests need a generic `State` — a plain themed
1211    /// [`frust::text`] run stands in for the icon, its content
1212    /// irrelevant to firing behavior).
1213    fn icon_stub<State: 'static>() -> AnyView<State> {
1214        frust::authoring::any::<State, _>(frust::text("i"))
1215    }
1216
1217    #[derive(Default)]
1218    struct Log {
1219        toggles: u32,
1220        selected: Vec<usize>,
1221    }
1222
1223    fn item(i: usize) -> FabMenuItem<Log> {
1224        fab_menu_item(
1225            icon_stub::<Log>(),
1226            format!("Item {i}"),
1227            move |s: &mut Log| s.selected.push(i),
1228        )
1229    }
1230
1231    fn build_menu(open: bool, item_count: usize) -> FabMenuWidget {
1232        let view = fab_menu::<Log, _>(
1233            icon_stub::<Log>(),
1234            open,
1235            (0..item_count).map(item).collect(),
1236            |s: &mut Log| s.toggles += 1,
1237        );
1238        let mut counter = 0u64;
1239        View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
1240    }
1241
1242    /// Directly snap every spring to the fully-open resting state, bypassing
1243    /// `paint`'s own stagger/spring advance entirely — the deterministic
1244    /// route to an "everything settled open" fixture a real spring can only
1245    /// approach asymptotically. Mirrors [`snap_anim`]'s own reduce-motion
1246    /// path.
1247    fn open_fully(w: &mut FabMenuWidget) {
1248        snap_anim(&mut w.fab_morph, true, FrameTime::ZERO);
1249        for a in w.item_anim.iter_mut() {
1250            snap_anim(a, true, FrameTime::ZERO);
1251        }
1252        w.cascade_launched = vec![true; w.item_anim.len()];
1253    }
1254
1255    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1256        InputEvent::Pointer(PointerEvent {
1257            phase,
1258            position: Point::new(x, y),
1259            button: PointerButton::Primary,
1260        })
1261    }
1262
1263    fn dispatch(w: &mut FabMenuWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1264        let state_any: &mut dyn Any = state;
1265        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
1266        w.event(&mut ctx, event)
1267    }
1268
1269    fn laid_out(w: &mut FabMenuWidget) {
1270        let mut tcx = frust::authoring::text::TextContext::new();
1271        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1272        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
1273    }
1274
1275    // -----------------------------------------------------------------------
1276    // Trigger shape morph (acceptance: endpoints cited, mid-t sanity).
1277    // -----------------------------------------------------------------------
1278
1279    #[test]
1280    fn trigger_shape_endpoints_are_a_rounded_square_and_a_circle() {
1281        assert_eq!(TRIGGER_CLOSED_SHAPE, ShapeKind::Square);
1282        assert_eq!(TRIGGER_OPEN_SHAPE, ShapeKind::Circle);
1283        let morph = trigger_morph();
1284        assert_eq!(*morph.start(), *ShapeKind::Square.polygon());
1285        assert_eq!(*morph.end(), *ShapeKind::Circle.polygon());
1286    }
1287
1288    #[test]
1289    fn trigger_path_is_closed_at_every_progress_and_stays_near_its_box() {
1290        let rect = Rect::from_origin_size(Point::new(10.0, 20.0), Size::new(56.0, 56.0));
1291        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1292            let path = trigger_path(t, rect);
1293            assert!(
1294                matches!(path.elements().last(), Some(PathEl::ClosePath)),
1295                "t={t}"
1296            );
1297            let bounds = path.bounding_box();
1298            assert!(
1299                bounds.x0 >= rect.x0 - 1.0 && bounds.y0 >= rect.y0 - 1.0,
1300                "t={t} {bounds:?}"
1301            );
1302            assert!(
1303                bounds.x1 <= rect.x1 + 1.0 && bounds.y1 <= rect.y1 + 1.0,
1304                "t={t} {bounds:?}"
1305            );
1306        }
1307    }
1308
1309    #[test]
1310    fn fab_shape_spring_matches_upstream_fab_shape_motion() {
1311        // m3e_fab_menu.dart:90-94: expressiveSpatialDefault().copyWith(damping: 0.7).
1312        assert_eq!(
1313            FAB_SHAPE_SPRING.stiffness,
1314            MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness
1315        );
1316        assert_eq!(FAB_SHAPE_SPRING.damping_ratio, 0.7);
1317        assert_eq!(FAB_SHAPE_SPRING.mass, 1.0);
1318    }
1319
1320    #[test]
1321    fn trigger_container_lerps_between_the_named_endpoints() {
1322        assert_eq!(MAIN_CONTAINER_CLOSED, 80.0);
1323        assert_eq!(MAIN_CONTAINER_OPEN, 56.0);
1324        assert_eq!(lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, 0.0), 80.0);
1325        assert_eq!(lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, 1.0), 56.0);
1326        assert_eq!(icon_size_for(MAIN_CONTAINER_OPEN), MAIN_ICON);
1327        assert!(icon_size_for(MAIN_CONTAINER_CLOSED) > MAIN_ICON);
1328    }
1329
1330    #[test]
1331    fn trigger_morph_is_a_layout_value_and_requests_relayout_while_in_flight() {
1332        let mut w = build_menu(false, 0);
1333        laid_out(&mut w);
1334        let closed_size = w.fab_rect.width();
1335        assert_eq!(closed_size, MAIN_CONTAINER_CLOSED, "built closed: 80dp");
1336
1337        let prev = fab_menu::<Log, _>(icon_stub(), false, vec![], |_s: &mut Log| {});
1338        let next = fab_menu::<Log, _>(icon_stub(), true, vec![], |_s: &mut Log| {});
1339        let mut counter = 0u64;
1340        View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1341
1342        // The first `advance` after a fresh `fling` only seeds the clock
1343        // (zero delta — `AnimationController::advance`'s own documented
1344        // contract), so two paints at increasing frame times are needed
1345        // before the value actually moves — the same idiom `sheet.rs`'s own
1346        // `PaintCtx::for_test`-driven tests use.
1347        let area = Size::new(400.0, 600.0);
1348        let mut rec = RectRecorder::default();
1349        let mut pctx = PaintCtx::for_test(Point::ZERO, area, FrameTime::from_nanos(0));
1350        w.paint(&mut pctx, &mut rec);
1351        let mut pctx = PaintCtx::for_test(Point::ZERO, area, FrameTime::from_nanos(80_000_000));
1352        w.paint(&mut pctx, &mut rec);
1353        assert!(
1354            pctx.needs_layout(),
1355            "an in-flight morph must request a relayout, not merely a repaint"
1356        );
1357
1358        laid_out(&mut w);
1359        assert!(
1360            w.fab_rect.width() < closed_size,
1361            "relaying out after the spring moved shrinks the trigger toward 56dp"
1362        );
1363    }
1364
1365    // -----------------------------------------------------------------------
1366    // Cascade stagger (acceptance: per-item delays cited).
1367    // -----------------------------------------------------------------------
1368
1369    #[test]
1370    fn cascade_stagger_step_matches_upstream() {
1371        assert_eq!(EXPAND_STAGGER_MS, 30); // m3e_fab_menu.dart:96
1372    }
1373
1374    #[test]
1375    fn item_spring_matches_expressive_spatial_press() {
1376        // m3e_fab_menu.dart:84-88: expressiveSpatialDefault().copyWith(damping: 0.55) ==
1377        // MaterialSpring::EXPRESSIVE_SPATIAL_PRESS (380, 0.55).
1378        assert_eq!(ITEM_SPRING.stiffness, 380.0);
1379        assert_eq!(ITEM_SPRING.damping_ratio, 0.55);
1380        assert_eq!(
1381            ITEM_SPRING.stiffness,
1382            MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.stiffness
1383        );
1384        assert_eq!(
1385            ITEM_SPRING.damping_ratio,
1386            MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.damping_ratio
1387        );
1388    }
1389
1390    #[test]
1391    fn opening_cascade_fires_nearest_the_fab_first() {
1392        assert_eq!(cascade_delay(0, 4, true), Duration::ZERO);
1393        assert_eq!(cascade_delay(1, 4, true), Duration::from_millis(30));
1394        assert_eq!(cascade_delay(2, 4, true), Duration::from_millis(60));
1395        assert_eq!(cascade_delay(3, 4, true), Duration::from_millis(90));
1396    }
1397
1398    #[test]
1399    fn closing_cascade_reverses_the_stagger_order() {
1400        assert_eq!(cascade_delay(3, 4, false), Duration::ZERO);
1401        assert_eq!(cascade_delay(2, 4, false), Duration::from_millis(30));
1402        assert_eq!(cascade_delay(1, 4, false), Duration::from_millis(60));
1403        assert_eq!(cascade_delay(0, 4, false), Duration::from_millis(90));
1404    }
1405
1406    #[test]
1407    fn advance_cascade_launches_items_in_order_as_time_elapses() {
1408        let mut w = build_menu(false, 3);
1409        laid_out(&mut w);
1410        let prev = fab_menu::<Log, _>(
1411            icon_stub(),
1412            false,
1413            (0..3).map(item).collect(),
1414            |_s: &mut Log| {},
1415        );
1416        let next = fab_menu::<Log, _>(
1417            icon_stub(),
1418            true,
1419            (0..3).map(item).collect(),
1420            |_s: &mut Log| {},
1421        );
1422        let mut counter = 0u64;
1423        View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1424        assert!(w.cascade_launched.iter().all(|&l| !l));
1425
1426        w.advance_cascade(FrameTime::ZERO);
1427        assert!(
1428            w.cascade_launched[0],
1429            "index 0 (nearest the FAB) has a 0ms delay"
1430        );
1431        assert!(!w.cascade_launched[1]);
1432        assert!(!w.cascade_launched[2]);
1433
1434        w.advance_cascade(FrameTime::from_nanos(35_000_000)); // +35ms
1435        assert!(w.cascade_launched[1], "30ms delay elapsed");
1436        assert!(!w.cascade_launched[2], "60ms delay hasn't elapsed yet");
1437
1438        w.advance_cascade(FrameTime::from_nanos(65_000_000)); // +65ms total
1439        assert!(w.cascade_launched[2], "60ms delay elapsed");
1440    }
1441
1442    #[test]
1443    fn item_count_change_rebuilds_pods_and_clears_armed() {
1444        let mut w = build_menu(true, 1);
1445        let prev = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |_s: &mut Log| {});
1446        let next = fab_menu::<Log, _>(
1447            icon_stub::<Log>(),
1448            true,
1449            vec![item(0), item(1), item(2)],
1450            |_s: &mut Log| {},
1451        );
1452        let mut counter = 0u64;
1453        View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1454        assert_eq!(w.items.len(), 3);
1455        assert_eq!(w.item_labels, vec!["Item 0", "Item 1", "Item 2"]);
1456        assert_eq!(w.item_anim.len(), 3);
1457        assert_eq!(
1458            w.cascade_launched,
1459            vec![true, true, true],
1460            "already open: settles instantly"
1461        );
1462    }
1463
1464    // -----------------------------------------------------------------------
1465    // Controlled open + interaction (unchanged behavioral contract).
1466    // -----------------------------------------------------------------------
1467
1468    #[test]
1469    fn tapping_the_closed_trigger_requests_open() {
1470        let mut w = build_menu(false, 2);
1471        laid_out(&mut w);
1472        let mut state = Log::default();
1473        let fab_center = w.fab_rect.center();
1474        dispatch(
1475            &mut w,
1476            &mut state,
1477            &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1478        );
1479        assert_eq!(w.armed, Some(Target::Fab));
1480        dispatch(
1481            &mut w,
1482            &mut state,
1483            &ev(PointerPhase::Up, fab_center.x, fab_center.y),
1484        );
1485        assert_eq!(state.toggles, 1, "trigger tap requests an open/close flip");
1486        assert!(state.selected.is_empty());
1487    }
1488
1489    #[test]
1490    fn tapping_the_open_trigger_again_also_requests_a_flip() {
1491        let mut w = build_menu(true, 2);
1492        laid_out(&mut w);
1493        let mut state = Log::default();
1494        let fab_center = w.fab_rect.center();
1495        dispatch(
1496            &mut w,
1497            &mut state,
1498            &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1499        );
1500        dispatch(
1501            &mut w,
1502            &mut state,
1503            &ev(PointerPhase::Up, fab_center.x, fab_center.y),
1504        );
1505        assert_eq!(state.toggles, 1, "tap-again closes");
1506    }
1507
1508    #[test]
1509    fn item_tap_fires_its_own_callback_then_closes() {
1510        let mut w = build_menu(true, 2);
1511        laid_out(&mut w);
1512        assert_eq!(w.item_rects.len(), 2);
1513        let mut state = Log::default();
1514        let target = w.item_rects[0].center();
1515        dispatch(
1516            &mut w,
1517            &mut state,
1518            &ev(PointerPhase::Down, target.x, target.y),
1519        );
1520        assert_eq!(w.armed, Some(Target::Item(0)));
1521        dispatch(
1522            &mut w,
1523            &mut state,
1524            &ev(PointerPhase::Up, target.x, target.y),
1525        );
1526        assert_eq!(state.selected, vec![0], "the tapped item's on_select fired");
1527        assert_eq!(state.toggles, 1, "an item tap also requests a close");
1528    }
1529
1530    #[test]
1531    fn scrim_tap_closes_without_selecting_an_item() {
1532        let mut w = build_menu(true, 1);
1533        laid_out(&mut w);
1534        let mut state = Log::default();
1535        // The top-left corner of a 400x600 area is neither the trigger
1536        // (bottom-right) nor any item (stacked just above the trigger).
1537        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1538        assert_eq!(w.armed, Some(Target::Scrim));
1539        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1540        assert_eq!(state.toggles, 1, "a scrim tap requests a close");
1541        assert!(state.selected.is_empty());
1542    }
1543
1544    #[test]
1545    fn down_outside_the_trigger_is_ignored_while_closed() {
1546        let mut w = build_menu(false, 1);
1547        laid_out(&mut w);
1548        let mut state = Log::default();
1549        let result = dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1550        assert!(matches!(result, EventResult::Ignored));
1551        assert!(w.armed.is_none());
1552    }
1553
1554    #[test]
1555    fn cancel_disarms_without_firing() {
1556        let mut w = build_menu(true, 1);
1557        laid_out(&mut w);
1558        let mut state = Log::default();
1559        let target = w.item_rects[0].center();
1560        dispatch(
1561            &mut w,
1562            &mut state,
1563            &ev(PointerPhase::Down, target.x, target.y),
1564        );
1565        dispatch(
1566            &mut w,
1567            &mut state,
1568            &ev(PointerPhase::Cancel, target.x, target.y),
1569        );
1570        assert!(w.armed.is_none());
1571        dispatch(
1572            &mut w,
1573            &mut state,
1574            &ev(PointerPhase::Up, target.x, target.y),
1575        );
1576        assert_eq!(state.toggles, 0);
1577        assert!(state.selected.is_empty());
1578    }
1579
1580    #[test]
1581    fn release_outside_the_armed_item_fires_nothing() {
1582        let mut w = build_menu(true, 2);
1583        laid_out(&mut w);
1584        let mut state = Log::default();
1585        let item0 = w.item_rects[0].center();
1586        dispatch(
1587            &mut w,
1588            &mut state,
1589            &ev(PointerPhase::Down, item0.x, item0.y),
1590        );
1591        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 500.0));
1592        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 500.0));
1593        assert!(state.selected.is_empty());
1594        assert_eq!(state.toggles, 0);
1595    }
1596
1597    // --- Focus + Escape opt-in. ---
1598
1599    fn escape_event() -> InputEvent {
1600        InputEvent::Key(KeyEvent {
1601            key: Key::Named(NamedKey::Escape),
1602            modifiers: Modifiers::default(),
1603            repeat: false,
1604        })
1605    }
1606
1607    #[test]
1608    fn escape_while_open_requests_toggle() {
1609        let mut w = build_menu(true, 2);
1610        laid_out(&mut w);
1611        let mut state = Log::default();
1612        let result = dispatch(&mut w, &mut state, &escape_event());
1613        assert!(matches!(result, EventResult::Handled));
1614        assert_eq!(state.toggles, 1, "Escape while open requests a close");
1615        assert!(state.selected.is_empty());
1616    }
1617
1618    #[test]
1619    fn escape_while_closed_is_ignored() {
1620        let mut w = build_menu(false, 2);
1621        laid_out(&mut w);
1622        let mut state = Log::default();
1623        let result = dispatch(&mut w, &mut state, &escape_event());
1624        assert!(matches!(result, EventResult::Ignored));
1625        assert_eq!(state.toggles, 0, "a closed menu has nothing to dismiss");
1626    }
1627
1628    #[test]
1629    fn trigger_press_claims_focus_so_a_later_escape_reaches_the_open_menu() {
1630        // Drive a real RenderRoot so the container-level focus-path recording
1631        // (mirrors `cupertino::alert_dialog`'s modal-widget precedent) is
1632        // actually exercised, not just the widget's own `event` in isolation.
1633        let mut root: RenderRoot<Log, FabMenuView<Log>> = RenderRoot::new();
1634        let mut app = |_s: &mut Log| {
1635            fab_menu::<Log, _>(
1636                icon_stub::<Log>(),
1637                true,
1638                (0..2).map(item).collect(),
1639                |s: &mut Log| s.toggles += 1,
1640            )
1641        };
1642        let mut state = Log::default();
1643        root.rebuild(&mut app, &mut state);
1644        let mut tcx = frust::authoring::text::TextContext::new();
1645        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
1646
1647        // A press on the scrim (top-left corner, outside the trigger/items —
1648        // see `scrim_tap_closes_without_selecting_an_item`) claims focus. A
1649        // `Cancel` instead of `Up` disarms without firing `on_toggle`, so the
1650        // Escape below is the only thing that can have requested the close.
1651        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1652        root.event(&mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1653        assert_eq!(
1654            state.toggles, 0,
1655            "the cancelled scrim press alone never toggles"
1656        );
1657
1658        root.event(&mut state, &escape_event());
1659        assert_eq!(
1660            state.toggles, 1,
1661            "Escape reaches the now-focused, still-open menu and requests a close"
1662        );
1663    }
1664
1665    #[test]
1666    fn a_down_reclaims_trigger_focus_after_an_external_blur() {
1667        use frust::authoring::ChildPod;
1668
1669        let mut w = build_menu(false, 2);
1670        laid_out(&mut w);
1671        let fab_center = w.fab_rect.center();
1672        let mut pod = ChildPod::new(Box::new(w));
1673        let mut tcx = frust::authoring::text::TextContext::new();
1674        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1675        let area = Size::new(400.0, 600.0);
1676        // Re-run layout through the pod (idempotent — same geometry) so its
1677        // recorded `size` is set for `event_child`'s translation.
1678        pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1679
1680        let mut state = Log::default();
1681        {
1682            let s: &mut dyn Any = &mut state;
1683            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1684            pod.event_child(
1685                &mut ctx,
1686                &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1687            );
1688        }
1689        assert!(pod.is_focused(), "the first trigger Down claims focus");
1690        {
1691            let s: &mut dyn Any = &mut state;
1692            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1693            pod.event_child(&mut ctx, &ev(PointerPhase::Up, fab_center.x, fab_center.y));
1694        }
1695
1696        // Simulate an external blur (mirrors the root's own `Down`-with-no-
1697        // claim release path) so the second Down's own re-claim is what's
1698        // under test, not a leftover flag.
1699        pod.set_focused(false);
1700        {
1701            let s: &mut dyn Any = &mut state;
1702            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1703            pod.event_child(
1704                &mut ctx,
1705                &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1706            );
1707        }
1708        assert!(
1709            pod.is_focused(),
1710            "a second trigger Down must re-claim focus after an external blur — this \
1711             is what keeps the root's focus/IME session alive while the menu is up"
1712        );
1713    }
1714
1715    #[test]
1716    fn a_down_reclaims_scrim_focus_after_an_external_blur() {
1717        use frust::authoring::ChildPod;
1718
1719        let w = build_menu(true, 2);
1720        let mut pod = ChildPod::new(Box::new(w));
1721        let mut tcx = frust::authoring::text::TextContext::new();
1722        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1723        let area = Size::new(400.0, 600.0);
1724        pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1725
1726        let mut state = Log::default();
1727        // The top-left corner is neither the trigger nor any item (the
1728        // scrim) — mirrors `scrim_tap_closes_without_selecting_an_item`.
1729        {
1730            let s: &mut dyn Any = &mut state;
1731            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1732            pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
1733        }
1734        assert!(pod.is_focused(), "the first scrim Down claims focus");
1735        {
1736            let s: &mut dyn Any = &mut state;
1737            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1738            pod.event_child(&mut ctx, &ev(PointerPhase::Cancel, 5.0, 5.0));
1739        }
1740
1741        // Simulate an external blur (mirrors the root's own `Down`-with-no-
1742        // claim release path) so the second Down's own re-claim is what's
1743        // under test, not a leftover flag.
1744        pod.set_focused(false);
1745        {
1746            let s: &mut dyn Any = &mut state;
1747            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1748            pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
1749        }
1750        assert!(
1751            pod.is_focused(),
1752            "a second scrim/item Down must re-claim focus after an external blur — \
1753             this is what keeps the root's focus/IME session alive while the menu is up"
1754        );
1755    }
1756
1757    #[test]
1758    fn reopening_after_a_close_reclaims_focus() {
1759        // The persisting-widget counterpart of the pushed-page widgets' fresh
1760        // `build`: the FAB lives on across open/close cycles. Driven through a
1761        // `ChildPod` (rather than a removed internal guard field), so what's
1762        // actually asserted is the pod's own recorded focus path surviving a
1763        // close/reopen rebuild, then self-healing a later external blur.
1764        use frust::authoring::ChildPod;
1765
1766        let mut w = build_menu(false, 1);
1767        laid_out(&mut w);
1768        let fab_center = w.fab_rect.center();
1769        let mut pod = ChildPod::new(Box::new(w));
1770        let mut tcx = frust::authoring::text::TextContext::new();
1771        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1772        let area = Size::new(400.0, 600.0);
1773        pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1774
1775        let mut state = Log::default();
1776        {
1777            let s: &mut dyn Any = &mut state;
1778            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1779            pod.event_child(
1780                &mut ctx,
1781                &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1782            );
1783        }
1784        assert!(pod.is_focused(), "the trigger press claims focus");
1785
1786        // Open, then close again via rebuild (the controlled `open` prop),
1787        // driven directly on the pod's boxed widget.
1788        let mut counter = 0u64;
1789        let closed = fab_menu::<Log, _>(icon_stub::<Log>(), false, vec![item(0)], |s: &mut Log| {
1790            s.toggles += 1
1791        });
1792        let opened = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |s: &mut Log| {
1793            s.toggles += 1
1794        });
1795        {
1796            let widget = pod
1797                .widget_mut()
1798                .downcast_mut::<FabMenuWidget>()
1799                .expect("the pod wraps a FabMenuWidget");
1800            View::<Log>::rebuild(&opened, &closed, widget, &mut BuildCtx::new(&mut counter));
1801            View::<Log>::rebuild(&closed, &opened, widget, &mut BuildCtx::new(&mut counter));
1802        }
1803        assert!(
1804            pod.is_focused(),
1805            "a same-identity rebuild (open then close) never touches the pod's \
1806             recorded focus path on its own"
1807        );
1808
1809        // Simulate an external blur (mirrors the root's own `Down`-with-no-
1810        // claim release path) so the reopened trigger's own re-claim is
1811        // what's under test.
1812        pod.set_focused(false);
1813        pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1814        {
1815            let s: &mut dyn Any = &mut state;
1816            let mut ctx = EventCtx::new(s, Point::ZERO, area);
1817            pod.event_child(
1818                &mut ctx,
1819                &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1820            );
1821        }
1822        assert!(
1823            pod.is_focused(),
1824            "reopening after a close still lets a fresh trigger press re-claim focus"
1825        );
1826    }
1827
1828    #[test]
1829    fn is_controlled_open_only_moves_via_rebuild() {
1830        let mut w = build_menu(false, 1);
1831        assert!(!w.open);
1832        let prev = fab_menu::<Log, _>(icon_stub::<Log>(), false, vec![item(0)], |_s: &mut Log| {});
1833        let next = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |_s: &mut Log| {});
1834        let mut counter = 0u64;
1835        View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1836        assert!(w.open, "open state moved on rebuild (controlled)");
1837        assert!(
1838            w.fab_morph.is_animating(),
1839            "the trigger morph relaunched toward the open target"
1840        );
1841    }
1842
1843    // -----------------------------------------------------------------------
1844    // Paint.
1845    // -----------------------------------------------------------------------
1846
1847    #[derive(Default)]
1848    struct RectRecorder {
1849        rrects: Vec<(Point, Size, f64, Color)>,
1850        rects: Vec<(Point, Size, Color)>,
1851        fills: Vec<BezPath>,
1852        /// One entry per `push_layer` — the item-fade contract (see the
1853        /// module docs' Cascade stagger section).
1854        layers: Vec<(Point, Size, f32)>,
1855        pops: usize,
1856    }
1857    impl PaintScene for RectRecorder {
1858        fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
1859            self.rects.push((o, s, c));
1860        }
1861        fn draw_text(&mut self, _o: Point, _t: &str) {}
1862        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1863            self.rrects.push((o, s, radius, color));
1864        }
1865        fn fill_path(&mut self, _origin: Point, path: &BezPath, _brush: &Brush) {
1866            self.fills.push(path.clone());
1867        }
1868        fn push_layer(&mut self, o: Point, s: Size, alpha: f32) {
1869            self.layers.push((o, s, alpha));
1870        }
1871        fn pop_layer(&mut self) {
1872            self.pops += 1;
1873        }
1874    }
1875
1876    /// Paint one frame at `millis` into `rec` (the cascade's own clock is
1877    /// seeded from the first such paint — see [`FabMenuWidget::advance_cascade`]).
1878    fn paint_at(w: &mut FabMenuWidget, millis: u64, rec: &mut RectRecorder) {
1879        let mut pctx = PaintCtx::for_test(
1880            Point::ZERO,
1881            Size::new(400.0, 600.0),
1882            FrameTime::from_nanos(millis * 1_000_000),
1883        );
1884        w.paint(&mut pctx, rec);
1885    }
1886
1887    /// Flip the controlled `open` prop through a rebuild — the only way it
1888    /// moves (see the module docs).
1889    fn set_open(w: &mut FabMenuWidget, from: bool, to: bool, item_count: usize) {
1890        let prev = fab_menu::<Log, _>(
1891            icon_stub(),
1892            from,
1893            (0..item_count).map(item).collect(),
1894            |_s: &mut Log| {},
1895        );
1896        let next = fab_menu::<Log, _>(
1897            icon_stub(),
1898            to,
1899            (0..item_count).map(item).collect(),
1900            |_s: &mut Log| {},
1901        );
1902        let mut counter = 0u64;
1903        View::<Log>::rebuild(&next, &prev, w, &mut BuildCtx::new(&mut counter));
1904    }
1905
1906    #[test]
1907    fn closed_paint_draws_only_the_trigger_shape() {
1908        let mut w = build_menu(false, 2);
1909        laid_out(&mut w);
1910        let mut rec = RectRecorder::default();
1911        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0));
1912        w.paint(&mut pctx, &mut rec);
1913        assert!(rec.rects.is_empty(), "no scrim while closed");
1914        assert!(rec.rrects.is_empty(), "no item containers while closed");
1915        assert!(rec.layers.is_empty(), "no item fade layers while closed");
1916        assert_eq!(rec.fills.len(), 1, "only the trigger's morphed outline");
1917    }
1918
1919    #[test]
1920    fn open_paint_draws_scrim_and_item_containers() {
1921        let mut w = build_menu(true, 2);
1922        laid_out(&mut w);
1923        open_fully(&mut w);
1924        let mut rec = RectRecorder::default();
1925        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0));
1926        w.paint(&mut pctx, &mut rec);
1927        assert_eq!(rec.rects.len(), 1, "one full-area scrim fill");
1928        assert_eq!(rec.rrects.len(), 2, "2 item containers");
1929        assert_eq!(rec.fills.len(), 1, "the trigger's morphed outline");
1930        // Fully-revealed items still composite through their own layer (one
1931        // per visible item, at their own progress) — the container itself
1932        // carries the role color unmodified, never an alpha-scaled copy.
1933        assert_eq!(rec.layers.len(), 2, "one fade layer per visible item");
1934        assert_eq!(rec.pops, 2, "every pushed layer is popped again");
1935        assert!(
1936            rec.layers.iter().all(|l| l.2 == 1.0),
1937            "settled open: t == 1"
1938        );
1939        assert_eq!(rec.rrects[0].3, PRIMARY_CONTAINER);
1940        assert_eq!(rec.rrects[1].3, SECONDARY_CONTAINER);
1941        // Upstream's own scrimOpacity default is 0.0 — see the module docs'
1942        // Scrim section.
1943        assert_eq!(rec.rects[0].2, with_alpha(SCRIM_FALLBACK, 0.0));
1944    }
1945
1946    #[test]
1947    fn themed_paint_resolves_container_family_tokens() {
1948        let theme = crate::baseline();
1949        let mut w = build_menu(true, 3);
1950        laid_out(&mut w);
1951        open_fully(&mut w);
1952        let mut rec = RectRecorder::default();
1953        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0)).with_theme(&theme);
1954        w.paint(&mut pctx, &mut rec);
1955
1956        let scheme = theme.scheme();
1957        assert_eq!(rec.rrects[0].3, scheme.primary_container);
1958        assert_eq!(rec.rrects[1].3, scheme.secondary_container);
1959        assert_eq!(rec.rrects[2].3, scheme.tertiary_container);
1960    }
1961
1962    #[test]
1963    fn item_shape_is_a_full_pill() {
1964        assert_eq!(item_radius(), ITEM_HEIGHT / 2.0);
1965    }
1966
1967    // -----------------------------------------------------------------------
1968    // Reveal-opacity contract: no bare-content flash, in either direction.
1969    // -----------------------------------------------------------------------
1970
1971    #[test]
1972    fn a_mid_cascade_item_fades_as_one_unit_through_its_own_layer() {
1973        let mut w = build_menu(false, 2);
1974        laid_out(&mut w);
1975        set_open(&mut w, false, true, 2);
1976
1977        let mut rec = RectRecorder::default();
1978        // The first paint only seeds the cascade clock and flings item 0
1979        // (`advance`'s zero-delta contract); the second lands it mid-reveal,
1980        // while item 1's own 30ms delay hasn't elapsed.
1981        paint_at(&mut w, 0, &mut rec);
1982        rec = RectRecorder::default();
1983        paint_at(&mut w, 20, &mut rec);
1984
1985        let t = w.item_anim[0].value_clamped();
1986        assert!(t > 0.0 && t < 1.0, "item 0 is mid-reveal (t={t})");
1987        assert_eq!(
1988            w.item_anim[1].value_clamped(),
1989            0.0,
1990            "item 1 hasn't launched"
1991        );
1992
1993        assert_eq!(rec.layers.len(), 1, "one fade layer per *visible* item");
1994        assert_eq!(rec.pops, 1, "and it is popped again");
1995        assert_eq!(
1996            rec.layers[0].2, t as f32,
1997            "the layer carries the item's own reveal progress"
1998        );
1999        let item = w.item_rects[0];
2000        assert_eq!(rec.layers[0].0, item.origin(), "layer covers the item row");
2001        assert_eq!(rec.layers[0].1, item.size());
2002        assert_eq!(rec.rrects.len(), 1, "only the revealed item's container");
2003        assert_eq!(
2004            rec.rrects[0].3, PRIMARY_CONTAINER,
2005            "the container paints at full alpha INSIDE the layer — fading it \
2006             again there would double-fade it, and fading it alone (while the \
2007             icon/label pods paint opaque regardless) is what made a low-`t` \
2008             item read as a bare content row with no pill behind it"
2009        );
2010    }
2011
2012    #[test]
2013    fn a_menu_built_closed_never_flings_and_never_paints_an_item() {
2014        let mut w = build_menu(false, 3);
2015        laid_out(&mut w);
2016        assert_eq!(
2017            w.cascade_launched,
2018            vec![true; 3],
2019            "a widget built at rest mounts fully-launched: an unseeded closed \
2020             mount lets the first paint fling every item from rest with the \
2021             retreat velocity, dipping below zero and rebounding back above it"
2022        );
2023        assert!(
2024            w.item_anim
2025                .iter()
2026                .all(|a| !a.is_animating() && a.value() == 0.0),
2027            "every item spring rests exactly at 0"
2028        );
2029        assert!(!w.fab_morph.is_animating() && w.fab_morph.value() == 0.0);
2030
2031        let mut rec = RectRecorder::default();
2032        for frame in 0..40u64 {
2033            paint_at(&mut w, frame * 16, &mut rec);
2034        }
2035        assert!(rec.rrects.is_empty(), "no item container ever paints");
2036        assert!(rec.layers.is_empty(), "no item fade layer ever pushes");
2037        assert!(
2038            w.item_anim.iter().all(|a| !a.is_animating()),
2039            "and no item spring ever launched"
2040        );
2041    }
2042
2043    #[test]
2044    fn the_closing_retreat_stops_at_zero_instead_of_ringing_back_above_it() {
2045        let mut w = build_menu(true, 1);
2046        laid_out(&mut w);
2047        open_fully(&mut w);
2048        set_open(&mut w, true, false, 1);
2049
2050        // ITEM_SPRING is under-damped (0.55): released from 1.0 at
2051        // -FLING_VELOCITY it first reaches zero around 124ms, then — unclamped
2052        // — rings back *above* zero twice before its energy dies.
2053        let mut rec = RectRecorder::default();
2054        for frame in 0..13u64 {
2055            paint_at(&mut w, frame * 16, &mut rec); // through 192ms
2056        }
2057        assert!(
2058            !w.item_anim[0].is_animating(),
2059            "the retreating spring stops the frame it reaches zero"
2060        );
2061        assert_eq!(w.item_anim[0].value(), 0.0, "and stops exactly *at* zero");
2062
2063        let mut after = RectRecorder::default();
2064        for frame in 13..60u64 {
2065            paint_at(&mut w, frame * 16, &mut after); // through ~950ms
2066        }
2067        assert!(
2068            after.rrects.is_empty() && after.layers.is_empty(),
2069            "no rebound ever paints the item back on"
2070        );
2071    }
2072
2073    #[test]
2074    fn the_trigger_morphs_retreat_rebound_is_imperceptible_so_it_stays_unclamped() {
2075        // The trigger morph rings around its 0.0 target exactly like an item's
2076        // spring does, but it drives a *layout* size rather than an opacity and
2077        // FAB_SHAPE_SPRING is damped at 0.7 rather than 0.55. Measured here
2078        // rather than assumed: the first rebound above zero peaks near
2079        // `+0.0021`, worth ~0.05px of container size, so there is nothing to
2080        // clamp away. This pins the measurement the no-clamp decision rests on
2081        // — re-take it if either spring constant moves.
2082        let mut anim = AnimationController::new(ANIM_PERIOD);
2083        snap_anim(&mut anim, true, FrameTime::ZERO);
2084        anim.fling(-FLING_VELOCITY, FAB_SHAPE_SPRING);
2085        let (mut crossed, mut peak) = (false, 0.0f64);
2086        for frame in 0..1000u64 {
2087            anim.advance(FrameTime::from_nanos(frame * 1_000_000)); // 1ms steps
2088            let v = anim.value();
2089            crossed |= v <= 0.0;
2090            if crossed {
2091                peak = peak.max(v);
2092            }
2093        }
2094        assert!(crossed, "the retreat does cross its target");
2095        let px = MAIN_CONTAINER_CLOSED - lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, peak);
2096        assert!(
2097            px < 0.1,
2098            "rebound peak {peak} = {px}px of container size — big enough to see, \
2099             so the retreat clamp now needs to cover the morph too"
2100        );
2101    }
2102
2103    #[test]
2104    fn reduce_motion_snaps_the_trigger_and_items_without_requesting_a_frame() {
2105        let mut theme = crate::baseline();
2106        theme.motion.reduce_motion = true;
2107        let mut w = build_menu(true, 2);
2108        laid_out(&mut w);
2109        let mut rec = RectRecorder::default();
2110        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0)).with_theme(&theme);
2111        w.paint(&mut pctx, &mut rec);
2112
2113        assert_eq!(w.fab_morph.value_clamped(), 1.0, "snapped straight open");
2114        assert!(
2115            w.item_anim.iter().all(|a| a.value_clamped() == 1.0),
2116            "every item snapped straight open"
2117        );
2118        assert!(
2119            !pctx.needs_frame(),
2120            "reduce_motion must not request a continuation frame"
2121        );
2122    }
2123
2124    #[test]
2125    fn inspect_surfaces_icon_and_label_pods_per_item_plus_the_trigger() {
2126        // Regression coverage for the hand-written `FabMenuItemPod`
2127        // `VisitPods` impl (ported from
2128        // crates/frust-widgets/tests/visit_children.rs, which covered this
2129        // shape before the design-system extraction). If
2130        // `FabMenuItemPod::visit_pods` ever stopped forwarding one of
2131        // `icon`/`label`, the child count and TextWidget count below would
2132        // both fall short of 5 — a dropped-child regression
2133        // `RenderRoot::inspect()`/devtools depend on this seam to never
2134        // allow.
2135        fn logic(_s: &mut ()) -> FabMenuView<()> {
2136            fab_menu::<(), _>(
2137                icon_stub::<()>(),
2138                true,
2139                vec![
2140                    fab_menu_item(icon_stub::<()>(), "alpha", |_s: &mut ()| {}),
2141                    fab_menu_item(icon_stub::<()>(), "beta", |_s: &mut ()| {}),
2142                ],
2143                |_s: &mut ()| {},
2144            )
2145        }
2146        let mut root: RenderRoot<(), FabMenuView<()>> = RenderRoot::new();
2147        let mut state = ();
2148        root.rebuild(&mut logic, &mut state);
2149        let mut tcx = frust::authoring::text::TextContext::new();
2150        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2151        let nodes = root.inspect();
2152
2153        let short_names: Vec<&str> = nodes
2154            .iter()
2155            .map(|n| {
2156                let bare = n.type_name.split('<').next().unwrap_or(n.type_name);
2157                bare.rsplit("::").next().unwrap_or(bare)
2158            })
2159            .collect();
2160        assert_eq!(
2161            short_names
2162                .iter()
2163                .filter(|n| **n == "FabMenuWidget")
2164                .count(),
2165            1
2166        );
2167        assert_eq!(
2168            nodes[0].children.len(),
2169            5,
2170            "two items x (icon + label), plus the trigger icon"
2171        );
2172        assert_eq!(
2173            short_names.iter().filter(|n| **n == "TextWidget").count(),
2174            5
2175        );
2176    }
2177
2178    #[test]
2179    fn semantics_reports_closed_trigger_then_open_menu() {
2180        fn logic_closed(_s: &mut ()) -> FabMenuView<()> {
2181            fab_menu::<(), _>(leaf_any(24.0, 24.0), false, vec![], |_s: &mut ()| {})
2182                .label("Compose")
2183        }
2184        let mut root: frust_core::RenderRoot<(), FabMenuView<()>> = frust_core::RenderRoot::new();
2185        let mut state = ();
2186        root.rebuild(&mut logic_closed, &mut state);
2187        let mut tcx = frust::authoring::text::TextContext::new();
2188        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2189        let update = root.semantics();
2190        let (_, node) = update
2191            .nodes
2192            .iter()
2193            .find(|(_, n)| n.role() == Role::Button)
2194            .expect("closed menu contributes a Role::Button trigger node");
2195        assert_eq!(node.label(), Some("Compose"));
2196        assert_eq!(node.is_expanded(), Some(false));
2197
2198        fn logic_open(_s: &mut ()) -> FabMenuView<()> {
2199            fab_menu::<(), _>(
2200                leaf_any(24.0, 24.0),
2201                true,
2202                vec![fab_menu_item(
2203                    leaf_any(24.0, 24.0),
2204                    "New note",
2205                    |_s: &mut ()| {},
2206                )],
2207                |_s: &mut ()| {},
2208            )
2209        }
2210        let mut root: frust_core::RenderRoot<(), FabMenuView<()>> = frust_core::RenderRoot::new();
2211        let mut state = ();
2212        root.rebuild(&mut logic_open, &mut state);
2213        let mut tcx = frust::authoring::text::TextContext::new();
2214        root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2215        let update = root.semantics();
2216        let menu = update
2217            .nodes
2218            .iter()
2219            .find(|(_, n)| n.role() == Role::Menu)
2220            .expect("an open menu contributes a Role::Menu container node");
2221        assert_eq!(menu.1.children().len(), 1);
2222        let menu_item = update
2223            .nodes
2224            .iter()
2225            .find(|(_, n)| n.role() == Role::MenuItem)
2226            .expect("an item contributes a Role::MenuItem node");
2227        assert_eq!(menu_item.1.label(), Some("New note"));
2228    }
2229}