Skip to main content

frust_material/
fab.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments)
2// Upstream: https://github.com/paadevelopments/material_3_expressive
3//   lib/components/floating_action_buttons/, lib/components/extended_fabs/
4// The reference's separate `M3EFab`/`M3EExtendedFab` widgets are unified
5// into one `FabView`/`FabWidget` pair here (constructed via `fab()`/
6// `extended_fab()`); `M3ETappable`, the shared interaction primitive both
7// build on upstream, is ported separately as `crate::interaction`.
8
9//! The M3 Expressive `FloatingActionButton`: three container sizes (small
10//! 40dp / medium 56dp / large 96dp), four container colors, a spring press
11//! scale, elevation that lifts on hover, and an animatable corner-radius
12//! override seam — plus [`extended_fab`]'s pill-shaped variant, whose label
13//! animates open/closed.
14//!
15//! [`fab`] produces an icon-only FAB (a fixed square, painted centered
16//! icon); [`extended_fab`] produces the pill-shaped extended variant (56dp
17//! fixed height, a visible text label, and an optional leading icon). Both
18//! fire a plain `on_press` callback on release inside their bounds —
19//! mirroring [`frust::Button`]'s fire-on-up-inside contract, not a
20//! controlled component (a FAB has no reportable/confirmable value, just an
21//! action).
22//!
23//! # Sizes
24//!
25//! Source: `m3e_fab_theme.dart`'s `M3EFabTheme` defaults. Container /
26//! icon / corner-radius token, per [`FabSize`]:
27//!
28//! | Size | Container | Icon | Radius (themed token) |
29//! |---|---|---|---|
30//! | [`FabSize::Small`] | 40dp | 24dp | 12dp (`shape.medium`) |
31//! | [`FabSize::Medium`] (default) | 56dp | 24dp | 16dp (`shape.large`) |
32//! | [`FabSize::Large`] | 96dp | 36dp | 28dp (`shape.extra_large`) |
33//!
34//! # Colors
35//!
36//! Source: `m3e_fab_theme.dart`'s `M3EFabTheme._palette`. Container /
37//! content role, per [`FabColor`]:
38//!
39//! | Color (default: [`FabColor::Primary`]) | Container | Content |
40//! |---|---|---|
41//! | [`FabColor::Primary`] | `primaryContainer` | `onPrimaryContainer` |
42//! | [`FabColor::Secondary`] | `secondaryContainer` | `onSecondaryContainer` |
43//! | [`FabColor::Tertiary`] | `tertiaryContainer` | `onTertiaryContainer` |
44//! | [`FabColor::Surface`] | `surfaceContainerHigh` | `primary` (**not** `onSurface` — verified from the Dart source's `_palette` switch) |
45//!
46//! # Elevation
47//!
48//! Rest: M3 elevation level 3. Hover: level 4 (`M3EFabTheme`/
49//! `M3EExtendedFabTheme.elevation(hovered:)` both use this exact rest/hover
50//! pair). Hover is claimed from the uncaptured `Move` arm via
51//! [`frust::authoring::EventCtx::claim_hover`] and self-corrected every
52//! paint from [`frust::authoring::PaintCtx::is_hovered`] — the same
53//! three-part contract [`super::list_item`] documents (see
54//! `docs/CODE_STANDARDS.md`'s Interaction Semantics).
55//!
56//! # Press scale
57//!
58//! Both variants play the reference's spatial press-scale spring —
59//! stiffness 380, damping ratio 0.55 (`m3e_floating_action_buttons.dart`'s
60//! module doc: "plays a spatial spring press scale (380 / 0.55)";
61//! `m3e_extended_fabs.dart`'s: "Press uses spatial spring scale only (380 /
62//! 0.55)") — toward `pressedScale` (0.95 for [`fab`], 0.97 for
63//! [`extended_fab`], `M3EFabTheme.pressedScale`/`M3EExtendedFabTheme.
64//! pressedScale`). [`PRESS_SPRING`] is a named constant rather than a
65//! paint-time theme read (a press starts in the event pass, which never
66//! reads a theme) — the same `button_group`-established
67//! `PRESS_SPRING`-plus-`fling` pattern, driven here via
68//! [`frust::AnimationController::fling`] and applied at paint time as a
69//! [`frust::authoring::PaintScene::push_transform`]/`pop_transform` pair
70//! around the whole surface (container, shadow, state layer, icon, label) —
71//! the same `scale_about`-pivot technique `frust_widgets::Button` uses,
72//! duplicated here rather than depended on (design-system plugins carry no
73//! `frust-widgets` production dependency).
74//!
75//! # Corner-radius override seam (the FAB-menu-morph hook)
76//!
77//! [`FabView::corner_radius`] (`Option<f64>`, [`fab`] only — a no-op on
78//! [`extended_fab`], whose upstream `M3EExtendedFab` constructor carries no
79//! such parameter at all) mirrors the reference's `M3EFab.cornerRadius`
80//! exactly: when `None`, the radius follows the size-derived themed token
81//! and eases toward it with `MaterialMotion::SHORT_4` whenever that target
82//! changes; when `Some(r)`, the radius pins to `r` immediately, every frame,
83//! with **no** internal animation — the reference's `cornerRadius != null ?
84//! Duration.zero : M3EMotion.short4` ternary. The override exists so an
85//! external, frame-by-frame-driven radius (a spring the *caller* owns) is
86//! never fought by an internal ease of its own. There is no consumer of this
87//! seam in this crate yet — it is built for [`mod@super::fab_menu`]'s
88//! trigger-to-close-button morph, a follow-up not built here (that module
89//! currently duplicates its own private trigger tokens rather than calling
90//! into this one).
91//!
92//! # Extended FAB: label collapse/expand
93//!
94//! [`FabView::extended`] (default `true`) mirrors `M3EExtendedFab.extended`:
95//! setting it `false` animates the visible label away, collapsing the pill
96//! toward an icon-only shape. Driven by an
97//! [`frust::AnimationController`] (`MaterialMotion::MEDIUM_2` / `EMPHASIZED`
98//! — the reference's own `AnimatedContainer`/`AnimatedSize` timing) whose
99//! `0.0`..`1.0` value scales both the icon-label gap and the label's own
100//! measured width, **and** the container's horizontal padding (`Tween`
101//! between the reference's `collapsedHorizontalPadding` 16dp and
102//! `extendedHorizontalPadding` 20dp) — a v1 approximation of the reference's
103//! `AnimatedSize`-plus-static-`Padding` combination (this crate's
104//! `PaintScene` has no generic animated-child-size primitive), which still
105//! lands on the reference's exact two endpoint widths: fully collapsed is
106//! `pad(16)*2 + icon(24)` = 56dp (a rounded square, since the radius itself
107//! never animates with collapse — it stays fixed), fully extended is
108//! `pad(20)*2 + icon(24) + gap(12) + label_width`. **Known v1 mount
109//! quirk:** because [`frust::AnimationController::advance`]'s first call
110//! after starting a motion only seeds its clock (see that method's own
111//! doc), a freshly mounted [`extended_fab`] whose label starts shown (the
112//! default) briefly grows its label into view over one `MEDIUM_2` duration
113//! on its very first frames, rather than mounting already-open the way the
114//! reference's own `AnimatedSize` does on its first build — a widget that
115//! starts *collapsed* (`.extended(false)`) has no such quirk, since a
116//! never-`forward()`-ed controller stays exactly at its resting `0.0`.
117//!
118//! **The layout-skip trap.** The reveal fraction above is a **layout**
119//! value — [`FabWidget::layout`]'s extended branch lerps pad/gap/label width
120//! straight off `label_anim.value_clamped()` — so an in-flight reveal needs
121//! an explicit relayout request from `paint`, not merely another frame: on
122//! the mobile intra-frame layout skip (`docs/SHELLS_ARCHITECTURE.md`'s
123//! `frame_gate`), layout does not re-run merely because paint asked for
124//! another frame. `FabWidget::paint` therefore calls
125//! [`frust::authoring::PaintCtx::request_layout`] (which implies
126//! `request_frame`) while `label_anim` is in flight — the same trap
127//! [`mod@super::navigation_rail`]'s width motion and
128//! [`mod@super::expandable_list`]'s reveal both document at length. Unlike
129//! those two, this widget has no `Theme.motion.reduce_motion` handling for
130//! `label_anim` at all yet — a v1 gap, not part of this fix.
131//!
132//! # Haptics: verified none
133//!
134//! `M3ETappable` defaults to `haptic: M3EHapticFeedback.none`
135//! (`m3e_tappable.dart`), and neither `M3EFab` nor `M3EExtendedFab` passes a
136//! `haptic:` argument to their `M3ETappable(...)` call — so the default
137//! applies, and a FAB press fires **no** platform haptic in the upstream
138//! reference. This port matches that verified fact by never calling
139//! [`crate::interaction::MaterialHaptics::fire`] from [`FabWidget::event`].
140//!
141//! # Attribution
142//!
143//! See `plugins/material/NOTICE`'s "MIT License — Root Crate" section and
144//! its Module Attribution Header Convention.
145
146use std::rc::Rc;
147use std::time::Duration;
148
149use frust::authoring::{Action, Role};
150use frust::authoring::{
151    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
152    LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
153};
154use frust::{AnimationController, ShapeScale, SpringDesc, Theme, Tween};
155use kurbo::{Affine, Point, Rect, Size};
156use peniko::Color;
157
158use super::press::presses;
159use super::state_layer::StateLayer;
160use frust::authoring::{ThemeTextColor, ThemeTextType};
161
162// ---- Sizes (M3EFabTheme defaults: small/medium/large) ---------------------
163
164/// Container size for the small FAB, in logical px (`M3EFabTheme.
165/// smallContainer`).
166const SMALL_CONTAINER: f64 = 40.0;
167/// Container size for the medium FAB, in logical px (`M3EFabTheme.
168/// mediumContainer`) — the default [`FabSize`].
169const MEDIUM_CONTAINER: f64 = 56.0;
170/// Container size for the large FAB, in logical px (`M3EFabTheme.
171/// largeContainer`).
172const LARGE_CONTAINER: f64 = 96.0;
173
174/// Icon size for the small FAB, in logical px (`M3EFabTheme.
175/// smallIconSize`).
176const SMALL_ICON: f64 = 24.0;
177/// Icon size for the medium FAB, in logical px (`M3EFabTheme.
178/// mediumIconSize`).
179const MEDIUM_ICON: f64 = 24.0;
180/// Icon size for the large FAB, in logical px (`M3EFabTheme.
181/// largeIconSize`).
182const LARGE_ICON: f64 = 36.0;
183
184/// Unthemed-fallback corner radius, small FAB (a theme resolves
185/// `shape.medium`; `M3EFabTheme.smallRadius`).
186const SMALL_RADIUS: f64 = 12.0;
187/// Unthemed-fallback corner radius, medium FAB (a theme resolves
188/// `shape.large`; `M3EFabTheme.mediumRadius`) — also the extended FAB's
189/// fallback radius, which coincides with this value exactly (see
190/// [`EXTENDED_RADIUS_FALLBACK`]'s doc).
191const MEDIUM_RADIUS: f64 = 16.0;
192/// Unthemed-fallback corner radius, large FAB (a theme resolves
193/// `shape.extra_large`; `M3EFabTheme.largeRadius`).
194const LARGE_RADIUS: f64 = 28.0;
195
196// ---- Extended FAB (M3EExtendedFabTheme defaults) ---------------------------
197
198/// Fixed extended-FAB height, in logical px (`M3EExtendedFabTheme.height`) —
199/// constant regardless of the `extended`/collapsed reveal state.
200const EXTENDED_HEIGHT: f64 = 56.0;
201/// Unthemed-fallback extended-FAB corner radius (a theme resolves
202/// `shape.large`; `M3EExtendedFabTheme.cornerRadius`). Coincides with
203/// [`MEDIUM_RADIUS`] exactly — both are the same `shape.large`/16dp token —
204/// reused rather than duplicated.
205const EXTENDED_RADIUS_FALLBACK: f64 = MEDIUM_RADIUS;
206/// Symmetric horizontal padding while the label is shown, in logical px
207/// (`M3EExtendedFabTheme.extendedHorizontalPadding`).
208const EXTENDED_PAD_EXPANDED: f64 = 20.0;
209/// Symmetric horizontal padding while the label is collapsed, in logical px
210/// (`M3EExtendedFabTheme.collapsedHorizontalPadding`).
211const EXTENDED_PAD_COLLAPSED: f64 = 16.0;
212/// Extended-FAB icon size, in logical px (`M3EExtendedFabTheme.iconSize`) —
213/// fixed regardless of any [`FabSize`] (ignored on an [`extended_fab`]).
214const EXTENDED_ICON: f64 = 24.0;
215/// Gap between the icon and the label when both are visible, in logical px
216/// (`M3EExtendedFabTheme.iconLabelGap`).
217const EXTENDED_ICON_LABEL_GAP: f64 = 12.0;
218/// Press-scale target for [`extended_fab`] (`M3EExtendedFabTheme.
219/// pressedScale`).
220const EXTENDED_PRESSED_SCALE: f64 = 0.97;
221
222// ---- Press scale ------------------------------------------------------------
223
224/// Press-scale target for [`fab`] (`M3EFabTheme.pressedScale`).
225const FAB_PRESSED_SCALE: f64 = 0.95;
226
227/// The press-scale spring both variants share: stiffness 380, damping ratio
228/// 0.55 — the reference's `M3EMotion.expressiveSpatialPress`
229/// (`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`). See the
230/// [module docs](self) for why this is a named constant rather than a
231/// paint-time theme read; `press_spring_matches_reference` is the tripwire
232/// that keeps it equal to the token.
233const PRESS_SPRING: SpringDesc = SpringDesc {
234    mass: 1.0,
235    stiffness: 380.0,
236    damping_ratio: 0.55,
237};
238
239/// Nominal period seeding the press [`AnimationController`]'s clock; the
240/// motion is spring-driven ([`PRESS_SPRING`]) via `fling`, so this duration
241/// only backs the controller's construction and is not itself a timing
242/// (mirrors `button_group`'s `PRESS_ANIM_PERIOD`).
243const PRESS_ANIM_PERIOD: Duration = Duration::from_millis(300);
244/// Launch velocity (value-units/sec) handed to the press-in / press-out
245/// [`AnimationController::fling`] — a modest kick so the spring reads snappy
246/// (mirrors `button_group`'s `FLING_VELOCITY`).
247const FLING_VELOCITY: f64 = 4.0;
248
249// ---- Colors -------------------------------------------------------------
250
251/// Unthemed-fallback primary container fill (a theme resolves
252/// `colors.primary_container`).
253const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
254/// Unthemed-fallback primary container content (a theme resolves
255/// `colors.on_primary_container`).
256const ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
257/// Unthemed-fallback secondary container fill (a theme resolves
258/// `colors.secondary_container`).
259const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
260/// Unthemed-fallback secondary container content (a theme resolves
261/// `colors.on_secondary_container`).
262const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
263/// Unthemed-fallback tertiary container fill (a theme resolves
264/// `colors.tertiary_container`).
265const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
266/// Unthemed-fallback tertiary container content (a theme resolves
267/// `colors.on_tertiary_container`).
268const ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
269/// Unthemed-fallback surface-variant container fill (a theme resolves
270/// `colors.surface_container_high`).
271const SURFACE_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
272/// Unthemed-fallback surface-variant content — **`colors.primary`, not
273/// `on_surface`** (verified from the Dart source's `_palette` switch; see
274/// the [module docs](self)' Colors table).
275const SURFACE_FOREGROUND: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
276
277// ---- Shadow (unthemed fallback) --------------------------------------------
278
279/// Unthemed-fallback shadow y-offset at M3 elevation level 3, matching
280/// `crate::tokens::elevation().level3`'s `y_offset` exactly (`dp / 2.0 +
281/// 1.0` at `dp = 6.0`).
282const LEVEL3_SHADOW_Y_OFFSET: f64 = 4.0;
283/// Unthemed-fallback shadow blur std-dev at M3 elevation level 3, matching
284/// `crate::tokens::elevation().level3`.
285const LEVEL3_SHADOW_BLUR: f64 = 6.0;
286/// Unthemed-fallback shadow y-offset at M3 elevation level 4, matching
287/// `crate::tokens::elevation().level4`'s `y_offset` exactly (`dp / 2.0 +
288/// 1.0` at `dp = 8.0`).
289const LEVEL4_SHADOW_Y_OFFSET: f64 = 5.0;
290/// Unthemed-fallback shadow blur std-dev at M3 elevation level 4, matching
291/// `crate::tokens::elevation().level4`.
292const LEVEL4_SHADOW_BLUR: f64 = 8.0;
293/// Unthemed-fallback shadow color (opaque black at
294/// `crate::tokens::elevation()`'s `0.3` alpha — identical at every level).
295const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
296
297/// The FAB container size tier. Source: `m3e_fab_theme.dart`'s
298/// `M3EFabTheme` defaults — see the [module docs](self)' Sizes table.
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum FabSize {
301    /// 40dp compact FAB.
302    Small,
303    /// 56dp baseline FAB (the default).
304    Medium,
305    /// 96dp hero FAB.
306    Large,
307}
308
309impl FabSize {
310    fn container(self) -> f64 {
311        match self {
312            FabSize::Small => SMALL_CONTAINER,
313            FabSize::Medium => MEDIUM_CONTAINER,
314            FabSize::Large => LARGE_CONTAINER,
315        }
316    }
317
318    fn icon_size(self) -> f64 {
319        match self {
320            FabSize::Small => SMALL_ICON,
321            FabSize::Medium => MEDIUM_ICON,
322            FabSize::Large => LARGE_ICON,
323        }
324    }
325
326    fn fallback_radius(self) -> f64 {
327        match self {
328            FabSize::Small => SMALL_RADIUS,
329            FabSize::Medium => MEDIUM_RADIUS,
330            FabSize::Large => LARGE_RADIUS,
331        }
332    }
333
334    fn themed_radius(self, shape: &ShapeScale) -> f64 {
335        match self {
336            FabSize::Small => shape.medium,
337            FabSize::Medium => shape.large,
338            FabSize::Large => shape.extra_large,
339        }
340    }
341}
342
343/// The FAB container color variant. Source: `m3e_fab_theme.dart`'s
344/// `M3EFabTheme._palette` — see the [module docs](self)' Colors table.
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub enum FabColor {
347    /// `primaryContainer` / `onPrimaryContainer` (the default).
348    Primary,
349    /// `secondaryContainer` / `onSecondaryContainer`.
350    Secondary,
351    /// `tertiaryContainer` / `onTertiaryContainer`.
352    Tertiary,
353    /// `surfaceContainerHigh` / `primary` (not `onSurface`).
354    Surface,
355}
356
357/// `color` with its alpha channel replaced by `alpha`.
358fn with_alpha(color: Color, alpha: f32) -> Color {
359    let c = color.components;
360    Color::new([c[0], c[1], c[2], alpha])
361}
362
363/// The resolved `(container, content)` colors for `color`. Themed: the
364/// [module docs](self)' Colors table. Unthemed: the matching fallback
365/// constants exactly.
366fn resolve_colors(theme: Option<&Theme>, color: FabColor) -> (Color, Color) {
367    match theme {
368        Some(theme) => {
369            let s = theme.scheme();
370            match color {
371                FabColor::Primary => (s.primary_container, s.on_primary_container),
372                FabColor::Secondary => (s.secondary_container, s.on_secondary_container),
373                FabColor::Tertiary => (s.tertiary_container, s.on_tertiary_container),
374                FabColor::Surface => (s.surface_container_high, s.primary),
375            }
376        }
377        None => match color {
378            FabColor::Primary => (PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER),
379            FabColor::Secondary => (SECONDARY_CONTAINER, ON_SECONDARY_CONTAINER),
380            FabColor::Tertiary => (TERTIARY_CONTAINER, ON_TERTIARY_CONTAINER),
381            FabColor::Surface => (SURFACE_CONTAINER_HIGH, SURFACE_FOREGROUND),
382        },
383    }
384}
385
386/// The resolved `(blur_std_dev, y_offset, color)` shadow parameters: M3
387/// elevation level 3 at rest, level 4 while `hovered`. Themed: `theme.
388/// elevation.{level3,level4}`'s `ShadowSpec`, colored by `colors.shadow` at
389/// the spec's `color_alpha`. Unthemed: the `LEVEL{3,4}_SHADOW_*` constants
390/// exactly (the same values `crate::tokens::elevation()` produces).
391fn resolve_shadow(theme: Option<&Theme>, hovered: bool) -> (f64, f64, Color) {
392    match theme {
393        Some(theme) => {
394            let level = if hovered {
395                theme.elevation.level4
396            } else {
397                theme.elevation.level3
398            };
399            let shadow = level.shadow(theme.brightness);
400            let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
401            (shadow.blur_std_dev, shadow.y_offset, color)
402        }
403        None => {
404            if hovered {
405                (
406                    LEVEL4_SHADOW_BLUR,
407                    LEVEL4_SHADOW_Y_OFFSET,
408                    FALLBACK_SHADOW_COLOR,
409                )
410            } else {
411                (
412                    LEVEL3_SHADOW_BLUR,
413                    LEVEL3_SHADOW_Y_OFFSET,
414                    FALLBACK_SHADOW_COLOR,
415                )
416            }
417        }
418    }
419}
420
421fn inside(pos: Point, size: Size) -> bool {
422    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
423}
424
425/// The affine transform scaling uniformly by `scale` about a fixed `pivot`
426/// point — mirrors `frust_widgets::Button`'s module-private `scale_about`
427/// (duplicated rather than depended on; see the [module docs](self)' Press
428/// scale section).
429fn scale_about(pivot: Point, scale: f64) -> Affine {
430    Affine::translate((pivot.x, pivot.y))
431        * Affine::scale(scale)
432        * Affine::translate((-pivot.x, -pivot.y))
433}
434
435/// A view-held, typed press callback (erased on build).
436type OnPress<State> = Rc<dyn Fn(&mut State)>;
437
438/// Build the extended FAB's visible label view, themed `OnPrimaryContainer`,
439/// its family following the live theme's `labelLarge` role (the M3 extended
440/// FAB label token). Shared by build/rebuild/teardown so the role stays
441/// consistent.
442fn label_view<State: 'static>(label: String) -> AnyView<State> {
443    frust::authoring::any::<State, _>(
444        frust::text(label)
445            .themed_role(ThemeTextColor::OnPrimaryContainer)
446            .themed_family(ThemeTextType::LabelLarge),
447    )
448}
449
450/// A declarative M3E FAB. See the [module docs](self).
451pub struct FabView<State: 'static> {
452    size: FabSize,
453    color: FabColor,
454    icon: Option<AnyView<State>>,
455    /// Doubles as the extended FAB's visible text (when built via
456    /// [`extended_fab`]) and every FAB's accessible name — an icon-only FAB
457    /// has no visible text, so [`FabView::label`] is how it gets a
458    /// screen-reader name.
459    label: Option<String>,
460    /// Whether this view was built via [`extended_fab`] (the pill-shaped
461    /// variant) rather than [`fab`] — fixed by the constructor, not exposed
462    /// as a public setter (mirrors the reference's separate `M3EFab`/
463    /// `M3EExtendedFab` widget types).
464    is_extended: bool,
465    /// Whether the label is currently shown (only meaningful when
466    /// `is_extended`) — [`FabView::extended`]'s target.
467    label_shown: bool,
468    /// [`FabView::corner_radius`]'s override — see the [module docs](self)'
469    /// Corner-radius override seam section.
470    corner_radius: Option<f64>,
471    on_press: OnPress<State>,
472}
473
474/// Create an icon-only, medium-size FAB running `on_press` on release
475/// inside its bounds. Chain [`FabView::size`]/[`FabView::color`] for the
476/// other size/color tiers, and [`FabView::label`] to attach an accessible
477/// name (recommended — an icon-only FAB has no visible text a screen reader
478/// can read).
479pub fn fab<State: 'static, F: Fn(&mut State) + 'static>(
480    icon: impl View<State>,
481    on_press: F,
482) -> FabView<State> {
483    FabView {
484        size: FabSize::Medium,
485        color: FabColor::Primary,
486        icon: Some(AnyView::new(icon)),
487        label: None,
488        is_extended: false,
489        label_shown: true,
490        corner_radius: None,
491        on_press: Rc::new(on_press),
492    }
493}
494
495/// Create an extended FAB labelled `label` (56dp fixed height), running
496/// `on_press` on release inside its bounds. Chain [`FabView::icon`] to
497/// attach an optional leading icon, [`FabView::color`] for the color tier,
498/// and [`FabView::extended`] to control the label collapse/expand state.
499pub fn extended_fab<State: 'static, F: Fn(&mut State) + 'static>(
500    label: impl Into<String>,
501    on_press: F,
502) -> FabView<State> {
503    FabView {
504        size: FabSize::Medium,
505        color: FabColor::Primary,
506        icon: None,
507        label: Some(label.into()),
508        is_extended: true,
509        label_shown: true,
510        corner_radius: None,
511        on_press: Rc::new(on_press),
512    }
513}
514
515impl<State: 'static> FabView<State> {
516    /// Set the container size tier ([`fab`] only — ignored on an
517    /// [`extended_fab`], whose height is always `EXTENDED_HEIGHT`).
518    pub fn size(mut self, size: FabSize) -> Self {
519        self.size = size;
520        self
521    }
522
523    /// Set the container color tier (both variants).
524    pub fn color(mut self, color: FabColor) -> Self {
525        self.color = color;
526        self
527    }
528
529    /// Attach a leading icon (usable on [`extended_fab`]; [`fab`] already
530    /// requires one at construction).
531    pub fn icon(mut self, icon: impl View<State>) -> Self {
532        self.icon = Some(AnyView::new(icon));
533        self
534    }
535
536    /// Attach an accessible label ([`fab`]'s content description). A no-op
537    /// override on an [`extended_fab`], whose visible label is already the
538    /// accessible name.
539    pub fn label(mut self, label: impl Into<String>) -> Self {
540        self.label = Some(label.into());
541        self
542    }
543
544    /// Whether the label is shown (only meaningful on an [`extended_fab`] —
545    /// a no-op on a plain [`fab`], which has no label to show). Defaults to
546    /// `true`. Setting `false` animates the label away, collapsing the pill
547    /// toward an icon-only shape — see the [module docs](self)' Extended
548    /// FAB section.
549    pub fn extended(mut self, extended: bool) -> Self {
550        self.label_shown = extended;
551        self
552    }
553
554    /// Override the corner radius ([`fab`] only — a no-op on
555    /// [`extended_fab`]; see the [module docs](self)' Corner-radius
556    /// override seam section). `None` (the default) resolves the
557    /// size-derived themed radius, easing toward a changed target with
558    /// `MaterialMotion::SHORT_4`; `Some(r)` pins the radius to `r`
559    /// immediately every frame, with no internal animation.
560    pub fn corner_radius(mut self, radius: Option<f64>) -> Self {
561        self.corner_radius = radius;
562        self
563    }
564}
565
566/// The retained widget for a [`FabView`].
567pub struct FabWidget {
568    size: FabSize,
569    color: FabColor,
570    is_extended: bool,
571    label_shown: bool,
572    corner_radius: Option<f64>,
573    icon: Option<ChildPod>,
574    /// The extended FAB's visible label child — `None` for an icon-only FAB
575    /// or an extended FAB with no label (shouldn't normally happen, but not
576    /// enforced at the type level). Stays mounted regardless of
577    /// `label_shown` so it remains measurable while collapsed.
578    visible_label: Option<ChildPod>,
579    /// The accessible name (see [`FabView::label`]'s docs); always
580    /// populated for an extended FAB from its visible text.
581    label_text: Option<String>,
582    pressed: bool,
583    captured: bool,
584    state_layer: StateLayer,
585    /// Drives the press-scale spring — `0.0` at rest, `1.0` (or briefly
586    /// beyond, under-damped) while pressed. See the [module docs](self)'
587    /// Press scale section.
588    press_anim: AnimationController,
589    /// Drives the extended FAB's label reveal fraction — `0.0` (collapsed)
590    /// .. `1.0` (fully shown). See the [module docs](self)' Extended FAB
591    /// section.
592    label_anim: AnimationController,
593    /// The corner radius `radius_anim` is currently animating *from* (dp).
594    radius_from: f64,
595    /// The corner radius `radius_anim` is currently animating *to* (dp) —
596    /// the last resolved target (override or themed).
597    radius_to: f64,
598    /// Becomes `true` after the first paint has resolved an initial radius
599    /// target — the mount frame snaps directly to it with no animation
600    /// (an `AnimatedContainer`'s first build is never itself animated; only
601    /// a later prop change triggers motion).
602    radius_seeded: bool,
603    /// Duration+curve-driven (`MaterialMotion::SHORT_4`/`STANDARD`) unless
604    /// an override is active, in which case it is left idle and unused —
605    /// see [`FabWidget::advance_radius`].
606    radius_anim: AnimationController,
607    on_press: frust::authoring::ErasedCallback,
608}
609
610impl<State: 'static> View<State> for FabView<State> {
611    type Element = FabWidget;
612
613    fn build(&self, ctx: &mut BuildCtx<'_>) -> FabWidget {
614        let icon = self
615            .icon
616            .as_ref()
617            .map(|icon| frust::authoring::build_child(icon, ctx));
618        let visible_label = if self.is_extended {
619            self.label.as_ref().map(|label| {
620                frust::authoring::build_child(&label_view::<State>(label.clone()), ctx)
621            })
622        } else {
623            None
624        };
625        let mut label_anim = AnimationController::new(crate::tokens::MaterialMotion::MEDIUM_2)
626            .with_curve(crate::tokens::MaterialMotion::EMPHASIZED);
627        if self.label_shown {
628            label_anim.forward();
629        }
630        FabWidget {
631            size: self.size,
632            color: self.color,
633            is_extended: self.is_extended,
634            label_shown: self.label_shown,
635            corner_radius: self.corner_radius,
636            icon,
637            visible_label,
638            label_text: self.label.clone(),
639            pressed: false,
640            captured: false,
641            state_layer: StateLayer::new(),
642            press_anim: AnimationController::new(PRESS_ANIM_PERIOD),
643            label_anim,
644            radius_from: 0.0,
645            radius_to: 0.0,
646            radius_seeded: false,
647            radius_anim: AnimationController::new(crate::tokens::MaterialMotion::SHORT_4)
648                .with_curve(crate::tokens::MaterialMotion::STANDARD),
649            on_press: frust::authoring::erase_callback(&self.on_press),
650        }
651    }
652
653    fn rebuild(&self, prev: &Self, element: &mut FabWidget, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
654        element.on_press = frust::authoring::erase_callback(&self.on_press);
655        let mut flags = ChangeFlags::NONE;
656
657        if prev.size != self.size {
658            element.size = self.size;
659            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
660        }
661        if prev.color != self.color {
662            element.color = self.color;
663            flags |= ChangeFlags::PAINT;
664        }
665        if prev.is_extended != self.is_extended {
666            element.is_extended = self.is_extended;
667            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
668        }
669        if prev.label_shown != self.label_shown {
670            element.label_shown = self.label_shown;
671            if self.label_shown {
672                element.label_anim.forward();
673            } else {
674                element.label_anim.reverse();
675            }
676            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
677        }
678        if prev.corner_radius != self.corner_radius {
679            element.corner_radius = self.corner_radius;
680            flags |= ChangeFlags::PAINT;
681        }
682
683        match (&prev.icon, &self.icon) {
684            (None, None) => {}
685            (Some(p), Some(n)) => {
686                let pod = element.icon.as_mut().expect("icon pod present");
687                flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
688            }
689            (None, Some(n)) => {
690                element.icon = Some(frust::authoring::build_child(n, ctx));
691                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
692            }
693            (Some(p), None) => {
694                let mut pod = element.icon.take().expect("icon pod present");
695                frust::authoring::teardown_child(p, &mut pod, ctx);
696                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
697            }
698        }
699
700        if self.is_extended {
701            // Whether `visible_label` starts this block unmounted — including
702            // a Plain->Extended swap that carried no prior pod at all, since
703            // `element` is reused across the type flip (see this `rebuild`'s
704            // top-level docs and G9's device-gate note below).
705            let freshly_mounted = element.visible_label.is_none();
706            match (&prev.label, &self.label) {
707                (None, None) => {}
708                (Some(p), Some(n)) if p == n && prev.is_extended == self.is_extended => {}
709                (Some(_), Some(n)) => {
710                    element.label_text = Some(n.clone());
711                    if let Some(pod) = element.visible_label.as_mut() {
712                        let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
713                        let next_view = label_view::<State>(n.clone());
714                        flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
715                    } else {
716                        element.visible_label = Some(frust::authoring::build_child(
717                            &label_view::<State>(n.clone()),
718                            ctx,
719                        ));
720                        flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
721                    }
722                }
723                (None, Some(n)) => {
724                    element.label_text = Some(n.clone());
725                    element.visible_label = Some(frust::authoring::build_child(
726                        &label_view::<State>(n.clone()),
727                        ctx,
728                    ));
729                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
730                }
731                (Some(_), None) => {
732                    element.label_text = None;
733                    if let Some(mut pod) = element.visible_label.take() {
734                        let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
735                        frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
736                    }
737                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
738                }
739            }
740            // A pod that just mounted (None -> Some above) — the common case
741            // being a Plain->Extended swap — mirrors `build()`'s own mount
742            // behavior exactly: reset the reveal controller fresh rather
743            // than inherit whatever value it drifted to while this FAB was
744            // plain (`paint` advances `label_anim` every frame regardless of
745            // `is_extended`, since `is_extended` can itself flip via this
746            // same `rebuild`). Without this, a Plain->Extended swap could
747            // land on an already-`Completed` controller and snap straight to
748            // the fully open pill instead of reveal-animating in — see the
749            // [module docs](self)' "known v1 mount quirk".
750            if freshly_mounted && element.visible_label.is_some() {
751                element.label_anim =
752                    AnimationController::new(crate::tokens::MaterialMotion::MEDIUM_2)
753                        .with_curve(crate::tokens::MaterialMotion::EMPHASIZED);
754                if self.label_shown {
755                    element.label_anim.forward();
756                }
757                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
758            }
759        } else {
760            // Icon-only FAB: tear down any stale visible-label pod carried
761            // over from an Extended->Plain swap (`element` is reused across
762            // the type flip since the rail diffs same-typed `FabView`s via
763            // `rebuild_child` — see the [module docs](self)' Extended FAB
764            // section). `layout`'s square branch never lays out
765            // `visible_label`, so a pod left mounted here paints at its
766            // stale prior origin/size, outside the square — the G9
767            // device-gate defect this guards against.
768            if prev.is_extended
769                && let Some(mut pod) = element.visible_label.take()
770            {
771                let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
772                frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
773                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
774            }
775            if prev.label != self.label {
776                // The label is accessibility-only, no child to reconcile.
777                element.label_text = self.label.clone();
778                flags |= ChangeFlags::PAINT;
779            }
780        }
781
782        flags
783    }
784
785    fn teardown(&self, element: &mut FabWidget, ctx: &mut BuildCtx<'_>) {
786        if let (Some(icon_view), Some(pod)) = (&self.icon, element.icon.as_mut()) {
787            frust::authoring::teardown_child(icon_view, pod, ctx);
788        }
789        if self.is_extended
790            && let (Some(label), Some(pod)) = (&self.label, element.visible_label.as_mut())
791        {
792            let label_view = label_view::<State>(label.clone());
793            frust::authoring::teardown_child(&label_view, pod, ctx);
794        }
795    }
796}
797
798impl FabWidget {
799    /// The current corner-radius target: the override (when set, on a plain
800    /// [`fab`]) or the size-/`is_extended`-derived themed/fallback radius.
801    /// See the [module docs](self)' Corner-radius override seam section.
802    fn resolve_radius_target(&self, theme: Option<&Theme>) -> f64 {
803        if let Some(r) = self.corner_radius
804            && !self.is_extended
805        {
806            return r;
807        }
808        match theme {
809            Some(theme) => {
810                if self.is_extended {
811                    theme.shape.large
812                } else {
813                    self.size.themed_radius(&theme.shape)
814                }
815            }
816            None => {
817                if self.is_extended {
818                    EXTENDED_RADIUS_FALLBACK
819                } else {
820                    self.size.fallback_radius()
821                }
822            }
823        }
824    }
825
826    /// Advance (or seed/retarget) [`FabWidget::radius_anim`] toward
827    /// `target`, returning the radius to paint this frame. `immediate`
828    /// (the override-active case) snaps directly with no animation;
829    /// otherwise a changed target eases in over `MaterialMotion::SHORT_4`.
830    /// See the [module docs](self)' Corner-radius override seam section.
831    fn advance_radius(&mut self, ctx: &mut PaintCtx, target: f64, immediate: bool) -> f64 {
832        if !self.radius_seeded {
833            self.radius_from = target;
834            self.radius_to = target;
835            self.radius_seeded = true;
836        } else if (self.radius_to - target).abs() > f64::EPSILON {
837            if immediate {
838                self.radius_from = target;
839                self.radius_to = target;
840                self.radius_anim = AnimationController::new(Duration::ZERO);
841            } else {
842                let current = Tween::new(self.radius_from, self.radius_to)
843                    .lerp(self.radius_anim.value_clamped());
844                self.radius_from = current;
845                self.radius_to = target;
846                self.radius_anim = AnimationController::new(crate::tokens::MaterialMotion::SHORT_4)
847                    .with_curve(crate::tokens::MaterialMotion::STANDARD);
848                self.radius_anim.forward();
849            }
850        }
851        if self.radius_anim.advance(ctx.frame_time()) {
852            ctx.request_frame();
853        }
854        Tween::new(self.radius_from, self.radius_to).lerp(self.radius_anim.value_clamped())
855    }
856}
857
858impl Widget for FabWidget {
859    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
860        if !self.is_extended {
861            let container = self.size.container();
862            if let Some(icon) = self.icon.as_mut() {
863                let dim = self.size.icon_size();
864                let icon_size = icon.layout_child(ctx, &BoxConstraints::tight(Size::new(dim, dim)));
865                icon.set_origin(Point::new(
866                    (container - icon_size.width) / 2.0,
867                    (container - icon_size.height) / 2.0,
868                ));
869            }
870            return bc.constrain(Size::new(container, container));
871        }
872
873        let height = EXTENDED_HEIGHT;
874        let t = self.label_anim.value_clamped();
875        let pad = Tween::new(EXTENDED_PAD_COLLAPSED, EXTENDED_PAD_EXPANDED).lerp(t);
876        let has_icon = self.icon.is_some();
877
878        let mut x = pad;
879        if let Some(icon) = self.icon.as_mut() {
880            let icon_size = icon.layout_child(
881                ctx,
882                &BoxConstraints::tight(Size::new(EXTENDED_ICON, EXTENDED_ICON)),
883            );
884            icon.set_origin(Point::new(x, (height - icon_size.height) / 2.0));
885            x += icon_size.width;
886        }
887        if let Some(label) = self.visible_label.as_mut() {
888            let label_size = label.layout_child(
889                ctx,
890                &BoxConstraints::loose(Size::new(f64::INFINITY, height)),
891            );
892            let gap = if has_icon {
893                EXTENDED_ICON_LABEL_GAP * t
894            } else {
895                0.0
896            };
897            x += gap;
898            label.set_origin(Point::new(x, (height - label_size.height) / 2.0));
899            x += label_size.width * t;
900        }
901        x += pad;
902        bc.constrain(Size::new(x, height))
903    }
904
905    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
906        // Resolve every theme-derived value up front, before any `&mut ctx`
907        // use below (`advance_radius`/`request_frame`) — `theme` borrows
908        // `ctx` shared, and its last use must precede a later mutable
909        // reborrow for this to typecheck under NLL (mirrors
910        // `frust_widgets::Button::paint`'s own documented ordering).
911        let theme = Theme::from_paint_ctx(ctx);
912        let (container_color, content_color) = resolve_colors(theme, self.color);
913        let hovered = ctx.is_hovered();
914        let radius_target = self.resolve_radius_target(theme);
915        let (blur, y_offset, shadow_color) = resolve_shadow(theme, hovered);
916
917        self.state_layer.set_hovered(hovered);
918
919        let immediate = self.corner_radius.is_some() && !self.is_extended;
920        let radius = self.advance_radius(ctx, radius_target, immediate);
921
922        if self.press_anim.advance(ctx.frame_time()) {
923            ctx.request_frame();
924        }
925        let pressed_scale = if self.is_extended {
926            EXTENDED_PRESSED_SCALE
927        } else {
928            FAB_PRESSED_SCALE
929        };
930        let scale = Tween::new(1.0, pressed_scale).lerp(self.press_anim.value());
931
932        // The label reveal is a **layout** value — `FabWidget::layout`'s
933        // extended branch lerps pad/gap/label width straight off
934        // `label_anim.value_clamped()` — so an in-flight reveal needs an
935        // explicit relayout request, not merely another frame: on the
936        // mobile intra-frame layout skip (`docs/SHELLS_ARCHITECTURE.md`'s
937        // `frame_gate`), layout does not re-run merely because paint asked
938        // for another frame. This mirrors the trap
939        // [`mod@super::navigation_rail`]'s width motion and
940        // [`mod@super::expandable_list`]'s reveal both document at length;
941        // comparing the value before/after (rather than trusting
942        // `advance`'s return alone) catches the exact settling frame, where
943        // `advance` reports "done" but `value` still moved to its final
944        // target on this call — the same reason `WidthMotion`'s caller does
945        // the same comparison.
946        let label_before = self.label_anim.value_clamped();
947        let label_animating = self.label_anim.advance(ctx.frame_time());
948        if label_animating || self.label_anim.value_clamped() != label_before {
949            ctx.request_layout();
950        }
951
952        let o = ctx.origin();
953        let size = ctx.size();
954        let pivot = Point::new(o.x + size.width / 2.0, o.y + size.height / 2.0);
955        scene.push_transform(scale_about(pivot, scale));
956
957        scene.draw_shadow(
958            Point::new(o.x, o.y + y_offset),
959            size,
960            radius,
961            blur,
962            shadow_color,
963        );
964        scene.fill_rounded_rect(o, size, radius, container_color);
965        self.state_layer.paint(
966            ctx,
967            scene,
968            Rect::from_origin_size(o, size),
969            radius,
970            content_color,
971        );
972        if let Some(icon) = self.icon.as_mut() {
973            icon.paint_child(ctx, scene);
974        }
975        // Belt-and-braces: `layout`'s square branch never lays out
976        // `visible_label`, so gate its paint on `is_extended` too — the
977        // `rebuild` fix above already keeps `visible_label` `None` whenever
978        // `is_extended` is false, but this keeps paint itself from ever
979        // drawing a pod at a stale origin if that invariant is broken later
980        // (see G9's device-gate note on [`FabView::rebuild`]).
981        if self.is_extended
982            && let Some(label) = self.visible_label.as_mut()
983        {
984            label.paint_child(ctx, scene);
985        }
986
987        scene.pop_transform();
988    }
989
990    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
991        let InputEvent::Pointer(p) = event else {
992            return EventResult::Ignored;
993        };
994        match p.phase {
995            PointerPhase::Down => {
996                if !presses(p) {
997                    return EventResult::Ignored;
998                }
999                self.pressed = true;
1000                self.captured = true;
1001                self.state_layer.set_pressed(true);
1002                self.press_anim.fling(FLING_VELOCITY, PRESS_SPRING);
1003                ctx.capture_pointer();
1004                ctx.request_redraw();
1005                EventResult::Handled
1006            }
1007            PointerPhase::Move => {
1008                if !self.captured {
1009                    // No capture: this is the hover pass — see the [module
1010                    // docs](self)' Elevation section and
1011                    // `docs/CODE_STANDARDS.md`'s Interaction Semantics.
1012                    let over = inside(p.position, ctx.size());
1013                    if over {
1014                        ctx.claim_hover();
1015                    }
1016                    if self.state_layer.set_hovered(over) {
1017                        ctx.request_redraw();
1018                    }
1019                    return EventResult::Ignored;
1020                }
1021                let inside_now = inside(p.position, ctx.size());
1022                self.pressed = inside_now;
1023                self.state_layer.set_pressed(inside_now);
1024                ctx.request_redraw();
1025                EventResult::Handled
1026            }
1027            PointerPhase::Up => {
1028                if !self.captured {
1029                    return EventResult::Ignored;
1030                }
1031                if inside(p.position, ctx.size()) {
1032                    (self.on_press)(ctx);
1033                }
1034                self.pressed = false;
1035                self.captured = false;
1036                self.state_layer.set_pressed(false);
1037                self.press_anim.fling(-FLING_VELOCITY, PRESS_SPRING);
1038                ctx.request_redraw();
1039                EventResult::Handled
1040            }
1041            PointerPhase::Cancel => {
1042                if !self.captured {
1043                    return EventResult::Ignored;
1044                }
1045                self.pressed = false;
1046                self.captured = false;
1047                self.state_layer.set_pressed(false);
1048                self.press_anim.fling(-FLING_VELOCITY, PRESS_SPRING);
1049                ctx.request_redraw();
1050                EventResult::Handled
1051            }
1052        }
1053    }
1054
1055    fn semantics(&self, ctx: &mut SemanticsCtx) {
1056        ctx.push_node(Role::Button, |node| {
1057            if let Some(label) = &self.label_text {
1058                node.set_label(label.as_str());
1059            }
1060            node.add_action(Action::Click);
1061        });
1062    }
1063
1064    frust::authoring::visit_children!(icon, visible_label);
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use frust::FrameTime;
1071    use frust_widgets::test_support::leaf_any;
1072    use std::any::Any;
1073
1074    fn ft_secs(s: f64) -> FrameTime {
1075        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1076    }
1077
1078    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1079        InputEvent::Pointer(frust::authoring::PointerEvent {
1080            phase,
1081            position: Point::new(x, y),
1082            button: frust::authoring::PointerButton::Primary,
1083        })
1084    }
1085
1086    /// Records each rounded rect's `(origin, size, radius, color)`, each
1087    /// shadow's `(origin, size, radius, std_dev, color)`, and push/pop
1088    /// transform activity.
1089    #[derive(Default)]
1090    struct Recorder {
1091        rrects: Vec<(Point, Size, f64, Color)>,
1092        shadows: Vec<(Point, Size, f64, f64, Color)>,
1093        transforms: Vec<Affine>,
1094        transform_pops: u32,
1095    }
1096
1097    impl PaintScene for Recorder {
1098        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1099        fn draw_text(&mut self, _o: Point, _t: &str) {}
1100        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1101            self.rrects.push((o, s, radius, color));
1102        }
1103        fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
1104            self.shadows.push((o, s, radius, std_dev, color));
1105        }
1106        fn push_transform(&mut self, transform: Affine) {
1107            self.transforms.push(transform);
1108        }
1109        fn pop_transform(&mut self) {
1110            self.transform_pops += 1;
1111        }
1112    }
1113
1114    fn build_icon_fab(size: FabSize) -> FabWidget {
1115        let view = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(size);
1116        let mut counter = 0u64;
1117        View::<()>::build(&view, &mut BuildCtx::new(&mut counter))
1118    }
1119
1120    #[test]
1121    fn container_sizes_match_m3e_theme_table() {
1122        // m3e_fab_theme.dart: smallContainer=40, mediumContainer=56,
1123        // largeContainer=96.
1124        assert_eq!(FabSize::Small.container(), 40.0);
1125        assert_eq!(FabSize::Medium.container(), 56.0);
1126        assert_eq!(FabSize::Large.container(), 96.0);
1127    }
1128
1129    #[test]
1130    fn icon_sizes_match_m3e_theme_table() {
1131        // m3e_fab_theme.dart: smallIconSize=24, mediumIconSize=24,
1132        // largeIconSize=36.
1133        assert_eq!(FabSize::Small.icon_size(), 24.0);
1134        assert_eq!(FabSize::Medium.icon_size(), 24.0);
1135        assert_eq!(FabSize::Large.icon_size(), 36.0);
1136    }
1137
1138    #[test]
1139    fn themed_radii_match_m3e_theme_table() {
1140        // m3e_fab_theme.dart: smallRadius=12, mediumRadius=16,
1141        // largeRadius=28 — this crate maps them onto shape.medium/large/
1142        // extra_large, whose baseline values coincide exactly.
1143        let theme = crate::baseline();
1144        assert_eq!(FabSize::Small.themed_radius(&theme.shape), 12.0);
1145        assert_eq!(FabSize::Medium.themed_radius(&theme.shape), 16.0);
1146        assert_eq!(FabSize::Large.themed_radius(&theme.shape), 28.0);
1147    }
1148
1149    #[test]
1150    fn color_mapping_matches_m3e_theme_table_themed() {
1151        let theme = crate::baseline();
1152        let s = theme.scheme();
1153        assert_eq!(
1154            resolve_colors(Some(&theme), FabColor::Primary),
1155            (s.primary_container, s.on_primary_container)
1156        );
1157        assert_eq!(
1158            resolve_colors(Some(&theme), FabColor::Secondary),
1159            (s.secondary_container, s.on_secondary_container)
1160        );
1161        assert_eq!(
1162            resolve_colors(Some(&theme), FabColor::Tertiary),
1163            (s.tertiary_container, s.on_tertiary_container)
1164        );
1165        // Surface's content color is `primary`, NOT `on_surface` — verified
1166        // from `m3e_fab_theme.dart`'s `_palette` switch.
1167        assert_eq!(
1168            resolve_colors(Some(&theme), FabColor::Surface),
1169            (s.surface_container_high, s.primary)
1170        );
1171    }
1172
1173    #[test]
1174    fn color_mapping_matches_m3e_theme_table_unthemed() {
1175        assert_eq!(
1176            resolve_colors(None, FabColor::Primary),
1177            (PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER)
1178        );
1179        assert_eq!(
1180            resolve_colors(None, FabColor::Secondary),
1181            (SECONDARY_CONTAINER, ON_SECONDARY_CONTAINER)
1182        );
1183        assert_eq!(
1184            resolve_colors(None, FabColor::Tertiary),
1185            (TERTIARY_CONTAINER, ON_TERTIARY_CONTAINER)
1186        );
1187        assert_eq!(
1188            resolve_colors(None, FabColor::Surface),
1189            (SURFACE_CONTAINER_HIGH, SURFACE_FOREGROUND)
1190        );
1191    }
1192
1193    #[test]
1194    fn press_spring_matches_reference() {
1195        // m3e_floating_action_buttons.dart's module doc: "plays a spatial
1196        // spring press scale (380 / 0.55)" == M3EMotion.expressiveSpatialPress.
1197        let spring = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
1198        assert_eq!(PRESS_SPRING.stiffness, spring.stiffness);
1199        assert_eq!(PRESS_SPRING.damping_ratio, spring.damping_ratio);
1200        assert_eq!(PRESS_SPRING.mass, 1.0);
1201    }
1202
1203    #[test]
1204    fn pressed_scale_targets_match_m3e_theme_table() {
1205        // M3EFabTheme.pressedScale=0.95, M3EExtendedFabTheme.pressedScale=0.97.
1206        assert_eq!(FAB_PRESSED_SCALE, 0.95);
1207        assert_eq!(EXTENDED_PRESSED_SCALE, 0.97);
1208    }
1209
1210    #[test]
1211    fn layout_is_a_fixed_square_at_the_container_size() {
1212        let mut w = build_icon_fab(FabSize::Large);
1213        let mut lctx = LayoutCtx::new();
1214        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1215        assert_eq!(size, Size::new(96.0, 96.0));
1216        let icon = w.icon.as_ref().unwrap();
1217        assert_eq!(icon.size(), Size::new(36.0, 36.0));
1218        // centered
1219        assert_eq!(icon.origin(), Point::new(30.0, 30.0));
1220    }
1221
1222    #[test]
1223    fn unthemed_paint_uses_fallback_container_and_shadow() {
1224        let mut w = build_icon_fab(FabSize::Medium);
1225        let mut lctx = LayoutCtx::new();
1226        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1227        let mut rec = Recorder::default();
1228        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
1229        w.paint(&mut pctx, &mut rec);
1230
1231        assert_eq!(rec.rrects[0].3, PRIMARY_CONTAINER);
1232        assert_eq!(rec.rrects[0].2, MEDIUM_RADIUS);
1233        assert_eq!(rec.shadows.len(), 1, "a resting FAB paints one shadow");
1234        assert_eq!(rec.shadows[0].3, LEVEL3_SHADOW_BLUR);
1235        assert_eq!(rec.shadows[0].4, FALLBACK_SHADOW_COLOR);
1236        assert_eq!(
1237            rec.shadows[0].0,
1238            Point::new(0.0, LEVEL3_SHADOW_Y_OFFSET),
1239            "the shadow origin is offset by y_offset"
1240        );
1241        assert_eq!(rec.transforms.len(), 1, "one press-scale transform pushed");
1242        assert_eq!(rec.transform_pops, 1, "paired with one pop");
1243    }
1244
1245    #[test]
1246    fn themed_paint_resolves_primary_container_tokens() {
1247        let theme = crate::baseline();
1248        let mut w = build_icon_fab(FabSize::Small);
1249        let mut lctx = LayoutCtx::new();
1250        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1251        let mut rec = Recorder::default();
1252        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0)).with_theme(&theme);
1253        w.paint(&mut pctx, &mut rec);
1254
1255        let scheme = theme.scheme();
1256        assert_eq!(rec.rrects[0].3, scheme.primary_container);
1257        assert_eq!(rec.rrects[0].2, theme.shape.medium);
1258        assert_eq!(
1259            rec.shadows[0].4.components[3],
1260            theme.elevation.level3.shadow(theme.brightness).color_alpha
1261        );
1262    }
1263
1264    #[test]
1265    fn extended_layout_has_fixed_height_and_grows_width() {
1266        let view: FabView<()> =
1267            extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1268        let mut counter = 0u64;
1269        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1270        // Settle the label reveal to fully open (see the module docs' known
1271        // v1 mount quirk: a freshly built controller hasn't been advanced
1272        // yet, so it still reads its untouched `0.0`).
1273        w.label_anim.advance(FrameTime::ZERO);
1274        w.label_anim.advance(ft_secs(1.0));
1275        let mut tcx = frust::authoring::text::TextContext::new();
1276        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1277        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1278        assert_eq!(size.height, EXTENDED_HEIGHT);
1279        assert!(size.width > EXTENDED_ICON + EXTENDED_PAD_EXPANDED * 2.0);
1280    }
1281
1282    #[test]
1283    fn extended_label_collapse_animates_width_between_endpoints() {
1284        // Fully extended: settle the label reveal to 1.0.
1285        let extended_view: FabView<()> =
1286            extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1287        let mut counter = 0u64;
1288        let mut w = View::<()>::build(&extended_view, &mut BuildCtx::new(&mut counter));
1289        w.label_anim.advance(FrameTime::ZERO);
1290        w.label_anim.advance(ft_secs(1.0)); // >> MEDIUM_2 (300ms): settles at 1.0
1291        assert_eq!(w.label_anim.value_clamped(), 1.0);
1292        let mut tcx = frust::authoring::text::TextContext::new();
1293        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1294        let extended_size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1295
1296        // Fully collapsed: built with `.extended(false)` — the controller's
1297        // untouched resting value (`0.0`) already matches, no advance needed.
1298        let collapsed_view: FabView<()> = extended_fab("Compose", |_s: &mut ()| {})
1299            .icon(leaf_any(24.0, 24.0))
1300            .extended(false);
1301        let mut w2 = View::<()>::build(&collapsed_view, &mut BuildCtx::new(&mut counter));
1302        assert_eq!(w2.label_anim.value_clamped(), 0.0);
1303        let mut tcx2 = frust::authoring::text::TextContext::new();
1304        let mut lctx2 = LayoutCtx::with_text_context(&mut tcx2 as &mut dyn Any);
1305        let collapsed_size = w2.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1306
1307        assert_eq!(extended_size.height, EXTENDED_HEIGHT);
1308        assert_eq!(collapsed_size.height, EXTENDED_HEIGHT);
1309        assert!(
1310            collapsed_size.width < extended_size.width,
1311            "a collapsed extended FAB is narrower than a fully open one"
1312        );
1313        // The collapsed pill's exact M3E formula: pad(16)*2 + icon(24) = 56.
1314        assert_eq!(
1315            collapsed_size.width,
1316            EXTENDED_PAD_COLLAPSED * 2.0 + EXTENDED_ICON
1317        );
1318    }
1319
1320    /// The regression guard for G9 (device-gate round 3): the rail swaps
1321    /// `extended_fab()`/`fab()` via `rebuild_child` on the same `FabView`
1322    /// type when its type toggles — pre-fix, `visible_label` was built only
1323    /// in `build()`, so an Extended->Plain swap left the label pod mounted,
1324    /// painting stale text outside the collapsed square (screenshot-
1325    /// confirmed on the Xiaomi).
1326    #[test]
1327    fn rebuild_extended_to_plain_tears_down_the_stale_label_pod() {
1328        let extended_view: FabView<()> =
1329            extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1330        let mut counter = 0u64;
1331        let mut w = View::<()>::build(&extended_view, &mut BuildCtx::new(&mut counter));
1332        assert!(
1333            w.visible_label.is_some(),
1334            "an extended FAB mounts a visible label pod"
1335        );
1336
1337        let plain_view: FabView<()> = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {});
1338        View::<()>::rebuild(
1339            &plain_view,
1340            &extended_view,
1341            &mut w,
1342            &mut BuildCtx::new(&mut counter),
1343        );
1344        assert!(
1345            w.visible_label.is_none(),
1346            "swapping to a plain FAB tears down the stale label pod"
1347        );
1348
1349        let mut lctx = LayoutCtx::new();
1350        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1351        assert_eq!(
1352            size,
1353            Size::new(MEDIUM_CONTAINER, MEDIUM_CONTAINER),
1354            "the collapsed FAB lays out as the plain square, not the old pill"
1355        );
1356    }
1357
1358    /// The reverse of the above: a Plain->Extended swap must mount the
1359    /// label pod and reveal-animate it in across advanced frames, not snap
1360    /// straight to the open pill from whatever value `label_anim` drifted
1361    /// to while this FAB was plain (`paint` advances it every frame
1362    /// regardless of `is_extended`).
1363    #[test]
1364    fn rebuild_plain_to_extended_mounts_the_label_and_reveal_animates() {
1365        let plain_view: FabView<()> = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {});
1366        let mut counter = 0u64;
1367        let mut w = View::<()>::build(&plain_view, &mut BuildCtx::new(&mut counter));
1368        // Drift the plain widget's (layout-irrelevant) `label_anim` to fully
1369        // settled, exactly as real paint calls would over several frames —
1370        // this is the state a naively-reused controller would wrongly
1371        // inherit on the swap below.
1372        w.label_anim.advance(FrameTime::ZERO);
1373        w.label_anim.advance(ft_secs(1.0));
1374        assert_eq!(w.label_anim.value_clamped(), 1.0);
1375        assert!(w.visible_label.is_none());
1376
1377        let extended_view: FabView<()> =
1378            extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1379        View::<()>::rebuild(
1380            &extended_view,
1381            &plain_view,
1382            &mut w,
1383            &mut BuildCtx::new(&mut counter),
1384        );
1385        assert!(
1386            w.visible_label.is_some(),
1387            "swapping to an extended FAB mounts the label pod"
1388        );
1389
1390        let mut tcx = frust::authoring::text::TextContext::new();
1391        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1392        let just_swapped_size =
1393            w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1394
1395        // Settle the label reveal to fully open (the module docs' known v1
1396        // mount quirk: the first `advance` after a fresh `forward()` only
1397        // seeds the clock).
1398        w.label_anim.advance(FrameTime::ZERO);
1399        w.label_anim.advance(ft_secs(1.0));
1400        let mut tcx2 = frust::authoring::text::TextContext::new();
1401        let mut lctx2 = LayoutCtx::with_text_context(&mut tcx2 as &mut dyn Any);
1402        let grown_size = w.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1403
1404        assert!(
1405            grown_size.width > just_swapped_size.width,
1406            "the label grows in across advanced frames: {just_swapped_size:?} -> {grown_size:?}"
1407        );
1408    }
1409
1410    /// The regression guard for the layout-skip trap (see the module docs'
1411    /// Extended FAB section): the pill's width is computed in `layout` from
1412    /// `label_anim.value_clamped()`, so every in-flight reveal frame must ask
1413    /// for a relayout — a paint-only frame request would freeze the pill at
1414    /// its collapsed (~56dp) square width under the mobile intra-frame
1415    /// layout skip, exactly the screenshot-confirmed device defect this test
1416    /// pins.
1417    #[test]
1418    fn paint_requests_layout_while_label_reveal_is_in_flight_and_stops_once_settled() {
1419        fn layout_at(w: &mut FabWidget) -> Size {
1420            let mut tcx = frust::authoring::text::TextContext::new();
1421            let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1422            w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)))
1423        }
1424        fn paint_at(w: &mut FabWidget, size: Size, t: f64) -> (Recorder, bool) {
1425            let mut rec = Recorder::default();
1426            let mut ctx = PaintCtx::for_test(Point::ZERO, size, ft_secs(t));
1427            w.paint(&mut ctx, &mut rec);
1428            let needs_layout = ctx.needs_layout();
1429            (rec, needs_layout)
1430        }
1431
1432        let collapsed: FabView<()> = extended_fab("Compose", |_s: &mut ()| {})
1433            .icon(leaf_any(24.0, 24.0))
1434            .extended(false);
1435        let mut counter = 0u64;
1436        let mut w = View::<()>::build(&collapsed, &mut BuildCtx::new(&mut counter));
1437        let mut size = layout_at(&mut w);
1438        let collapsed_width = size.width;
1439
1440        // Rebuild into the extended state: this starts the reveal
1441        // (`label_anim.forward()`) but hasn't advanced it yet — layout still
1442        // reads the untouched `0.0`, matching the collapsed width exactly
1443        // (the module docs' known v1 mount quirk).
1444        let expanded: FabView<()> =
1445            extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1446        View::<()>::rebuild(
1447            &expanded,
1448            &collapsed,
1449            &mut w,
1450            &mut BuildCtx::new(&mut counter),
1451        );
1452        size = layout_at(&mut w);
1453        assert_eq!(size.width, collapsed_width);
1454
1455        let mut widths = vec![size.width];
1456        let mut frames = 0u32;
1457        let mut t = 0.0;
1458        loop {
1459            let (_, needs_layout) = paint_at(&mut w, size, t);
1460            if !needs_layout {
1461                break;
1462            }
1463            frames += 1;
1464            // A real shell relayouts on seeing `needs_layout` — this is the
1465            // exact step the pre-fix code never asked for, freezing the pill
1466            // at `collapsed_width` while the label kept painting past it.
1467            size = layout_at(&mut w);
1468            widths.push(size.width);
1469            t += 1.0 / 60.0;
1470            assert!(
1471                frames < 600,
1472                "the reveal should settle well inside 600 frames"
1473            );
1474        }
1475        assert!(frames > 1, "the reveal spans more than one frame");
1476        assert!(
1477            widths.windows(2).all(|pair| pair[1] + 1e-9 >= pair[0]),
1478            "the pill's laid-out width never shrinks while opening: {widths:?}"
1479        );
1480        let last = *widths.last().expect("at least one frame recorded");
1481        assert!(
1482            last > collapsed_width,
1483            "the settled pill is wider than the frozen collapsed square: {last} > {collapsed_width}"
1484        );
1485
1486        // A settled pill stops asking for layout.
1487        let (_, still) = paint_at(&mut w, size, t + 1.0 / 60.0);
1488        assert!(!still, "settled: no more layout requests");
1489    }
1490
1491    #[test]
1492    fn corner_radius_override_is_immediate_no_animation() {
1493        let view: FabView<()> =
1494            fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).corner_radius(Some(40.0));
1495        let mut counter = 0u64;
1496        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1497        let mut lctx = LayoutCtx::new();
1498        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1499
1500        let mut rec = Recorder::default();
1501        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
1502        w.paint(&mut pctx, &mut rec);
1503        assert_eq!(rec.rrects[0].2, 40.0, "paints the override radius exactly");
1504
1505        // A second, different override snaps immediately too — no lingering
1506        // easing from the first value.
1507        let view2: FabView<()> =
1508            fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).corner_radius(Some(8.0));
1509        let mut lctx2 = LayoutCtx::new();
1510        View::<()>::rebuild(&view2, &view, &mut w, &mut BuildCtx::new(&mut counter));
1511        w.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1512        let mut rec2 = Recorder::default();
1513        let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
1514        w.paint(&mut pctx2, &mut rec2);
1515        assert_eq!(
1516            rec2.rrects[0].2, 8.0,
1517            "a changed override still snaps immediately, no ease from the prior value"
1518        );
1519    }
1520
1521    #[test]
1522    fn corner_radius_none_eases_a_changed_target_with_short_4() {
1523        let mut w = build_icon_fab(FabSize::Small);
1524        let mut lctx = LayoutCtx::new();
1525        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1526
1527        let theme = crate::baseline();
1528        // First paint: seeds at the small-size themed radius (12dp), no
1529        // animation from an unknown prior value.
1530        let mut rec = Recorder::default();
1531        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0)).with_theme(&theme);
1532        w.paint(&mut pctx, &mut rec);
1533        assert_eq!(rec.rrects[0].2, 12.0);
1534
1535        // Switch to Large (target 28dp): the very next paint call must
1536        // still read the *old* radius (progress not yet advanced past the
1537        // clock-seeding first `advance`), then settle at 28dp once advanced
1538        // further — matching `MaterialMotion::SHORT_4` (200ms).
1539        let prev_view: FabView<()> =
1540            fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(FabSize::Small);
1541        let next_view: FabView<()> =
1542            fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(FabSize::Large);
1543        let mut counter = 1u64;
1544        View::<()>::rebuild(
1545            &next_view,
1546            &prev_view,
1547            &mut w,
1548            &mut BuildCtx::new(&mut counter),
1549        );
1550        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1551
1552        let mut rec2 = Recorder::default();
1553        let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(96.0, 96.0)).with_theme(&theme);
1554        w.paint(&mut pctx2, &mut rec2);
1555        assert_eq!(
1556            rec2.rrects[0].2, 12.0,
1557            "the change-triggering frame hasn't eased yet (first advance only seeds the clock)"
1558        );
1559
1560        // Advance well past SHORT_4 (200ms): settles at the new target.
1561        w.radius_anim.advance(ft_secs(1.0));
1562        let mut rec3 = Recorder::default();
1563        let mut pctx3 = PaintCtx::new(Point::ZERO, Size::new(96.0, 96.0)).with_theme(&theme);
1564        w.paint(&mut pctx3, &mut rec3);
1565        assert_eq!(
1566            rec3.rrects[0].2, 28.0,
1567            "settled at the new (Large) themed radius"
1568        );
1569    }
1570
1571    /// A minimal `State`-generic icon stand-in (`test_support::leaf`/`leaf_any`
1572    /// only implement `View<()>`; the interactive tests below need a generic
1573    /// `State`, so a plain themed [`frust::text`] run stands in for the
1574    /// icon — its content is irrelevant to firing behavior).
1575    fn icon_stub<State: 'static>() -> AnyView<State> {
1576        frust::authoring::any::<State, _>(frust::text("i"))
1577    }
1578
1579    fn dispatch(w: &mut FabWidget, state: &mut u32, event: &InputEvent, size: Size) {
1580        let state_any: &mut dyn Any = state;
1581        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1582        w.event(&mut ctx, event);
1583    }
1584
1585    #[test]
1586    fn up_inside_fires_on_press() {
1587        let view: FabView<u32> =
1588            fab(icon_stub::<u32>(), |s: &mut u32| *s += 1).size(FabSize::Medium);
1589        let mut counter = 0u64;
1590        let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1591        let mut state = 0u32;
1592        let size = Size::new(56.0, 56.0);
1593        dispatch(
1594            &mut w,
1595            &mut state,
1596            &ev(PointerPhase::Down, 28.0, 28.0),
1597            size,
1598        );
1599        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 28.0, 28.0), size);
1600        assert_eq!(state, 1);
1601    }
1602
1603    #[test]
1604    fn up_outside_does_not_fire() {
1605        let view: FabView<u32> = fab(icon_stub::<u32>(), |s: &mut u32| *s += 1);
1606        let mut counter = 0u64;
1607        let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1608        let mut state = 0u32;
1609        let size = Size::new(56.0, 56.0);
1610        dispatch(
1611            &mut w,
1612            &mut state,
1613            &ev(PointerPhase::Down, 10.0, 10.0),
1614            size,
1615        );
1616        dispatch(
1617            &mut w,
1618            &mut state,
1619            &ev(PointerPhase::Move, 500.0, 500.0),
1620            size,
1621        );
1622        dispatch(
1623            &mut w,
1624            &mut state,
1625            &ev(PointerPhase::Up, 500.0, 500.0),
1626            size,
1627        );
1628        assert_eq!(state, 0);
1629    }
1630
1631    #[test]
1632    fn cancel_clears_armed_state_without_firing() {
1633        let view: FabView<u32> = fab(icon_stub::<u32>(), |s: &mut u32| *s += 1);
1634        let mut counter = 0u64;
1635        let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1636        let mut state = 0u32;
1637        let size = Size::new(56.0, 56.0);
1638        dispatch(
1639            &mut w,
1640            &mut state,
1641            &ev(PointerPhase::Down, 10.0, 10.0),
1642            size,
1643        );
1644        assert!(w.captured);
1645        dispatch(
1646            &mut w,
1647            &mut state,
1648            &ev(PointerPhase::Cancel, 10.0, 10.0),
1649            size,
1650        );
1651        assert!(!w.captured);
1652        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0), size);
1653        assert_eq!(state, 0);
1654    }
1655
1656    #[test]
1657    fn hover_move_outside_bounds_without_down_is_ignored_noop() {
1658        let mut w = build_icon_fab(FabSize::Medium);
1659        let mut state = 0u32;
1660        let state_any: &mut dyn Any = &mut state;
1661        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(56.0, 56.0));
1662        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 500.0, 500.0));
1663        assert!(matches!(result, EventResult::Ignored));
1664        assert!(
1665            !ctx.needs_redraw(),
1666            "outside the bounds, nothing claims hover"
1667        );
1668    }
1669
1670    #[test]
1671    fn hover_move_inside_bounds_without_down_claims_hover_and_redraws() {
1672        // Still `Ignored` — an uncaptured hover pass never consumes the
1673        // event — but it does claim the hover link and, since the flag
1674        // changed, asks for a redraw (see the module docs' Elevation
1675        // section and `docs/CODE_STANDARDS.md`'s Interaction Semantics).
1676        let mut w = build_icon_fab(FabSize::Medium);
1677        let mut state = 0u32;
1678        let state_any: &mut dyn Any = &mut state;
1679        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(56.0, 56.0));
1680        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 10.0, 10.0));
1681        assert!(matches!(result, EventResult::Ignored));
1682        assert!(ctx.needs_redraw());
1683    }
1684
1685    #[test]
1686    fn down_starts_the_press_scale_spring_toward_pressed_and_up_reverses_it() {
1687        let view: FabView<u32> = fab(icon_stub::<u32>(), |_s: &mut u32| {}).size(FabSize::Medium);
1688        let mut counter = 0u64;
1689        let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1690        let mut state = 0u32;
1691        let size = Size::new(56.0, 56.0);
1692        assert!(!w.press_anim.is_animating());
1693
1694        dispatch(
1695            &mut w,
1696            &mut state,
1697            &ev(PointerPhase::Down, 28.0, 28.0),
1698            size,
1699        );
1700        assert!(
1701            w.press_anim.is_animating(),
1702            "Down starts the fling toward pressed"
1703        );
1704
1705        // Settle the fling (bounded loop, mirrors `frust-core::anim`'s own
1706        // settle-loop test pattern).
1707        let mut t = 0.0;
1708        let mut running = true;
1709        for _ in 0..100_000 {
1710            running = w.press_anim.advance(ft_secs(t));
1711            if !running {
1712                break;
1713            }
1714            t += 1.0 / 120.0;
1715        }
1716        assert!(!running, "press-in fling failed to settle");
1717        let pressed_scale = Tween::new(1.0, FAB_PRESSED_SCALE).lerp(w.press_anim.value());
1718        assert!((pressed_scale - FAB_PRESSED_SCALE).abs() < 1e-6);
1719
1720        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 28.0, 28.0), size);
1721        assert!(
1722            w.press_anim.is_animating(),
1723            "Up starts the fling back to rest"
1724        );
1725        t = 0.0;
1726        running = true;
1727        for _ in 0..100_000 {
1728            running = w.press_anim.advance(ft_secs(t));
1729            if !running {
1730                break;
1731            }
1732            t += 1.0 / 120.0;
1733        }
1734        assert!(!running, "press-out fling failed to settle");
1735        let rest_scale = Tween::new(1.0, FAB_PRESSED_SCALE).lerp(w.press_anim.value());
1736        assert!((rest_scale - 1.0).abs() < 1e-6);
1737    }
1738
1739    #[test]
1740    fn semantics_reports_button_role_and_label() {
1741        fn logic(_s: &mut ()) -> FabView<()> {
1742            fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).label("Compose")
1743        }
1744        let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
1745        let mut state = ();
1746        root.rebuild(&mut logic, &mut state);
1747        root.layout(Size::new(200.0, 200.0));
1748        let update = root.semantics();
1749        let (_, node) = update
1750            .nodes
1751            .iter()
1752            .find(|(_, n)| n.role() == Role::Button)
1753            .expect("fab contributes a Role::Button node");
1754        assert_eq!(node.label(), Some("Compose"));
1755        assert!(node.supports_action(Action::Click));
1756    }
1757
1758    #[test]
1759    fn extended_semantics_uses_visible_label_as_accessible_name() {
1760        fn logic(_s: &mut ()) -> FabView<()> {
1761            extended_fab::<(), _>("New item", |_s: &mut ()| {})
1762        }
1763        let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
1764        let mut state = ();
1765        root.rebuild(&mut logic, &mut state);
1766        let mut tcx = frust::authoring::text::TextContext::new();
1767        root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1768        let update = root.semantics();
1769        let (_, node) = update
1770            .nodes
1771            .iter()
1772            .find(|(_, n)| n.role() == Role::Button)
1773            .expect("extended fab contributes a Role::Button node");
1774        assert_eq!(node.label(), Some("New item"));
1775    }
1776
1777    /// A single FAB under a real `RenderRoot` — the only harness that can
1778    /// exercise hover at all, since the hover link is recorded by the
1779    /// root's event pass and read back through `PaintCtx::is_hovered`
1780    /// (mirrors `list_item.rs`'s own `HoverHarness`).
1781    #[test]
1782    fn hover_lifts_elevation_from_level3_to_level4() {
1783        fn logic(_s: &mut ()) -> FabView<()> {
1784            fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {})
1785        }
1786        let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
1787        let mut state = ();
1788        root.rebuild(&mut logic, &mut state);
1789        root.layout(Size::new(200.0, 200.0));
1790
1791        let mut rec = Recorder::default();
1792        root.paint(&mut rec, frust::FrameTime::ZERO);
1793        assert_eq!(
1794            rec.shadows[0].3, LEVEL3_SHADOW_BLUR,
1795            "resting FAB is at level 3"
1796        );
1797
1798        root.event(
1799            &mut state,
1800            &InputEvent::Pointer(frust::authoring::PointerEvent {
1801                phase: PointerPhase::Move,
1802                position: Point::new(28.0, 28.0),
1803                button: frust::authoring::PointerButton::Primary,
1804            }),
1805        );
1806        let mut rec2 = Recorder::default();
1807        root.paint(&mut rec2, frust::FrameTime::ZERO);
1808        assert_eq!(
1809            rec2.shadows[0].3, LEVEL4_SHADOW_BLUR,
1810            "a hovered FAB lifts to level 4"
1811        );
1812    }
1813}