Skip to main content

frust_material/button/
core.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/buttons/` tree is itself vendored from m3e_buttons
3// (MIT, © 2026 Mudit Purohit) — `styles/m3e_button_theme.dart` (tables),
4// `components/m3e_button_style.dart` (per-state resolvers),
5// `components/m3e_button_content.dart` (shape + assembly),
6// `components/m3e_button_state.dart` (icon layout),
7// `components/m3e_focus_ring.dart` (focus ring).
8// Porting decision: the Dart widget hands its surface to Flutter's own
9// FilledButton/OutlinedButton/…; this file paints it directly.
10
11//! [`super::ButtonWidget`]'s retained core: the per-size metric tables, the
12//! per-variant × per-state color resolvers, the icon+label content layout, and
13//! the layout/paint/event passes that drive them. The public shape of all this
14//! — the tables, the mapping decisions, the seams — is documented on [the
15//! parent module](super); this file carries each citation next to the value it
16//! pins.
17
18use frust::Theme;
19use frust::authoring::text::{
20    FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
21};
22use frust::authoring::{
23    Action, BoxConstraints, CursorIcon, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
24    PaintScene, PointerPhase, Role, SemanticsCtx, Widget,
25};
26use kurbo::{Point, RoundedRect, Shape, Size};
27use peniko::{Brush, Color};
28
29use super::gradient;
30use super::motion::ContentPadding;
31use super::overflow::max_scroll;
32use super::{
33    ButtonShape, ButtonSize, ButtonVariant, ButtonWidget, IconAlignment, OverflowStrategy,
34};
35use crate::interaction::{
36    DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
37    MaterialHaptics,
38};
39use crate::press::presses;
40
41// ---- Metric tables (m3e_button_theme.dart) ---------------------------------
42
43/// The floor every button's width is held above, in logical px
44/// (`M3EButtonTheme.minWidthFloor`, `m3e_button_theme.dart:16`).
45const MIN_WIDTH_FLOOR: f64 = 48.0;
46
47/// Stroke width of an [`ButtonVariant::Outlined`] button's hairline, in
48/// logical px — Flutter's own `BorderSide` default, which
49/// `m3e_button_style.dart:135` constructs with a color only.
50const OUTLINE_WIDTH: f64 = 1.0;
51
52/// Gap between the container edge and the focus ring, in logical px
53/// (`M3EButtonConstants.kFocusRingGap`, `m3e_button_constants.dart:21`).
54const FOCUS_RING_GAP: f64 = 2.0;
55/// Focus-ring stroke width, in logical px
56/// (`M3EButtonConstants.kFocusRingWidth`, `m3e_button_constants.dart:24`).
57const FOCUS_RING_WIDTH: f64 = 2.0;
58
59/// Flattening tolerance for the `kurbo` rounded-rect paths this module strokes
60/// (matches [`mod@crate::card`]'s `STROKE_TOLERANCE`). `pub(super)`: also the
61/// tolerance [`super::gradient`]'s outline layer flattens its stroke path at,
62/// so a solid and a gradient hairline agree pixel-for-pixel.
63pub(super) const PATH_TOLERANCE: f64 = 0.1;
64
65/// The ink every label run is *shaped* with; never painted — the run is
66/// re-brushed with its resolved per-variant/per-state color at paint time, and
67/// holding the shaping color constant keeps the shape cache from missing on a
68/// recolor (the contract [`mod@crate::text_field`]'s own runs document).
69const SHAPING_INK: Color = Color::BLACK;
70
71/// One row of `m3e_button_theme.dart:76`'s `_measurementsTable`.
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub(super) struct SizeMetrics {
74    /// Fixed container height, logical px.
75    pub(super) height: f64,
76    /// Symmetric horizontal content padding, logical px.
77    pub(super) h_padding: f64,
78    /// Icon side length, logical px.
79    pub(super) icon_size: f64,
80    /// Gap between icon and label, logical px.
81    pub(super) icon_gap: f64,
82}
83
84/// One row of the M3 type scale, as `(size, line_height, letter_spacing,
85/// weight)` — the unthemed fallback for a label role.
86type TypeToken = (f32, f32, f32, FontWeight);
87
88const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
89const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
90const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
91const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
92const TITLE_LARGE: TypeToken = (22.0, 28.0, 0.0, FontWeight::REGULAR);
93
94impl ButtonSize {
95    /// The size's row of `m3e_button_theme.dart:76`'s `_measurementsTable`.
96    pub(super) const fn metrics(self) -> SizeMetrics {
97        match self {
98            // m3e_button_theme.dart:77
99            ButtonSize::Xs => SizeMetrics {
100                height: 32.0,
101                h_padding: 16.0,
102                icon_size: 20.0,
103                icon_gap: 8.0,
104            },
105            // m3e_button_theme.dart:83
106            ButtonSize::Sm => SizeMetrics {
107                height: 40.0,
108                h_padding: 16.0,
109                icon_size: 20.0,
110                icon_gap: 8.0,
111            },
112            // m3e_button_theme.dart:89
113            ButtonSize::Md => SizeMetrics {
114                height: 56.0,
115                h_padding: 24.0,
116                icon_size: 24.0,
117                icon_gap: 8.0,
118            },
119            // m3e_button_theme.dart:95
120            ButtonSize::Lg => SizeMetrics {
121                height: 96.0,
122                h_padding: 48.0,
123                icon_size: 32.0,
124                icon_gap: 12.0,
125            },
126            // m3e_button_theme.dart:101
127            ButtonSize::Xl => SizeMetrics {
128                height: 136.0,
129                h_padding: 64.0,
130                icon_size: 40.0,
131                icon_gap: 16.0,
132            },
133        }
134    }
135
136    /// The [`ButtonShape::Square`] corner radius
137    /// (`m3e_button_theme.dart:44`'s `_squareRadiusTable`: 12/12/16/28/28).
138    /// Resolved from the theme's shape scale, whose `medium`/`large`/
139    /// `extra_large` tokens coincide with that table exactly; the literals are
140    /// the unthemed fallback.
141    pub(super) fn square_radius(self, theme: Option<&Theme>) -> f64 {
142        match theme {
143            Some(theme) => match self {
144                ButtonSize::Xs | ButtonSize::Sm => theme.shape.medium,
145                ButtonSize::Md => theme.shape.large,
146                ButtonSize::Lg | ButtonSize::Xl => theme.shape.extra_large,
147            },
148            None => match self {
149                ButtonSize::Xs | ButtonSize::Sm => 12.0,
150                ButtonSize::Md => 16.0,
151                ButtonSize::Lg | ButtonSize::Xl => 28.0,
152            },
153        }
154    }
155
156    /// The pressed corner radius (`m3e_button_theme.dart:52`'s
157    /// `_pressedRadiusTable`: 8/8/12/16/16) — one shape-scale rung squarer
158    /// than [`Self::square_radius`], and likewise theme-resolved.
159    pub(super) fn pressed_radius(self, theme: Option<&Theme>) -> f64 {
160        match theme {
161            Some(theme) => match self {
162                ButtonSize::Xs | ButtonSize::Sm => theme.shape.small,
163                ButtonSize::Md => theme.shape.medium,
164                ButtonSize::Lg | ButtonSize::Xl => theme.shape.large,
165            },
166            None => match self {
167                ButtonSize::Xs | ButtonSize::Sm => 8.0,
168                ButtonSize::Md => 12.0,
169                ButtonSize::Lg | ButtonSize::Xl => 16.0,
170            },
171        }
172    }
173
174    /// The hovered corner radius (`m3e_button_theme.dart:60`'s
175    /// `_hoveredRadiusTable`: 10/10/14/22/22). Each entry is the midpoint of
176    /// its size's square and pressed radii, which lands *between* shape-scale
177    /// tokens — so unlike the other two tables this one has nothing to resolve
178    /// from and stays literal.
179    pub(super) const fn hovered_radius(self) -> f64 {
180        match self {
181            ButtonSize::Xs | ButtonSize::Sm => 10.0,
182            ButtonSize::Md => 14.0,
183            ButtonSize::Lg | ButtonSize::Xl => 22.0,
184        }
185    }
186
187    /// The label's type role (`m3e_base_button_state.dart:169`): `labelSmall`
188    /// / `labelMedium` / `labelLarge` / `titleMedium` / `titleLarge`. Themed
189    /// from [`frust::Theme`]'s type scale; unthemed from the M3 token literals
190    /// above. The returned style always carries [`SHAPING_INK`].
191    pub(super) fn label_style(self, theme: Option<&Theme>) -> TextStyle {
192        let mut style = match theme {
193            Some(theme) => match self {
194                ButtonSize::Xs => theme.type_scale.label_small.clone(),
195                ButtonSize::Sm => theme.type_scale.label_medium.clone(),
196                ButtonSize::Md => theme.type_scale.label_large.clone(),
197                ButtonSize::Lg => theme.type_scale.title_medium.clone(),
198                ButtonSize::Xl => theme.type_scale.title_large.clone(),
199            },
200            None => {
201                let (size, line_height, letter_spacing, weight) = match self {
202                    ButtonSize::Xs => LABEL_SMALL,
203                    ButtonSize::Sm => LABEL_MEDIUM,
204                    ButtonSize::Md => LABEL_LARGE,
205                    ButtonSize::Lg => TITLE_MEDIUM,
206                    ButtonSize::Xl => TITLE_LARGE,
207                };
208                let mut style = TextStyle::new(size, SHAPING_INK);
209                style.line_height = LineHeight::Absolute(line_height);
210                style.letter_spacing = letter_spacing;
211                style.weight = weight;
212                style
213            }
214        };
215        style.color = SHAPING_INK;
216        style
217    }
218}
219
220// ---- Colors (m3e_button_theme.dart + m3e_button_style.dart) ----------------
221
222/// Unthemed-fallback `primary` (a theme resolves `colors.primary`).
223const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
224/// Unthemed-fallback `on_primary`.
225const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
226/// Unthemed-fallback `secondary_container`.
227const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
228/// Unthemed-fallback `on_secondary_container`.
229const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
230/// Unthemed-fallback `surface_container_low`.
231const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
232/// Unthemed-fallback `outline`.
233const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
234/// Unthemed-fallback `on_surface` — the disabled roles' base color.
235const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
236/// Unthemed-fallback shadow color (opaque black at
237/// `crate::tokens::elevation()`'s `0.3` alpha — identical at every level).
238const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
239
240/// The seven color roles a button reads, resolved once per pass from the live
241/// theme or from this module's unthemed fallbacks.
242#[derive(Clone, Copy, Debug, PartialEq)]
243struct Roles {
244    primary: Color,
245    on_primary: Color,
246    secondary_container: Color,
247    on_secondary_container: Color,
248    surface_container_low: Color,
249    outline: Color,
250    on_surface: Color,
251}
252
253impl Roles {
254    fn resolve(theme: Option<&Theme>) -> Self {
255        match theme {
256            Some(theme) => {
257                let s = theme.scheme();
258                Self {
259                    primary: s.primary,
260                    on_primary: s.on_primary,
261                    secondary_container: s.secondary_container,
262                    on_secondary_container: s.on_secondary_container,
263                    surface_container_low: s.surface_container_low,
264                    outline: s.outline,
265                    on_surface: s.on_surface,
266                }
267            }
268            None => Self {
269                primary: PRIMARY,
270                on_primary: ON_PRIMARY,
271                secondary_container: SECONDARY_CONTAINER,
272                on_secondary_container: ON_SECONDARY_CONTAINER,
273                surface_container_low: SURFACE_CONTAINER_LOW,
274                outline: OUTLINE,
275                on_surface: ON_SURFACE,
276            },
277        }
278    }
279}
280
281/// The resolved inks for one paint pass.
282#[derive(Clone, Copy, Debug, PartialEq)]
283pub(super) struct ButtonColors {
284    /// Container fill (fully transparent for outlined/text).
285    pub(super) container: Color,
286    /// Label + icon ink, and the state layer's tint.
287    pub(super) content: Color,
288    /// The hairline, when the variant draws one.
289    pub(super) outline: Option<Color>,
290}
291
292/// `color` with its alpha channel replaced by `alpha`.
293fn with_alpha(color: Color, alpha: f32) -> Color {
294    let c = color.components;
295    Color::new([c[0], c[1], c[2], alpha])
296}
297
298/// Whether `variant` paints no container at all
299/// (`m3e_button_style.dart:85`'s `isTransparent`).
300fn is_transparent(variant: ButtonVariant) -> bool {
301    matches!(variant, ButtonVariant::Outlined | ButtonVariant::Text)
302}
303
304/// Resolve the container/content/outline inks for `variant`.
305///
306/// Enabled follows `m3e_button_theme.dart:111`/`:127`/`:142`; disabled follows
307/// `m3e_button_style.dart:69`/`:89`/`:128` — content at
308/// [`DISABLED_CONTENT_OPACITY`] (38%) and an opaque container or an outline at
309/// [`DISABLED_CONTAINER_OPACITY`] (12%), both over `on_surface`, while a
310/// transparent container stays transparent.
311pub(super) fn resolve_colors(
312    theme: Option<&Theme>,
313    variant: ButtonVariant,
314    enabled: bool,
315) -> ButtonColors {
316    let roles = Roles::resolve(theme);
317
318    if !enabled {
319        return ButtonColors {
320            container: if is_transparent(variant) {
321                Color::TRANSPARENT
322            } else {
323                with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)
324            },
325            content: with_alpha(roles.on_surface, DISABLED_CONTENT_OPACITY),
326            outline: (variant == ButtonVariant::Outlined)
327                .then(|| with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)),
328        };
329    }
330
331    ButtonColors {
332        container: match variant {
333            ButtonVariant::Filled => roles.primary,
334            ButtonVariant::Tonal => roles.secondary_container,
335            ButtonVariant::Elevated => roles.surface_container_low,
336            ButtonVariant::Outlined | ButtonVariant::Text => Color::TRANSPARENT,
337        },
338        content: match variant {
339            ButtonVariant::Filled => roles.on_primary,
340            ButtonVariant::Tonal => roles.on_secondary_container,
341            ButtonVariant::Elevated | ButtonVariant::Outlined | ButtonVariant::Text => {
342                roles.primary
343            }
344        },
345        outline: (variant == ButtonVariant::Outlined).then_some(roles.outline),
346    }
347}
348
349/// The container elevation in dp for `variant` in the current state
350/// (`m3e_button_theme.dart:150`): an elevated button rests at 1, lifts to 3 on
351/// hover and flattens to 0 on press; filled and tonal lift to 1 on hover only;
352/// outlined, text and *every* disabled button stay flat.
353pub(super) fn elevation_dp(
354    variant: ButtonVariant,
355    enabled: bool,
356    pressed: bool,
357    hovered: bool,
358) -> f64 {
359    if !enabled {
360        return 0.0;
361    }
362    match variant {
363        ButtonVariant::Elevated => {
364            if pressed {
365                0.0
366            } else if hovered {
367                3.0
368            } else {
369                1.0
370            }
371        }
372        ButtonVariant::Filled | ButtonVariant::Tonal => {
373            if !pressed && hovered {
374                1.0
375            } else {
376                0.0
377            }
378        }
379        ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
380    }
381}
382
383/// The `(blur_std_dev, y_offset, color)` shadow for an elevation of `dp`, or
384/// `None` for a flat button.
385///
386/// The reference's table only ever produces 1dp or 3dp, which are exactly M3
387/// elevation levels 1 and 2 — so a theme resolves through
388/// `theme.elevation.level1`/`level2`'s own `ShadowSpec`. Unthemed, the same
389/// math `crate::tokens::elevation()` uses (`y = dp / 2 + 1`, `blur = dp`,
390/// alpha 0.3) is applied directly.
391pub(super) fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
392    if dp <= 0.0 {
393        return None;
394    }
395    match theme {
396        Some(theme) => {
397            let level = if dp >= theme.elevation.level2.dp {
398                theme.elevation.level2
399            } else {
400                theme.elevation.level1
401            };
402            let shadow = level.shadow(theme.brightness);
403            let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
404            Some((shadow.blur_std_dev, shadow.y_offset, color))
405        }
406        None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
407    }
408}
409
410// ---- Seams (ButtonDecoration's gradient layers now live in `super::gradient`;
411// ---- OverflowObserver awaits a follow-up overflow-strategy task) -----------
412
413/// The morphing surface a [`ButtonDecoration`] paints against: window-space
414/// geometry, the live corner radius, and the inks + interaction state the core
415/// resolved for this frame.
416#[derive(Clone, Copy, Debug, PartialEq)]
417pub struct ButtonSurface {
418    /// The container's window-space origin.
419    pub origin: Point,
420    /// The container's size.
421    pub size: Size,
422    /// This frame's animating corner radius.
423    pub radius: f64,
424    /// The resolved container fill (transparent for outlined/text).
425    pub container: Color,
426    /// The resolved label/icon ink.
427    pub content: Color,
428    /// The resolved hairline ink, when the variant draws one.
429    pub outline: Option<Color>,
430    /// The hairline's stroke width, logical px.
431    pub outline_width: f64,
432    /// Whether the button is enabled.
433    pub enabled: bool,
434    /// The live interaction state — what a per-state gradient resolves
435    /// against, the way the reference's `WidgetStateProperty<Gradient?>` does.
436    pub state: InteractionState,
437}
438
439/// Whether a [`ButtonDecoration`] hook took over a layer.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum DecorationOutcome {
442    /// The hook painted this layer; the core skips its own.
443    Painted,
444    /// The hook painted nothing; the core paints its own.
445    Skipped,
446}
447
448/// The paint-time decoration seam — where the reference's
449/// `m3eGradientSurfaceBuilder` fill/overlay/outline layers (plus
450/// `m3eGradientForegroundBuilder`'s separate content layer) attach. See
451/// [`super::gradient`] for the built-in [`ButtonDecoration`] implementation
452/// that fills every hook with gradient paint.
453///
454/// The first three methods default to [`DecorationOutcome::Skipped`], so an
455/// implementation overrides only the layers it draws and a button with no
456/// decoration behaves exactly as if this trait did not exist. They fire in
457/// paint order — fill, overlay, outline — each with the same
458/// [`ButtonSurface`], whose `radius` is the morph's live value rather than a
459/// resting token (the reference threads `animatedRadius` into its own builders
460/// for the same reason: a gradient must clip to the shape actually painted).
461/// [`Self::foreground_brush`] is shaped differently — see its own doc.
462pub trait ButtonDecoration {
463    /// Paint the container fill. [`DecorationOutcome::Painted`] suppresses the
464    /// core's own solid fill.
465    fn paint_fill(
466        &self,
467        _surface: &ButtonSurface,
468        _scene: &mut dyn PaintScene,
469    ) -> DecorationOutcome {
470        DecorationOutcome::Skipped
471    }
472
473    /// Paint an overlay above the fill. [`DecorationOutcome::Painted`]
474    /// suppresses the core's own interaction state layer — mirroring
475    /// `m3e_button_style.dart:47`, where a gradient overlay forces
476    /// `overlayColor` to transparent so the two never stack.
477    fn paint_overlay(
478        &self,
479        _surface: &ButtonSurface,
480        _scene: &mut dyn PaintScene,
481    ) -> DecorationOutcome {
482        DecorationOutcome::Skipped
483    }
484
485    /// Paint the outline. [`DecorationOutcome::Painted`] suppresses the core's
486    /// own hairline.
487    fn paint_outline(
488        &self,
489        _surface: &ButtonSurface,
490        _scene: &mut dyn PaintScene,
491    ) -> DecorationOutcome {
492        DecorationOutcome::Skipped
493    }
494
495    /// Resolve a [`Brush`] (solid or gradient) to paint the label's ink with,
496    /// replacing the core's own per-variant/per-state solid `content` color —
497    /// where the reference's `foregroundGradient` attaches
498    /// (`m3eGradientForegroundBuilder`, `m3e_button_content.dart:157`).
499    ///
500    /// Upstream applies this through `ShaderMask` + `BlendMode.srcIn` over
501    /// the *whole* assembled content (icon and label together): the shader
502    /// only shows through pixels the content already painted opaque, which
503    /// is why upstream's `_resolveForegroundColor` first forces the
504    /// underlying paint to opaque white when a foreground gradient is set
505    /// (`m3e_button_style.dart:58`) — `srcIn` discards that color anyway and
506    /// keeps only its alpha. This port has no `srcIn`-style masking primitive
507    /// in [`PaintScene`], but the label's own glyph runs already carry a
508    /// [`Brush`] (never just a solid color) and paint only their covered
509    /// glyph pixels either way — brushing a run with a gradient directly is
510    /// pixel-equivalent to `srcIn`-masking a solid-ink run with the same
511    /// gradient, no synthetic white layer needed. The leading/trailing icon
512    /// is a nested [`frust::authoring::ChildPod`] this seam has no
513    /// color-override reach into, so v1 narrows the foreground gradient to
514    /// the label only — a documented gap (see [`super::gradient`]'s module
515    /// doc), not a silent drop.
516    ///
517    /// Shaped as a *resolver* rather than a paint callback (unlike the three
518    /// hooks above): the label's shaped glyph geometry is core-private —
519    /// cached lazily against the exact style it was last shaped in — so only
520    /// the core can safely re-emit it. This hook supplies the brush; the core
521    /// does the drawing, right where it would otherwise paint the label in
522    /// its own resolved solid ink. `None` (the default) leaves that solid ink
523    /// untouched.
524    ///
525    /// Resolve any gradient geometry against `surface.origin`/`surface.size`
526    /// in window space, exactly like [`Self::paint_fill`]/[`Self::paint_outline`]
527    /// do — the core re-expresses it relative to the label's own glyph-run
528    /// transform before painting (see
529    /// [`super::gradient::brush_relative_to_run_origin`]'s doc for why a
530    /// glyph run's brush needs that step and the other three hooks don't).
531    fn foreground_brush(&self, _surface: &ButtonSurface) -> Option<Brush> {
532        None
533    }
534}
535
536/// What one layout pass measured of a button's content — the input an overflow
537/// strategy decides on.
538#[derive(Clone, Copy, Debug, PartialEq)]
539pub struct ContentMetrics {
540    /// The label's width if nothing constrained it, logical px.
541    pub natural_label_width: f64,
542    /// The width actually left for the label after padding, icon and gap —
543    /// [`f64::INFINITY`] when the incoming constraint is unbounded.
544    pub available_label_width: f64,
545    /// The width the label was finally laid out at (ellipsized when it had to
546    /// fit), logical px.
547    pub painted_label_width: f64,
548    /// Icon + gap + label, logical px.
549    pub content_width: f64,
550}
551
552impl ContentMetrics {
553    /// The pre-layout value: nothing measured yet, nothing constrained.
554    pub(super) fn empty() -> Self {
555        Self {
556            natural_label_width: 0.0,
557            available_label_width: f64::INFINITY,
558            painted_label_width: 0.0,
559            content_width: 0.0,
560        }
561    }
562
563    /// Whether the label did not fit the width available to it — the question
564    /// the reference's `M3EOverflowStrategy` family exists to answer.
565    pub fn overflows(&self) -> bool {
566        self.natural_label_width > self.available_label_width
567    }
568}
569
570/// The layout-time content-measurement seam: handed the [`ContentMetrics`] of
571/// every layout pass, including whether the label overflowed.
572///
573/// Measurement only — deliberately no return value, so an observer cannot
574/// steer layout from inside it. The strategies that *react* to an overflow
575/// (scroll, popup, bottom sheet) are a follow-up; this is the signal they
576/// subscribe to.
577pub trait OverflowObserver {
578    /// Called at the end of each of the button's layout passes.
579    fn measured(&self, metrics: ContentMetrics);
580
581    /// Called instead of firing the normal `on_press` when a press lands on
582    /// an overflowing button whose [`super::OverflowStrategy`] is
583    /// [`super::OverflowStrategy::BottomSheet`] — the hook an app shows its
584    /// own bottom sheet from (see that variant's doc for why the widget
585    /// cannot do so itself). Defaulted to a no-op, mirroring
586    /// [`Self::measured`]'s own "install nothing, behave as if this trait did
587    /// not exist" shape.
588    fn overflow_pressed(&self) {}
589}
590
591// ---- The label run ---------------------------------------------------------
592
593/// The button's own lazily-shaped label run, re-brushed at paint time.
594///
595/// A catalog-owned run rather than a nested `frust::text` child for two
596/// reasons: the ink is per-variant *and* per-state (no themed color role could
597/// express it), and the overflow seam needs the natural width, which only the
598/// shaper knows. Mirrors [`mod@crate::text_field`]'s `Run`.
599pub(super) struct LabelRun {
600    content: String,
601    layout: Option<TextLayout>,
602    /// The `(style, max_width)` the cached `layout` was shaped/broken for.
603    shaped_for: Option<(TextStyle, Option<f64>)>,
604    /// The `(style, width)` of the last natural (unconstrained) measurement.
605    natural: Option<(TextStyle, f64)>,
606}
607
608impl LabelRun {
609    /// A run holding `content`, unshaped until the first measurement.
610    pub(super) fn new(content: String) -> Self {
611        Self {
612            content,
613            layout: None,
614            shaped_for: None,
615            natural: None,
616        }
617    }
618
619    /// Replace the text, dropping every cached measurement — but only if it
620    /// actually changed.
621    pub(super) fn set_content(&mut self, content: &str) {
622        if self.content != content {
623            self.content = content.to_string();
624            self.layout = None;
625            self.shaped_for = None;
626            self.natural = None;
627        }
628    }
629
630    /// The text this run holds — also the accessible name.
631    pub(super) fn content(&self) -> &str {
632        &self.content
633    }
634
635    /// The run's width with nothing constraining it.
636    pub(super) fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
637        if let Some((cached_style, width)) = &self.natural
638            && cached_style == style
639        {
640            return *width;
641        }
642        let text_ctx = ctx.text_context::<TextContext>();
643        let width = text_ctx.layout(&self.content, style, None).size().width;
644        self.natural = Some((style.clone(), width));
645        width
646    }
647
648    /// Shape (or reuse) the run in `style`, fitted to `max_width` when one is
649    /// given, and return its measured size.
650    ///
651    /// A fitted run is a **single ellipsized line**: `softWrap: false` +
652    /// `overflow: ellipsis` is what the reference's label style and icon
653    /// layout apply (`m3e_base_button_state.dart:177`,
654    /// `m3e_button_state.dart:29`), and a button's height is a fixed size
655    /// token — there is no second line for wrapped text to occupy.
656    pub(super) fn shape(
657        &mut self,
658        ctx: &mut LayoutCtx,
659        style: &TextStyle,
660        max_width: Option<f64>,
661    ) -> Size {
662        let key = (style.clone(), max_width);
663        if let Some(cached) = &self.layout
664            && self.shaped_for.as_ref() == Some(&key)
665        {
666            return cached.size();
667        }
668        let text_ctx = ctx.text_context::<TextContext>();
669        let laid = match max_width {
670            Some(width) => text_ctx.layout_bounded(
671                &self.content,
672                style,
673                Some(width as f32),
674                Some(1),
675                TextOverflow::Ellipsis,
676            ),
677            None => text_ctx.layout(&self.content, style, None),
678        };
679        let size = laid.size();
680        self.layout = Some(laid);
681        self.shaped_for = Some(key);
682        size
683    }
684
685    /// Paint the run at `origin` in `color`, overriding the [`SHAPING_INK`] it
686    /// was shaped with. A run that has never been shaped paints nothing.
687    pub(super) fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
688        let Some(layout) = &self.layout else {
689            return;
690        };
691        for mut run in layout.to_scene_runs(origin) {
692            run.brush = Brush::Solid(color);
693            scene.draw_glyph_run(run);
694        }
695    }
696
697    /// Paint the run at `origin` with an arbitrary `brush` (solid or
698    /// gradient), overriding the [`SHAPING_INK`] it was shaped with — the
699    /// paint side of [`ButtonDecoration::foreground_brush`]. A run that has
700    /// never been shaped paints nothing. Mirrors [`Self::paint`], which stays
701    /// the plain-color path a button with no foreground decoration takes.
702    pub(super) fn paint_brush(&self, origin: Point, brush: &Brush, scene: &mut dyn PaintScene) {
703        let Some(layout) = &self.layout else {
704            return;
705        };
706        for mut run in layout.to_scene_runs(origin) {
707            run.brush = brush.clone();
708            scene.draw_glyph_run(run);
709        }
710    }
711}
712
713// ---- Geometry helpers ------------------------------------------------------
714
715/// Whether local point `pos` is inside a widget of `size`.
716fn inside(pos: Point, size: Size) -> bool {
717    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
718}
719
720/// The rounded rect to *stroke* for a `width`-wide hairline lying fully inside
721/// a container of `size` with corner `radius` — a stroke is centered on its
722/// path, so both the rect and the radius pull in by half the width (the inset
723/// [`mod@crate::card`]'s outlined variant applies too). `pub(super)`: shared
724/// with [`super::gradient`]'s outline layer, which strokes the identical
725/// geometry with a gradient brush instead of a solid one.
726pub(super) fn inset_stroke_rect(size: Size, radius: f64, width: f64) -> RoundedRect {
727    let half = width / 2.0;
728    RoundedRect::new(
729        half,
730        half,
731        size.width - half,
732        size.height - half,
733        (radius - half).max(0.0),
734    )
735}
736
737/// The focus ring's stroke geometry in widget-local coordinates, for a
738/// container of `size` painting at corner `radius`.
739///
740/// `m3e_focus_ring.dart:49` insets the ring by `-(gap + width)` on every side
741/// and grows the radius by that same outset; this returns the *stroke* path for
742/// that box, pulled in by half the stroke width so the ring's outer edge lands
743/// exactly on `-(gap + width)`.
744fn focus_ring_rect(size: Size, radius: f64) -> RoundedRect {
745    let outset = FOCUS_RING_GAP + FOCUS_RING_WIDTH;
746    let half = FOCUS_RING_WIDTH / 2.0;
747    RoundedRect::new(
748        -outset + half,
749        -outset + half,
750        size.width + outset - half,
751        size.height + outset - half,
752        (radius + outset - half).max(0.0),
753    )
754}
755
756/// Stroke the focus ring for a container of `size` at corner `radius`, in
757/// `color`, around window-space `origin`. See [`focus_ring_rect`] and the
758/// [parent module](super)'s Focus ring section.
759fn paint_focus_ring(
760    origin: Point,
761    size: Size,
762    radius: f64,
763    color: Color,
764    scene: &mut dyn PaintScene,
765) {
766    let path = focus_ring_rect(size, radius).to_path(PATH_TOLERANCE);
767    scene.stroke_path(origin, &path, FOCUS_RING_WIDTH, &Brush::Solid(color));
768}
769
770impl ButtonWidget {
771    /// The corner radius the morph is currently aiming at, per
772    /// `m3e_button_content.dart:40`'s `_resolveShapes` precedence: an explicit
773    /// pressed override beats an explicit radius beats the pressed token while
774    /// pressed; an explicit radius beats the hovered token while hovered;
775    /// otherwise an explicit radius beats the shape family's own resting
776    /// radius (a pill at `height / 2`, or the size's square token). A disabled
777    /// button never leaves its resting radius (the reference's
778    /// `effectivelyEnabled &&` guards, `m3e_button_content.dart:86`).
779    fn target_radius(&self, theme: Option<&Theme>, height: f64) -> f64 {
780        let resting = self.corner_radius.unwrap_or(match self.shape {
781            ButtonShape::Round => height / 2.0,
782            ButtonShape::Square => self.size.square_radius(theme),
783        });
784        if !self.enabled {
785            return resting;
786        }
787        if self.state.pressed {
788            return self
789                .pressed_radius
790                .or(self.corner_radius)
791                .unwrap_or_else(|| self.size.pressed_radius(theme));
792        }
793        if self.state.hovered {
794            return self
795                .corner_radius
796                .unwrap_or_else(|| self.size.hovered_radius());
797        }
798        resting
799    }
800
801    /// What the last layout pass measured. The [`OverflowObserver`] seam is
802    /// handed this same value; a caller holding the widget can read it back
803    /// here instead of installing one.
804    pub fn content_metrics(&self) -> ContentMetrics {
805        self.metrics
806    }
807}
808
809impl Widget for ButtonWidget {
810    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
811        let metrics = self.size.metrics();
812        // Resolve every theme-derived value first: the borrow has to end
813        // before anything takes `ctx` mutably (child layout, text shaping).
814        let (label_style, resting_radius) = {
815            let theme = Theme::from_layout_ctx(ctx);
816            (
817                self.size.label_style(theme),
818                self.target_radius(theme, metrics.height),
819            )
820        };
821        // The padding target is a pure function of `self.size` — unlike the
822        // radius channel, which also depends on interaction state only
823        // `paint` tracks — so `layout` resolves it directly rather than
824        // reading it back off the morph. Reading `self.motion.padding()`
825        // here would lag a runtime `size` change by a whole pass: `layout`
826        // always runs before `paint` in a frame, and `paint` below is the
827        // only place that ever calls `retarget` with the new size's
828        // padding — its bare `request_frame` (never `request_layout`)
829        // leaves the stale width standing until an unrelated layout dirty
830        // happens along (`button_group.rs`'s layout-affecting-animation
831        // convention names this exact hazard). A plain button's padding
832        // target never depends on press/hover state either (the parent
833        // module's Shape and the press morph section), so it is never
834        // itself mid-flight — the width computed here is always
835        // immediately correct, with no in-flight frame that would need its
836        // own `request_layout`.
837        let padding = ContentPadding::symmetric(metrics.h_padding);
838        if !self.motion.is_seeded() {
839            // The mount frame snaps; only a later target change springs.
840            self.motion.snap_to(resting_radius, padding);
841        }
842
843        let icon_size = self.icon.as_mut().map(|pod| {
844            pod.layout_child(
845                ctx,
846                &BoxConstraints::tight(Size::new(metrics.icon_size, metrics.icon_size)),
847            )
848        });
849        let icon_width = icon_size.map_or(0.0, |s| s.width);
850        // The gap exists only between an icon and a label
851        // (`m3e_button_state.dart:27`).
852        let gap = if icon_size.is_some() {
853            metrics.icon_gap
854        } else {
855            0.0
856        };
857
858        let available_label_width = if bc.max().width.is_finite() {
859            (bc.max().width - padding.left - padding.right - icon_width - gap).max(0.0)
860        } else {
861            f64::INFINITY
862        };
863
864        let natural_label_width = self.label.natural_width(ctx, &label_style);
865        let overflows = natural_label_width > available_label_width;
866        // A `Scroll` strategy shapes the label at its full natural width even
867        // while overflowing — panning needs the whole run, not an ellipsized
868        // stand-in. Every other case (including no strategy at all) keeps
869        // today's ellipsize-to-fit behavior, the reference's one real
870        // single-button overflow behavior (`m3e_base_button_state.dart:177`;
871        // see `overflow`'s module docs).
872        let fit_to = if overflows && self.overflow_strategy != OverflowStrategy::Scroll {
873            Some(available_label_width).filter(|w| w.is_finite())
874        } else {
875            None
876        };
877        let label_size = self.label.shape(ctx, &label_style, fit_to);
878
879        let content_width = icon_width + gap + label_size.width;
880        let width = (content_width + padding.left + padding.right).max(MIN_WIDTH_FLOOR);
881        let size = bc.constrain(Size::new(width, metrics.height));
882
883        // The content row is centered as a whole (`Row(mainAxisSize: min,
884        // mainAxisAlignment: center)` inside a center-aligned button,
885        // `m3e_button_state.dart:42` + `m3e_button_style.dart:19`).
886        let content_x = ((size.width - content_width) / 2.0).max(0.0);
887        let (icon_x, label_x) = match self.icon_alignment {
888            IconAlignment::Start => (content_x, content_x + icon_width + gap),
889            IconAlignment::End => (content_x + label_size.width + gap, content_x),
890        };
891        if let Some(pod) = self.icon.as_mut() {
892            let icon_height = icon_size.map_or(0.0, |s| s.height);
893            pod.set_origin(Point::new(icon_x, (size.height - icon_height) / 2.0));
894        }
895        self.label_origin = Point::new(label_x, (size.height - label_size.height) / 2.0);
896
897        self.metrics = ContentMetrics {
898            natural_label_width,
899            available_label_width,
900            painted_label_width: label_size.width,
901            content_width,
902        };
903        if let Some(observer) = &self.overflow {
904            observer.measured(self.metrics);
905        }
906        // Re-clamp every layout: a resize can shrink `max_scroll` out from
907        // under a live pan position (or, when the label no longer overflows
908        // at all, collapse it to `0`).
909        self.scroll_offset = self.scroll_offset.clamp(0.0, max_scroll(self.metrics));
910        size
911    }
912
913    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
914        let origin = ctx.origin();
915        let size = ctx.size();
916
917        // Authoritative hover/focus reads, self-correcting the latched flags
918        // (`docs/CODE_STANDARDS.md`'s Interaction Semantics); both are inert
919        // while disabled, mirroring the reference's `effectivelyEnabled`
920        // guards (`m3e_button_content.dart:86`).
921        self.state.set_hovered(self.enabled && ctx.is_hovered());
922        self.state.set_focused(self.enabled && ctx.has_focus());
923
924        // Every theme read happens here, before `ctx` is taken mutably below.
925        let (colors, shadow, radius_target, focus_ring_color) = {
926            let theme = Theme::from_paint_ctx(ctx);
927            let dp = elevation_dp(
928                self.variant,
929                self.enabled,
930                self.state.pressed,
931                self.state.hovered,
932            );
933            (
934                resolve_colors(theme, self.variant, self.enabled),
935                resolve_shadow(theme, dp),
936                self.target_radius(theme, size.height),
937                // `m3e_button_theme.dart:146` — the ring takes `primary`.
938                theme.map_or(PRIMARY, |theme| theme.scheme().primary),
939            )
940        };
941
942        // Retargeted every paint so a press/hover change is caught
943        // continuously and the morph's own continuity bookkeeping stays
944        // live; `layout` above resolves its own copy of this same
945        // size-derived padding directly rather than reading it back through
946        // `self.motion.padding()` (see that comment) — this call drives the
947        // radius channel, not this widget's layout.
948        let padding = ContentPadding::symmetric(self.size.metrics().h_padding);
949        if self.motion.retarget(radius_target, padding) {
950            ctx.request_frame();
951        }
952        if self.motion.advance(ctx.frame_time()) {
953            ctx.request_frame();
954        }
955        let radius = self.motion.radius();
956
957        let surface = ButtonSurface {
958            origin,
959            size,
960            radius,
961            container: colors.container,
962            content: colors.content,
963            outline: colors.outline,
964            outline_width: OUTLINE_WIDTH,
965            enabled: self.enabled,
966            state: self.state,
967        };
968
969        if let Some((blur, y_offset, shadow_color)) = shadow {
970            scene.draw_shadow(
971                Point::new(origin.x, origin.y + y_offset),
972                size,
973                radius,
974                blur,
975                shadow_color,
976            );
977        }
978
979        let fill = self
980            .decoration
981            .as_ref()
982            .map_or(DecorationOutcome::Skipped, |d| {
983                d.paint_fill(&surface, scene)
984            });
985        if fill == DecorationOutcome::Skipped && colors.container.components[3] > 0.0 {
986            scene.fill_rounded_rect(origin, size, radius, colors.container);
987        }
988
989        let overlay = self
990            .decoration
991            .as_ref()
992            .map_or(DecorationOutcome::Skipped, |d| {
993                d.paint_overlay(&surface, scene)
994            });
995        // The M3E state layer: `dragged > pressed > focused > hovered`
996        // precedence (`m3e_state_layer.dart`'s `M3EInteractionState.opacity`,
997        // ported as `InteractionState::resolve_opacity`). The reference's own
998        // button suppresses the *pressed* step because `InkSparkle` draws the
999        // press for it (`m3e_button_style.dart:153`); this framework has no
1000        // ripple engine, so the state layer *is* the press feedback here and
1001        // the step is kept.
1002        if overlay == DecorationOutcome::Skipped && self.enabled {
1003            let opacity = self.state.resolve_opacity();
1004            if opacity > 0.0 {
1005                scene.fill_rounded_rect(origin, size, radius, with_alpha(colors.content, opacity));
1006            }
1007        }
1008
1009        let outlined = self
1010            .decoration
1011            .as_ref()
1012            .map_or(DecorationOutcome::Skipped, |d| {
1013                d.paint_outline(&surface, scene)
1014            });
1015        if outlined == DecorationOutcome::Skipped
1016            && let Some(outline) = colors.outline
1017        {
1018            let path = inset_stroke_rect(size, radius, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1019            scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1020        }
1021
1022        if let Some(pod) = self.icon.as_mut() {
1023            pod.paint_child(ctx, scene);
1024        }
1025        // `OverflowStrategy::Scroll`'s live pan, clipped to the container so
1026        // the unellipsized run painted below never spills past it — see
1027        // `overflow`'s module docs.
1028        let panning =
1029            self.overflow_strategy == OverflowStrategy::Scroll && self.metrics.overflows();
1030        let label_origin = Point::new(
1031            origin.x + self.label_origin.x - if panning { self.scroll_offset } else { 0.0 },
1032            origin.y + self.label_origin.y,
1033        );
1034        if panning {
1035            scene.push_clip(origin, size);
1036        }
1037        match self
1038            .decoration
1039            .as_ref()
1040            .and_then(|d| d.foreground_brush(&surface))
1041        {
1042            Some(brush) => {
1043                // `foreground_brush` resolves gradient geometry in window
1044                // space, matching every other `ButtonSurface`-anchored
1045                // brush — but the label's `GlyphRun` paints under its OWN
1046                // `transform` (`label_origin`, via `LabelRun::paint_brush`
1047                // -> `TextLayout::to_scene_runs`), which the render backend
1048                // applies to the active paint too, not just the glyph
1049                // outlines. Re-express the brush relative to `label_origin`
1050                // so it isn't translated a second time at paint time — see
1051                // `gradient::brush_relative_to_run_origin`'s doc.
1052                let local_brush = gradient::brush_relative_to_run_origin(&brush, label_origin);
1053                self.label.paint_brush(label_origin, &local_brush, scene);
1054            }
1055            None => self.label.paint(label_origin, colors.content, scene),
1056        }
1057        if panning {
1058            scene.pop_clip();
1059        }
1060
1061        // Outside the container, and only while focused — see the parent
1062        // module's Focus ring section, including why nothing reaches it yet.
1063        if self.state.focused {
1064            paint_focus_ring(origin, size, radius, focus_ring_color, scene);
1065        }
1066    }
1067
1068    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1069        if !self.enabled {
1070            // A disabled button arms nothing and asks for no cursor —
1071            // `m3e_button_style.dart:146` resolves the plain arrow, which is
1072            // what a pass where nobody asks already produces.
1073            return EventResult::Ignored;
1074        }
1075        let InputEvent::Pointer(p) = event else {
1076            return EventResult::Ignored;
1077        };
1078        match p.phase {
1079            PointerPhase::Down => {
1080                if !presses(p) {
1081                    return EventResult::Ignored;
1082                }
1083                self.state.set_pressed(true);
1084                self.captured = true;
1085                // Seeds `Scroll`'s drag-delta sample; harmless (and unread)
1086                // under every other strategy.
1087                self.drag_last_x = Some(p.position.x);
1088                ctx.capture_pointer();
1089                ctx.request_redraw();
1090                EventResult::Handled
1091            }
1092            PointerPhase::Move => {
1093                let over = inside(p.position, ctx.size());
1094                if !self.captured {
1095                    // The hover pass: claim, latch, and let paint self-correct
1096                    // (`docs/CODE_STANDARDS.md`'s three-part hover contract).
1097                    if over {
1098                        ctx.claim_hover();
1099                        // `mouseCursor: SystemMouseCursors.click`
1100                        // (`m3e_buttons.dart:56`), re-asked every move.
1101                        ctx.set_cursor(CursorIcon::Pointer);
1102                    }
1103                    if self.state.set_hovered(over) {
1104                        ctx.request_redraw();
1105                    }
1106                    return EventResult::Ignored;
1107                }
1108                ctx.set_cursor(CursorIcon::Pointer);
1109                // `OverflowStrategy::Scroll`: a captured drag pans the label
1110                // by the delta from the last sample, clamped to the pannable
1111                // range — the reference's `SingleChildScrollView` is
1112                // user-driven, never an auto-marquee (see `overflow`'s
1113                // module docs).
1114                if self.overflow_strategy == OverflowStrategy::Scroll && self.metrics.overflows() {
1115                    if let Some(last_x) = self.drag_last_x {
1116                        let dx = p.position.x - last_x;
1117                        self.scroll_offset =
1118                            (self.scroll_offset - dx).clamp(0.0, max_scroll(self.metrics));
1119                        ctx.request_redraw();
1120                    }
1121                    self.drag_last_x = Some(p.position.x);
1122                }
1123                if self.state.set_pressed(over) {
1124                    ctx.request_redraw();
1125                }
1126                EventResult::Handled
1127            }
1128            PointerPhase::Up => {
1129                if !self.captured {
1130                    return EventResult::Ignored;
1131                }
1132                if inside(p.position, ctx.size()) {
1133                    // `OverflowStrategy::BottomSheet`: an overflowing button's
1134                    // press defers to the installed `OverflowObserver`
1135                    // instead of firing normally — mirroring the reference's
1136                    // own overflow trigger, which never fires a hidden action
1137                    // directly (see `overflow`'s module docs). No observer
1138                    // installed falls back to the normal press rather than
1139                    // going silently dead.
1140                    let deferred = self.overflow_strategy == OverflowStrategy::BottomSheet
1141                        && self.metrics.overflows()
1142                        && self.overflow.is_some();
1143                    if deferred {
1144                        if let Some(observer) = &self.overflow {
1145                            observer.overflow_pressed();
1146                        }
1147                    } else {
1148                        // The reference fires its haptic inside the same
1149                        // `onPressed` wrapper, ahead of the app callback
1150                        // (`m3e_button_content.dart:178`). `None` is defined
1151                        // as a no-op, so it is elided rather than routed
1152                        // through the process-global hook.
1153                        if self.haptic != HapticSignal::None {
1154                            MaterialHaptics::fire(self.haptic);
1155                        }
1156                        (self.on_press)(ctx);
1157                    }
1158                }
1159                self.state.set_pressed(false);
1160                self.captured = false;
1161                self.drag_last_x = None;
1162                ctx.request_redraw();
1163                EventResult::Handled
1164            }
1165            PointerPhase::Cancel => {
1166                if !self.captured {
1167                    return EventResult::Ignored;
1168                }
1169                self.state.set_pressed(false);
1170                self.captured = false;
1171                self.drag_last_x = None;
1172                ctx.request_redraw();
1173                EventResult::Handled
1174            }
1175        }
1176    }
1177
1178    fn semantics(&self, ctx: &mut SemanticsCtx) {
1179        // One node labelled by the button's own text; the icon child is not
1180        // forwarded (it is decoration, and the label already names the action)
1181        // — the same shape `frust_widgets::Button` reports.
1182        ctx.push_node(Role::Button, |node| {
1183            node.set_label(self.label.content());
1184            if self.enabled {
1185                node.add_action(Action::Click);
1186            } else {
1187                node.set_disabled();
1188            }
1189        });
1190    }
1191
1192    frust::authoring::visit_children!(icon);
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198    use crate::button::{ButtonView, button, button_with_icon};
1199    use frust::FrameTime;
1200    use frust::authoring::scene::GlyphRun;
1201    use frust::authoring::{BuildCtx, PointerButton, PointerEvent, View};
1202    use frust_widgets::test_support::leaf_any;
1203    use std::any::Any;
1204    use std::cell::RefCell;
1205    use std::rc::Rc;
1206
1207    // ---- fixtures ---------------------------------------------------------
1208
1209    /// Records the rounded rects, stroked paths, shadows and glyph runs a
1210    /// paint pass emits.
1211    #[derive(Default)]
1212    struct Recorder {
1213        rrects: Vec<(Point, Size, f64, Color)>,
1214        strokes: Vec<(Point, f64, Color)>,
1215        shadows: Vec<(Point, Size, f64, f64, Color)>,
1216        runs: Vec<(Point, Color)>,
1217    }
1218
1219    impl PaintScene for Recorder {
1220        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1221        fn draw_text(&mut self, _o: Point, _t: &str) {}
1222        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1223            self.rrects.push((o, s, radius, color));
1224        }
1225        fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
1226            self.shadows.push((o, s, radius, std_dev, color));
1227        }
1228        fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
1229            self.strokes.push((o, width, solid(brush)));
1230        }
1231        fn draw_glyph_run(&mut self, run: GlyphRun) {
1232            let t = run.transform.translation();
1233            self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
1234        }
1235    }
1236
1237    fn solid(brush: &Brush) -> Color {
1238        match brush {
1239            Brush::Solid(c) => *c,
1240            _ => Color::TRANSPARENT,
1241        }
1242    }
1243
1244    fn ft_secs(s: f64) -> FrameTime {
1245        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1246    }
1247
1248    /// Build a widget from a view over any state type — icon-carrying views
1249    /// are `()`-stated, since `frust-widgets`' `leaf_any` fixture erases to
1250    /// `AnyView<()>`.
1251    fn build<S: 'static>(view: &ButtonView<S>) -> ButtonWidget {
1252        let mut counter = 0u64;
1253        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1254    }
1255
1256    /// Lay `widget` out against `max_width` with a real text context, and
1257    /// return the resolved size.
1258    fn layout_with(widget: &mut ButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
1259        let mut tcx = TextContext::new();
1260        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1261        if let Some(theme) = theme {
1262            lctx = lctx.with_theme(theme as &dyn Any);
1263        }
1264        widget.layout(
1265            &mut lctx,
1266            &BoxConstraints::loose(Size::new(max_width, 500.0)),
1267        )
1268    }
1269
1270    fn paint_at(widget: &mut ButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
1271        let mut rec = Recorder::default();
1272        let mut pctx = PaintCtx::new(Point::ZERO, size);
1273        if let Some(theme) = theme {
1274            pctx = pctx.with_theme(theme as &dyn Any);
1275        }
1276        widget.paint(&mut pctx, &mut rec);
1277        rec
1278    }
1279
1280    /// Advance the widget's press morph past its settling point, the way a run
1281    /// of real frames would (`PaintCtx::for_test`, the chosen-`FrameTime`
1282    /// seam, is behind a `frust-core` feature this crate's dev-dependency does
1283    /// not enable — see `switch.rs`'s own note).
1284    fn settle_morph(widget: &mut ButtonWidget) {
1285        let mut t = 0.0;
1286        for _ in 0..600 {
1287            t += 1.0 / 60.0;
1288            if !widget.motion.advance(ft_secs(t)) {
1289                return;
1290            }
1291        }
1292        panic!("the press morph never settled");
1293    }
1294
1295    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1296        InputEvent::Pointer(PointerEvent {
1297            phase,
1298            position: Point::new(x, y),
1299            button: PointerButton::Primary,
1300        })
1301    }
1302
1303    fn dispatch(widget: &mut ButtonWidget, state: &mut u32, size: Size, event: &InputEvent) {
1304        let state_any: &mut dyn Any = state;
1305        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1306        widget.event(&mut ctx, event);
1307    }
1308
1309    const ALL_SIZES: [ButtonSize; 5] = [
1310        ButtonSize::Xs,
1311        ButtonSize::Sm,
1312        ButtonSize::Md,
1313        ButtonSize::Lg,
1314        ButtonSize::Xl,
1315    ];
1316
1317    const ALL_VARIANTS: [ButtonVariant; 5] = [
1318        ButtonVariant::Filled,
1319        ButtonVariant::Outlined,
1320        ButtonVariant::Tonal,
1321        ButtonVariant::Elevated,
1322        ButtonVariant::Text,
1323    ];
1324
1325    // ---- metric tables ----------------------------------------------------
1326
1327    #[test]
1328    fn measurements_match_the_reference_table() {
1329        // m3e_button_theme.dart:76 `_measurementsTable`:
1330        // (height, hPadding, iconSize, iconGap).
1331        let expected: [(ButtonSize, f64, f64, f64, f64); 5] = [
1332            (ButtonSize::Xs, 32.0, 16.0, 20.0, 8.0),
1333            (ButtonSize::Sm, 40.0, 16.0, 20.0, 8.0),
1334            (ButtonSize::Md, 56.0, 24.0, 24.0, 8.0),
1335            (ButtonSize::Lg, 96.0, 48.0, 32.0, 12.0),
1336            (ButtonSize::Xl, 136.0, 64.0, 40.0, 16.0),
1337        ];
1338        for (size, height, h_padding, icon_size, icon_gap) in expected {
1339            assert_eq!(
1340                size.metrics(),
1341                SizeMetrics {
1342                    height,
1343                    h_padding,
1344                    icon_size,
1345                    icon_gap
1346                },
1347                "{size:?}"
1348            );
1349        }
1350    }
1351
1352    #[test]
1353    fn radius_tables_match_the_reference_themed_and_unthemed() {
1354        // m3e_button_theme.dart:44 (square), :52 (pressed), :60 (hovered).
1355        let theme = crate::baseline();
1356        let expected: [(ButtonSize, f64, f64, f64); 5] = [
1357            (ButtonSize::Xs, 12.0, 8.0, 10.0),
1358            (ButtonSize::Sm, 12.0, 8.0, 10.0),
1359            (ButtonSize::Md, 16.0, 12.0, 14.0),
1360            (ButtonSize::Lg, 28.0, 16.0, 22.0),
1361            (ButtonSize::Xl, 28.0, 16.0, 22.0),
1362        ];
1363        for (size, square, pressed, hovered) in expected {
1364            assert_eq!(size.square_radius(None), square, "{size:?} square unthemed");
1365            assert_eq!(
1366                size.square_radius(Some(&theme)),
1367                square,
1368                "{size:?} square themed"
1369            );
1370            assert_eq!(
1371                size.pressed_radius(None),
1372                pressed,
1373                "{size:?} pressed unthemed"
1374            );
1375            assert_eq!(
1376                size.pressed_radius(Some(&theme)),
1377                pressed,
1378                "{size:?} pressed themed"
1379            );
1380            assert_eq!(size.hovered_radius(), hovered, "{size:?} hovered");
1381        }
1382    }
1383
1384    #[test]
1385    fn every_hovered_radius_is_the_midpoint_of_its_square_and_pressed_radii() {
1386        // Documented on `hovered_radius`: why that table needs no token.
1387        for size in ALL_SIZES {
1388            assert_eq!(
1389                size.hovered_radius(),
1390                (size.square_radius(None) + size.pressed_radius(None)) / 2.0,
1391                "{size:?}"
1392            );
1393        }
1394    }
1395
1396    #[test]
1397    fn label_roles_match_the_reference_switch() {
1398        // m3e_base_button_state.dart:169 — labelSmall/labelMedium/labelLarge/
1399        // titleMedium/titleLarge.
1400        let theme = crate::baseline();
1401        let expected = [
1402            (ButtonSize::Xs, theme.type_scale.label_small.clone()),
1403            (ButtonSize::Sm, theme.type_scale.label_medium.clone()),
1404            (ButtonSize::Md, theme.type_scale.label_large.clone()),
1405            (ButtonSize::Lg, theme.type_scale.title_medium.clone()),
1406            (ButtonSize::Xl, theme.type_scale.title_large.clone()),
1407        ];
1408        for (size, role) in expected {
1409            let style = size.label_style(Some(&theme));
1410            assert_eq!(style.size, role.size, "{size:?} size");
1411            assert_eq!(style.weight, role.weight, "{size:?} weight");
1412            assert_eq!(style.line_height, role.line_height, "{size:?} line height");
1413            assert_eq!(style.color, SHAPING_INK, "{size:?} shapes in the fixed ink");
1414        }
1415    }
1416
1417    #[test]
1418    fn unthemed_label_roles_match_the_m3_type_tokens() {
1419        let expected: [(ButtonSize, TypeToken); 5] = [
1420            (ButtonSize::Xs, LABEL_SMALL),
1421            (ButtonSize::Sm, LABEL_MEDIUM),
1422            (ButtonSize::Md, LABEL_LARGE),
1423            (ButtonSize::Lg, TITLE_MEDIUM),
1424            (ButtonSize::Xl, TITLE_LARGE),
1425        ];
1426        for (size, (pt, line_height, letter_spacing, weight)) in expected {
1427            let style = size.label_style(None);
1428            assert_eq!(style.size, pt, "{size:?}");
1429            assert_eq!(style.line_height, LineHeight::Absolute(line_height));
1430            assert_eq!(style.letter_spacing, letter_spacing);
1431            assert_eq!(style.weight, weight);
1432        }
1433    }
1434
1435    // ---- colors -----------------------------------------------------------
1436
1437    #[test]
1438    fn enabled_colors_match_the_reference_container_and_foreground_switches() {
1439        // m3e_button_theme.dart:111 (container) and :127 (foreground).
1440        let theme = crate::baseline();
1441        let s = theme.scheme();
1442        let expected: [(ButtonVariant, Color, Color, Option<Color>); 5] = [
1443            (ButtonVariant::Filled, s.primary, s.on_primary, None),
1444            (
1445                ButtonVariant::Outlined,
1446                Color::TRANSPARENT,
1447                s.primary,
1448                Some(s.outline),
1449            ),
1450            (
1451                ButtonVariant::Tonal,
1452                s.secondary_container,
1453                s.on_secondary_container,
1454                None,
1455            ),
1456            (
1457                ButtonVariant::Elevated,
1458                s.surface_container_low,
1459                s.primary,
1460                None,
1461            ),
1462            (ButtonVariant::Text, Color::TRANSPARENT, s.primary, None),
1463        ];
1464        for (variant, container, content, outline) in expected {
1465            assert_eq!(
1466                resolve_colors(Some(&theme), variant, true),
1467                ButtonColors {
1468                    container,
1469                    content,
1470                    outline
1471                },
1472                "{variant:?}"
1473            );
1474        }
1475    }
1476
1477    #[test]
1478    fn unthemed_colors_fall_back_to_the_baseline_light_roles() {
1479        let expected: [(ButtonVariant, Color, Color, Option<Color>); 5] = [
1480            (ButtonVariant::Filled, PRIMARY, ON_PRIMARY, None),
1481            (
1482                ButtonVariant::Outlined,
1483                Color::TRANSPARENT,
1484                PRIMARY,
1485                Some(OUTLINE),
1486            ),
1487            (
1488                ButtonVariant::Tonal,
1489                SECONDARY_CONTAINER,
1490                ON_SECONDARY_CONTAINER,
1491                None,
1492            ),
1493            (
1494                ButtonVariant::Elevated,
1495                SURFACE_CONTAINER_LOW,
1496                PRIMARY,
1497                None,
1498            ),
1499            (ButtonVariant::Text, Color::TRANSPARENT, PRIMARY, None),
1500        ];
1501        for (variant, container, content, outline) in expected {
1502            assert_eq!(
1503                resolve_colors(None, variant, true),
1504                ButtonColors {
1505                    container,
1506                    content,
1507                    outline
1508                },
1509                "{variant:?}"
1510            );
1511        }
1512    }
1513
1514    #[test]
1515    fn disabled_colors_are_the_38_and_12_percent_on_surface_roles() {
1516        // m3e_button_constants.dart:27/:30/:33 — 0.38 content, 0.12 container,
1517        // 0.12 outline.
1518        assert_eq!(DISABLED_CONTENT_OPACITY, 0.38);
1519        assert_eq!(DISABLED_CONTAINER_OPACITY, 0.12);
1520        let theme = crate::baseline();
1521        let on_surface = theme.scheme().on_surface;
1522        for variant in ALL_VARIANTS {
1523            let colors = resolve_colors(Some(&theme), variant, false);
1524            assert_eq!(
1525                colors.content,
1526                with_alpha(on_surface, 0.38),
1527                "{variant:?} content"
1528            );
1529            if is_transparent(variant) {
1530                assert_eq!(
1531                    colors.container,
1532                    Color::TRANSPARENT,
1533                    "{variant:?} stays transparent"
1534                );
1535            } else {
1536                assert_eq!(
1537                    colors.container,
1538                    with_alpha(on_surface, 0.12),
1539                    "{variant:?} container"
1540                );
1541            }
1542            if variant == ButtonVariant::Outlined {
1543                assert_eq!(colors.outline, Some(with_alpha(on_surface, 0.12)));
1544            } else {
1545                assert_eq!(colors.outline, None, "{variant:?} draws no outline");
1546            }
1547        }
1548    }
1549
1550    #[test]
1551    fn elevation_matches_the_reference_state_table() {
1552        // m3e_button_theme.dart:150: (variant, pressed, hovered) -> dp.
1553        for variant in ALL_VARIANTS {
1554            for pressed in [false, true] {
1555                for hovered in [false, true] {
1556                    let expected = match variant {
1557                        ButtonVariant::Elevated => {
1558                            if pressed {
1559                                0.0
1560                            } else if hovered {
1561                                3.0
1562                            } else {
1563                                1.0
1564                            }
1565                        }
1566                        ButtonVariant::Filled | ButtonVariant::Tonal => {
1567                            if !pressed && hovered {
1568                                1.0
1569                            } else {
1570                                0.0
1571                            }
1572                        }
1573                        ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
1574                    };
1575                    assert_eq!(
1576                        elevation_dp(variant, true, pressed, hovered),
1577                        expected,
1578                        "{variant:?} pressed={pressed} hovered={hovered}"
1579                    );
1580                    assert_eq!(
1581                        elevation_dp(variant, false, pressed, hovered),
1582                        0.0,
1583                        "{variant:?} disabled is always flat"
1584                    );
1585                }
1586            }
1587        }
1588    }
1589
1590    #[test]
1591    fn shadows_resolve_onto_m3_elevation_levels_one_and_two() {
1592        let theme = crate::baseline();
1593        assert_eq!(
1594            resolve_shadow(Some(&theme), 0.0),
1595            None,
1596            "flat casts nothing"
1597        );
1598        let (blur, y, _) = resolve_shadow(Some(&theme), 1.0).expect("1dp casts a shadow");
1599        let level1 = theme.elevation.level1.shadow(theme.brightness);
1600        assert_eq!((blur, y), (level1.blur_std_dev, level1.y_offset));
1601        let (blur, y, _) = resolve_shadow(Some(&theme), 3.0).expect("3dp casts a shadow");
1602        let level2 = theme.elevation.level2.shadow(theme.brightness);
1603        assert_eq!((blur, y), (level2.blur_std_dev, level2.y_offset));
1604        // Unthemed applies `crate::tokens::elevation()`'s own math.
1605        assert_eq!(
1606            resolve_shadow(None, 3.0),
1607            Some((3.0, 2.5, FALLBACK_SHADOW_COLOR))
1608        );
1609    }
1610
1611    // ---- layout -----------------------------------------------------------
1612
1613    #[test]
1614    fn every_size_lays_out_at_its_token_height() {
1615        for size in ALL_SIZES {
1616            let mut widget = build(&button::<u32, _>("Save", |_| {}).size(size));
1617            let laid = layout_with(&mut widget, 500.0, None);
1618            assert_eq!(laid.height, size.metrics().height, "{size:?}");
1619        }
1620    }
1621
1622    #[test]
1623    fn width_is_the_content_plus_the_size_padding_floored_at_48() {
1624        for size in ALL_SIZES {
1625            let mut widget = build(&button::<u32, _>("Save", |_| {}).size(size));
1626            let laid = layout_with(&mut widget, 500.0, None);
1627            let metrics = widget.content_metrics();
1628            let expected =
1629                (metrics.content_width + size.metrics().h_padding * 2.0).max(MIN_WIDTH_FLOOR);
1630            assert_eq!(laid.width, expected, "{size:?}");
1631        }
1632    }
1633
1634    #[test]
1635    fn an_empty_label_still_respects_the_min_width_floor() {
1636        // M3EButtonTheme.minWidthFloor = 48 (m3e_button_theme.dart:16).
1637        let mut widget = build(&button::<u32, _>("", |_| {}).size(ButtonSize::Xs));
1638        let laid = layout_with(&mut widget, 500.0, None);
1639        assert_eq!(laid.width, MIN_WIDTH_FLOOR);
1640    }
1641
1642    #[test]
1643    fn the_icon_constructor_lays_the_icon_out_at_the_size_icon_and_gap() {
1644        // m3e_button_state.dart:19-:36 — icon at `m.iconSize`, then a
1645        // `m.iconGap`-wide box, then the label.
1646        for size in ALL_SIZES {
1647            let metrics = size.metrics();
1648            let view = button_with_icon::<(), _>(leaf_any(64.0, 64.0), "Save", |_| {}).size(size);
1649            let mut widget = build(&view);
1650            let laid = layout_with(&mut widget, 500.0, None);
1651
1652            let icon = widget.icon.as_ref().expect("icon pod");
1653            assert_eq!(
1654                icon.size(),
1655                Size::new(metrics.icon_size, metrics.icon_size),
1656                "{size:?} icon is tight-constrained to the token size"
1657            );
1658            assert_eq!(
1659                icon.origin().y,
1660                (laid.height - metrics.icon_size) / 2.0,
1661                "{size:?} icon is vertically centered"
1662            );
1663            assert_eq!(
1664                widget.label_origin.x - (icon.origin().x + metrics.icon_size),
1665                metrics.icon_gap,
1666                "{size:?} label starts one icon gap past the icon"
1667            );
1668            let m = widget.content_metrics();
1669            assert_eq!(
1670                m.content_width,
1671                metrics.icon_size + metrics.icon_gap + m.painted_label_width,
1672                "{size:?} content is icon + gap + label"
1673            );
1674        }
1675    }
1676
1677    #[test]
1678    fn end_alignment_moves_the_icon_to_the_trailing_side() {
1679        let view = button_with_icon::<(), _>(leaf_any(20.0, 20.0), "Save", |_| {})
1680            .icon_alignment(IconAlignment::End);
1681        let mut widget = build(&view);
1682        layout_with(&mut widget, 500.0, None);
1683        let icon = widget.icon.as_ref().expect("icon pod");
1684        let metrics = ButtonSize::Sm.metrics();
1685        assert!(
1686            icon.origin().x > widget.label_origin.x,
1687            "the icon trails the label"
1688        );
1689        assert_eq!(
1690            icon.origin().x
1691                - (widget.label_origin.x + widget.content_metrics().painted_label_width),
1692            metrics.icon_gap,
1693            "still separated by exactly one icon gap"
1694        );
1695    }
1696
1697    #[test]
1698    fn a_button_with_no_icon_adds_no_gap() {
1699        let mut widget = build(&button::<u32, _>("Save", |_| {}));
1700        layout_with(&mut widget, 500.0, None);
1701        let m = widget.content_metrics();
1702        assert_eq!(m.content_width, m.painted_label_width);
1703    }
1704
1705    // ---- the overflow seam ------------------------------------------------
1706
1707    #[derive(Default)]
1708    struct RecordingObserver {
1709        seen: RefCell<Vec<ContentMetrics>>,
1710    }
1711
1712    impl OverflowObserver for RecordingObserver {
1713        fn measured(&self, metrics: ContentMetrics) {
1714            self.seen.borrow_mut().push(metrics);
1715        }
1716    }
1717
1718    #[test]
1719    fn the_overflow_seam_fires_every_layout_with_what_it_measured() {
1720        let observer = Rc::new(RecordingObserver::default());
1721        let view = button::<u32, _>("A comfortably long button label", |_| {})
1722            .overflow_observer(observer.clone());
1723        let mut widget = build(&view);
1724        layout_with(&mut widget, 500.0, None);
1725
1726        let seen = observer.seen.borrow().clone();
1727        assert_eq!(seen.len(), 1, "one measurement per layout pass");
1728        let metrics = seen[0];
1729        assert!(metrics.natural_label_width > 0.0);
1730        assert!(!metrics.overflows(), "500px is plenty of room");
1731        assert_eq!(
1732            metrics,
1733            widget.content_metrics(),
1734            "the observer sees exactly what the widget kept"
1735        );
1736    }
1737
1738    #[test]
1739    fn a_label_wider_than_the_room_left_reports_an_overflow_and_is_fitted() {
1740        let observer = Rc::new(RecordingObserver::default());
1741        let view = button::<u32, _>(
1742            "A label far too long to ever fit inside a narrow button",
1743            |_| {},
1744        )
1745        .overflow_observer(observer.clone());
1746        let mut widget = build(&view);
1747        let laid = layout_with(&mut widget, 120.0, None);
1748
1749        let metrics = widget.content_metrics();
1750        assert!(
1751            metrics.overflows(),
1752            "the natural width exceeds what is available"
1753        );
1754        assert!(
1755            metrics.painted_label_width <= metrics.available_label_width + 0.5,
1756            "the painted label was fitted to the available width"
1757        );
1758        assert!(laid.width <= 120.0, "and the button honors its constraint");
1759        assert_eq!(observer.seen.borrow().len(), 1);
1760    }
1761
1762    #[test]
1763    fn an_unbounded_constraint_never_reports_an_overflow() {
1764        let mut widget = build(&button::<u32, _>("Save", |_| {}));
1765        let mut tcx = TextContext::new();
1766        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1767        widget.layout(
1768            &mut lctx,
1769            &BoxConstraints::loose(Size::new(f64::INFINITY, 500.0)),
1770        );
1771        let metrics = widget.content_metrics();
1772        assert_eq!(metrics.available_label_width, f64::INFINITY);
1773        assert!(!metrics.overflows());
1774    }
1775
1776    // ---- overflow strategies -----------------------------------------------
1777
1778    const LONG_LABEL: &str = "A label far too long to ever fit inside a narrow button";
1779    /// Narrow enough that [`LONG_LABEL`] overflows every button size.
1780    const NARROW: f64 = 120.0;
1781    /// Roomy enough that no label used in these tests overflows.
1782    const ROOMY: f64 = 500.0;
1783
1784    #[derive(Default)]
1785    struct BottomSheetObserver {
1786        overflow_presses: RefCell<u32>,
1787    }
1788
1789    impl OverflowObserver for BottomSheetObserver {
1790        fn measured(&self, _metrics: ContentMetrics) {}
1791        fn overflow_pressed(&self) {
1792            *self.overflow_presses.borrow_mut() += 1;
1793        }
1794    }
1795
1796    #[test]
1797    fn a_fitted_button_behaves_identically_under_every_strategy() {
1798        // Acceptance: "fitted case = no-op pinned" — None/Scroll/BottomSheet
1799        // must all fire the normal press and pan nothing when the label
1800        // never overflowed.
1801        for strategy in [
1802            OverflowStrategy::None,
1803            OverflowStrategy::Scroll,
1804            OverflowStrategy::BottomSheet,
1805        ] {
1806            let presses = Rc::new(RefCell::new(0u32));
1807            let counted = presses.clone();
1808            let observer = Rc::new(BottomSheetObserver::default());
1809            let view = button::<u32, _>("Save", move |_| {
1810                *counted.borrow_mut() += 1;
1811            })
1812            .overflow(strategy)
1813            .overflow_observer(observer.clone());
1814            let mut widget = build(&view);
1815            let size = layout_with(&mut widget, ROOMY, None);
1816            assert!(!widget.content_metrics().overflows(), "{strategy:?}");
1817
1818            let mut state = 0u32;
1819            dispatch(
1820                &mut widget,
1821                &mut state,
1822                size,
1823                &ev(PointerPhase::Down, 5.0, 5.0),
1824            );
1825            dispatch(
1826                &mut widget,
1827                &mut state,
1828                size,
1829                &ev(PointerPhase::Move, 40.0, 5.0),
1830            );
1831            dispatch(
1832                &mut widget,
1833                &mut state,
1834                size,
1835                &ev(PointerPhase::Up, 40.0, 5.0),
1836            );
1837
1838            assert_eq!(*presses.borrow(), 1, "{strategy:?} fires the normal press");
1839            assert_eq!(
1840                *observer.overflow_presses.borrow(),
1841                0,
1842                "{strategy:?} never defers when fitted"
1843            );
1844            assert_eq!(widget.scroll_offset, 0.0, "{strategy:?} pans nothing");
1845        }
1846    }
1847
1848    #[test]
1849    fn scroll_shapes_the_label_at_its_full_natural_width_while_overflowing() {
1850        let view = button::<u32, _>(LONG_LABEL, |_| {}).overflow(OverflowStrategy::Scroll);
1851        let mut widget = build(&view);
1852        layout_with(&mut widget, NARROW, None);
1853        let m = widget.content_metrics();
1854        assert!(m.overflows());
1855        assert_eq!(
1856            m.painted_label_width, m.natural_label_width,
1857            "scroll never ellipsizes — it needs the whole run to pan"
1858        );
1859    }
1860
1861    #[test]
1862    fn a_drag_pans_an_overflowing_scroll_label_clamped_to_the_hidden_extent() {
1863        let view = button::<u32, _>(LONG_LABEL, |_| {}).overflow(OverflowStrategy::Scroll);
1864        let mut widget = build(&view);
1865        let size = layout_with(&mut widget, NARROW, None);
1866        let max = max_scroll(widget.content_metrics());
1867        assert!(max > 0.0, "the fixture must actually overflow");
1868
1869        let mut state = 0u32;
1870        dispatch(
1871            &mut widget,
1872            &mut state,
1873            size,
1874            &ev(PointerPhase::Down, 100.0, 5.0),
1875        );
1876        dispatch(
1877            &mut widget,
1878            &mut state,
1879            size,
1880            &ev(PointerPhase::Move, 40.0, 5.0),
1881        );
1882        // Dragging left (finger moves toward negative x) reveals more of the
1883        // label's trailing side, i.e. increases the pan offset.
1884        assert_eq!(widget.scroll_offset, 60.0);
1885
1886        // A drag far past the hidden extent clamps rather than overshoots.
1887        dispatch(
1888            &mut widget,
1889            &mut state,
1890            size,
1891            &ev(PointerPhase::Move, -900.0, 5.0),
1892        );
1893        assert_eq!(widget.scroll_offset, max);
1894
1895        // Releasing does not reset the pan position (a real scroll view
1896        // keeps its position between gestures).
1897        dispatch(
1898            &mut widget,
1899            &mut state,
1900            size,
1901            &ev(PointerPhase::Up, -900.0, 5.0),
1902        );
1903        assert_eq!(widget.scroll_offset, max);
1904    }
1905
1906    #[test]
1907    fn a_drag_on_a_fitted_scroll_button_pans_nothing() {
1908        let view = button::<u32, _>("Save", |_| {}).overflow(OverflowStrategy::Scroll);
1909        let mut widget = build(&view);
1910        let size = layout_with(&mut widget, ROOMY, None);
1911        assert!(!widget.content_metrics().overflows());
1912
1913        let mut state = 0u32;
1914        dispatch(
1915            &mut widget,
1916            &mut state,
1917            size,
1918            &ev(PointerPhase::Down, 40.0, 5.0),
1919        );
1920        dispatch(
1921            &mut widget,
1922            &mut state,
1923            size,
1924            &ev(PointerPhase::Move, 5.0, 5.0),
1925        );
1926        assert_eq!(widget.scroll_offset, 0.0);
1927    }
1928
1929    #[test]
1930    fn a_relayout_that_stops_overflowing_resets_the_scroll_offset() {
1931        let view = button::<u32, _>(LONG_LABEL, |_| {}).overflow(OverflowStrategy::Scroll);
1932        let mut widget = build(&view);
1933        let size = layout_with(&mut widget, NARROW, None);
1934        let mut state = 0u32;
1935        dispatch(
1936            &mut widget,
1937            &mut state,
1938            size,
1939            &ev(PointerPhase::Down, 100.0, 5.0),
1940        );
1941        dispatch(
1942            &mut widget,
1943            &mut state,
1944            size,
1945            &ev(PointerPhase::Move, 40.0, 5.0),
1946        );
1947        assert!(widget.scroll_offset > 0.0);
1948
1949        layout_with(&mut widget, ROOMY, None);
1950        assert_eq!(widget.scroll_offset, 0.0);
1951    }
1952
1953    #[test]
1954    fn bottom_sheet_defers_an_overflowing_press_to_the_observer_instead_of_on_press() {
1955        let presses = Rc::new(RefCell::new(0u32));
1956        let counted = presses.clone();
1957        let observer = Rc::new(BottomSheetObserver::default());
1958        let view = button::<u32, _>(LONG_LABEL, move |_| {
1959            *counted.borrow_mut() += 1;
1960        })
1961        .overflow(OverflowStrategy::BottomSheet)
1962        .overflow_observer(observer.clone());
1963        let mut widget = build(&view);
1964        let size = layout_with(&mut widget, NARROW, None);
1965        assert!(widget.content_metrics().overflows());
1966
1967        let mut state = 0u32;
1968        dispatch(
1969            &mut widget,
1970            &mut state,
1971            size,
1972            &ev(PointerPhase::Down, 5.0, 5.0),
1973        );
1974        dispatch(
1975            &mut widget,
1976            &mut state,
1977            size,
1978            &ev(PointerPhase::Up, 5.0, 5.0),
1979        );
1980
1981        assert_eq!(*observer.overflow_presses.borrow(), 1);
1982        assert_eq!(*presses.borrow(), 0, "on_press does not also fire");
1983    }
1984
1985    #[test]
1986    fn bottom_sheet_falls_back_to_a_normal_press_with_no_observer_installed() {
1987        let presses = Rc::new(RefCell::new(0u32));
1988        let counted = presses.clone();
1989        let view = button::<u32, _>(LONG_LABEL, move |_| {
1990            *counted.borrow_mut() += 1;
1991        })
1992        .overflow(OverflowStrategy::BottomSheet);
1993        let mut widget = build(&view);
1994        let size = layout_with(&mut widget, NARROW, None);
1995        assert!(widget.content_metrics().overflows());
1996
1997        let mut state = 0u32;
1998        dispatch(
1999            &mut widget,
2000            &mut state,
2001            size,
2002            &ev(PointerPhase::Down, 5.0, 5.0),
2003        );
2004        dispatch(
2005            &mut widget,
2006            &mut state,
2007            size,
2008            &ev(PointerPhase::Up, 5.0, 5.0),
2009        );
2010
2011        assert_eq!(*presses.borrow(), 1, "no observer means no dead press");
2012    }
2013
2014    // ---- the decoration seam ----------------------------------------------
2015
2016    struct RecordingDecoration {
2017        fills: RefCell<Vec<ButtonSurface>>,
2018        overlays: RefCell<Vec<ButtonSurface>>,
2019        outlines: RefCell<Vec<ButtonSurface>>,
2020        /// What every hook reports back to the core.
2021        outcome: DecorationOutcome,
2022    }
2023
2024    impl RecordingDecoration {
2025        fn new(outcome: DecorationOutcome) -> Self {
2026            Self {
2027                fills: RefCell::new(Vec::new()),
2028                overlays: RefCell::new(Vec::new()),
2029                outlines: RefCell::new(Vec::new()),
2030                outcome,
2031            }
2032        }
2033    }
2034
2035    impl ButtonDecoration for RecordingDecoration {
2036        fn paint_fill(
2037            &self,
2038            surface: &ButtonSurface,
2039            _scene: &mut dyn PaintScene,
2040        ) -> DecorationOutcome {
2041            self.fills.borrow_mut().push(*surface);
2042            self.outcome
2043        }
2044        fn paint_overlay(
2045            &self,
2046            surface: &ButtonSurface,
2047            _scene: &mut dyn PaintScene,
2048        ) -> DecorationOutcome {
2049            self.overlays.borrow_mut().push(*surface);
2050            self.outcome
2051        }
2052        fn paint_outline(
2053            &self,
2054            surface: &ButtonSurface,
2055            _scene: &mut dyn PaintScene,
2056        ) -> DecorationOutcome {
2057            self.outlines.borrow_mut().push(*surface);
2058            self.outcome
2059        }
2060    }
2061
2062    #[test]
2063    fn every_decoration_hook_fires_with_the_painted_surface() {
2064        let decoration = Rc::new(RecordingDecoration::new(DecorationOutcome::Skipped));
2065        let view = button::<u32, _>("Save", |_| {})
2066            .variant(ButtonVariant::Outlined)
2067            .decoration(decoration.clone());
2068        let mut widget = build(&view);
2069        let laid = layout_with(&mut widget, 500.0, None);
2070        paint_at(&mut widget, laid, None);
2071
2072        for (name, seen) in [
2073            ("fill", decoration.fills.borrow().clone()),
2074            ("overlay", decoration.overlays.borrow().clone()),
2075            ("outline", decoration.outlines.borrow().clone()),
2076        ] {
2077            assert_eq!(seen.len(), 1, "the {name} hook fired exactly once");
2078            let surface = seen[0];
2079            assert_eq!(surface.size, laid);
2080            assert_eq!(surface.radius, laid.height / 2.0, "the live pill radius");
2081            assert_eq!(surface.outline_width, OUTLINE_WIDTH);
2082            assert!(surface.enabled);
2083            assert_eq!(
2084                surface.outline,
2085                Some(OUTLINE),
2086                "outlined carries a hairline"
2087            );
2088        }
2089    }
2090
2091    #[test]
2092    fn a_default_decoration_is_a_no_op_the_core_paints_through() {
2093        struct Silent;
2094        impl ButtonDecoration for Silent {}
2095
2096        let mut plain = build(&button::<u32, _>("Save", |_| {}));
2097        let laid = layout_with(&mut plain, 500.0, None);
2098        let without = paint_at(&mut plain, laid, None);
2099
2100        let mut decorated = build(&button::<u32, _>("Save", |_| {}).decoration(Rc::new(Silent)));
2101        layout_with(&mut decorated, 500.0, None);
2102        let with = paint_at(&mut decorated, laid, None);
2103
2104        assert_eq!(with.rrects, without.rrects, "identical fills");
2105        assert_eq!(with.strokes.len(), without.strokes.len());
2106        assert_eq!(with.runs, without.runs, "and an identically painted label");
2107    }
2108
2109    #[test]
2110    fn a_painting_decoration_suppresses_the_core_layers_it_took_over() {
2111        let decoration = Rc::new(RecordingDecoration::new(DecorationOutcome::Painted));
2112        let view = button::<u32, _>("Save", |_| {})
2113            .variant(ButtonVariant::Outlined)
2114            .decoration(decoration.clone());
2115        let mut widget = build(&view);
2116        let laid = layout_with(&mut widget, 500.0, None);
2117        let rec = paint_at(&mut widget, laid, None);
2118
2119        assert!(
2120            rec.rrects.is_empty(),
2121            "the fill was taken over, and so is the overlay slot the state \
2122             layer would paint into"
2123        );
2124        assert!(rec.strokes.is_empty(), "and so was the outline");
2125        assert_eq!(rec.runs.len(), 1, "the label is still the core's to paint");
2126    }
2127
2128    // ---- paint ------------------------------------------------------------
2129
2130    #[test]
2131    fn a_resting_round_button_paints_its_container_at_a_pill_radius() {
2132        let mut widget = build(&button::<u32, _>("Save", |_| {}));
2133        let laid = layout_with(&mut widget, 500.0, None);
2134        let rec = paint_at(&mut widget, laid, None);
2135        let (origin, size, radius, color) = rec.rrects[0];
2136        assert_eq!(origin, Point::ZERO);
2137        assert_eq!(size, laid);
2138        assert_eq!(radius, laid.height / 2.0);
2139        assert_eq!(color, PRIMARY);
2140        assert!(rec.shadows.is_empty(), "a filled button rests flat");
2141        assert_eq!(rec.runs.len(), 1, "the label paints one run");
2142        assert_eq!(rec.runs[0].1, ON_PRIMARY);
2143    }
2144
2145    #[test]
2146    fn a_square_button_paints_the_size_token_radius() {
2147        let theme = crate::baseline();
2148        let mut widget = build(
2149            &button::<u32, _>("Save", |_| {})
2150                .shape(ButtonShape::Square)
2151                .size(ButtonSize::Md),
2152        );
2153        let laid = layout_with(&mut widget, 500.0, Some(&theme));
2154        let rec = paint_at(&mut widget, laid, Some(&theme));
2155        assert_eq!(rec.rrects[0].2, ButtonSize::Md.square_radius(Some(&theme)));
2156        assert_eq!(rec.rrects[0].3, theme.scheme().primary);
2157    }
2158
2159    #[test]
2160    fn an_explicit_corner_radius_overrides_both_shape_families() {
2161        let mut widget = build(&button::<u32, _>("Save", |_| {}).corner_radius(3.0));
2162        let laid = layout_with(&mut widget, 500.0, None);
2163        let rec = paint_at(&mut widget, laid, None);
2164        assert_eq!(rec.rrects[0].2, 3.0);
2165    }
2166
2167    #[test]
2168    fn a_text_button_paints_no_container_and_no_outline() {
2169        let mut widget = build(&button::<u32, _>("Save", |_| {}).variant(ButtonVariant::Text));
2170        let laid = layout_with(&mut widget, 500.0, None);
2171        let rec = paint_at(&mut widget, laid, None);
2172        assert!(
2173            rec.rrects.is_empty(),
2174            "a fully transparent container is never filled"
2175        );
2176        assert!(rec.strokes.is_empty());
2177        assert_eq!(rec.runs[0].1, PRIMARY);
2178    }
2179
2180    #[test]
2181    fn an_outlined_button_strokes_a_one_dp_hairline() {
2182        let mut widget = build(&button::<u32, _>("Save", |_| {}).variant(ButtonVariant::Outlined));
2183        let laid = layout_with(&mut widget, 500.0, None);
2184        let rec = paint_at(&mut widget, laid, None);
2185        assert!(rec.rrects.is_empty(), "no container fill");
2186        assert_eq!(rec.strokes.len(), 1);
2187        assert_eq!(rec.strokes[0].1, OUTLINE_WIDTH);
2188        assert_eq!(rec.strokes[0].2, OUTLINE);
2189    }
2190
2191    #[test]
2192    fn the_outlined_hairline_is_inset_by_half_its_width() {
2193        // Mirrors `card.rs`'s outlined inset: a centered stroke would spill
2194        // half its width outside the container.
2195        let rr = inset_stroke_rect(Size::new(100.0, 40.0), 20.0, OUTLINE_WIDTH);
2196        assert_eq!(rr.rect().x0, 0.5);
2197        assert_eq!(rr.rect().y0, 0.5);
2198        assert_eq!(rr.rect().x1, 99.5);
2199        assert_eq!(rr.rect().y1, 39.5);
2200    }
2201
2202    #[test]
2203    fn an_elevated_button_casts_its_resting_level_one_shadow() {
2204        let mut widget = build(&button::<u32, _>("Save", |_| {}).variant(ButtonVariant::Elevated));
2205        let laid = layout_with(&mut widget, 500.0, None);
2206        let rec = paint_at(&mut widget, laid, None);
2207        assert_eq!(rec.shadows.len(), 1);
2208        let (origin, _, _, blur, _) = rec.shadows[0];
2209        assert_eq!(blur, 1.0, "1dp of elevation");
2210        assert_eq!(origin.y, 1.5, "offset by the level's own y");
2211    }
2212
2213    #[test]
2214    fn a_disabled_button_paints_the_twelve_percent_container_and_a_38_percent_label() {
2215        let mut widget = build(&button::<u32, _>("Save", |_| {}).enabled(false));
2216        let laid = layout_with(&mut widget, 500.0, None);
2217        let rec = paint_at(&mut widget, laid, None);
2218        assert_eq!(rec.rrects.len(), 1, "container only, no state layer");
2219        assert_eq!(rec.rrects[0].3, with_alpha(ON_SURFACE, 0.12));
2220        assert_eq!(rec.runs[0].1, with_alpha(ON_SURFACE, 0.38));
2221    }
2222
2223    #[test]
2224    fn an_unfocused_button_draws_no_ring_and_paint_re_syncs_the_flag() {
2225        let mut widget = build(&button::<u32, _>("Save", |_| {}));
2226        let laid = layout_with(&mut widget, 500.0, None);
2227        let rec = paint_at(&mut widget, laid, None);
2228        assert!(rec.strokes.is_empty(), "an unfocused button draws no ring");
2229
2230        // `PaintCtx::has_focus` is authoritative: a stale latched flag is
2231        // corrected by the paint pass, not honored by it.
2232        widget.state.set_focused(true);
2233        let rec = paint_at(&mut widget, laid, None);
2234        assert!(rec.strokes.is_empty());
2235        assert!(!widget.state.focused, "the flag was re-synced from the ctx");
2236    }
2237
2238    #[test]
2239    fn the_focus_ring_paints_at_the_reference_outset_and_radius() {
2240        // m3e_focus_ring.dart:49 — gap 2 + width 2 outside the container, with
2241        // the radius grown by the same outset.
2242        let size = Size::new(100.0, 40.0);
2243        let ring = focus_ring_rect(size, 20.0);
2244        let outset = FOCUS_RING_GAP + FOCUS_RING_WIDTH;
2245        let half = FOCUS_RING_WIDTH / 2.0;
2246        assert_eq!(ring.rect().x0, -outset + half);
2247        assert_eq!(ring.rect().y0, -outset + half);
2248        assert_eq!(ring.rect().x1, size.width + outset - half);
2249        assert_eq!(ring.rect().y1, size.height + outset - half);
2250        assert_eq!(ring.radii().top_left, 20.0 + outset - half);
2251        assert_eq!(
2252            ring.rect().x0 - half,
2253            -outset,
2254            "the ring's outer edge lands exactly on the reference's inset"
2255        );
2256
2257        let mut rec = Recorder::default();
2258        paint_focus_ring(Point::ZERO, size, 20.0, PRIMARY, &mut rec);
2259        assert_eq!(rec.strokes.len(), 1);
2260        assert_eq!(rec.strokes[0].1, FOCUS_RING_WIDTH);
2261        assert_eq!(rec.strokes[0].2, PRIMARY);
2262    }
2263
2264    // ---- the press morph --------------------------------------------------
2265
2266    #[test]
2267    fn a_press_morphs_the_radius_from_the_pill_to_the_pressed_token() {
2268        let mut state = 0u32;
2269        let mut widget = build(&button::<u32, _>("Save", |_| {}));
2270        let laid = layout_with(&mut widget, 500.0, None);
2271        let rec = paint_at(&mut widget, laid, None);
2272        let resting = rec.rrects[0].2;
2273        assert_eq!(resting, laid.height / 2.0);
2274
2275        dispatch(
2276            &mut widget,
2277            &mut state,
2278            laid,
2279            &ev(PointerPhase::Down, 10.0, 10.0),
2280        );
2281        // The frame right after the press still paints the resting radius —
2282        // the morph springs, it does not cut.
2283        let rec = paint_at(&mut widget, laid, None);
2284        assert_eq!(rec.rrects[0].2, resting);
2285
2286        settle_morph(&mut widget);
2287        let rec = paint_at(&mut widget, laid, None);
2288        assert_eq!(
2289            rec.rrects[0].2,
2290            ButtonSize::Sm.pressed_radius(None),
2291            "settled on the pressed radius"
2292        );
2293
2294        // Releasing springs it back to the pill.
2295        dispatch(
2296            &mut widget,
2297            &mut state,
2298            laid,
2299            &ev(PointerPhase::Up, 10.0, 10.0),
2300        );
2301        paint_at(&mut widget, laid, None);
2302        settle_morph(&mut widget);
2303        let rec = paint_at(&mut widget, laid, None);
2304        assert_eq!(rec.rrects[0].2, resting, "and back to rest on release");
2305    }
2306
2307    #[test]
2308    fn a_pressed_radius_override_wins_over_the_token() {
2309        let mut state = 0u32;
2310        let mut widget = build(&button::<u32, _>("Save", |_| {}).pressed_radius(2.0));
2311        let laid = layout_with(&mut widget, 500.0, None);
2312        dispatch(
2313            &mut widget,
2314            &mut state,
2315            laid,
2316            &ev(PointerPhase::Down, 10.0, 10.0),
2317        );
2318        assert_eq!(widget.target_radius(None, laid.height), 2.0);
2319    }
2320
2321    #[test]
2322    fn hover_targets_the_hovered_radius_and_press_outranks_it() {
2323        let mut widget = build(&button::<u32, _>("Save", |_| {}).size(ButtonSize::Md));
2324        let laid = layout_with(&mut widget, 500.0, None);
2325        assert_eq!(widget.target_radius(None, laid.height), laid.height / 2.0);
2326
2327        widget.state.set_hovered(true);
2328        assert_eq!(
2329            widget.target_radius(None, laid.height),
2330            ButtonSize::Md.hovered_radius()
2331        );
2332
2333        widget.state.set_pressed(true);
2334        assert_eq!(
2335            widget.target_radius(None, laid.height),
2336            ButtonSize::Md.pressed_radius(None)
2337        );
2338    }
2339
2340    #[test]
2341    fn a_disabled_button_never_leaves_its_resting_radius() {
2342        let mut widget = build(&button::<u32, _>("Save", |_| {}).enabled(false));
2343        let laid = layout_with(&mut widget, 500.0, None);
2344        widget.state.set_pressed(true);
2345        widget.state.set_hovered(true);
2346        assert_eq!(widget.target_radius(None, laid.height), laid.height / 2.0);
2347    }
2348
2349    #[test]
2350    fn a_runtime_size_change_relayouts_at_the_new_sizes_padding_immediately() {
2351        // `ButtonView::rebuild` already raises `ChangeFlags::LAYOUT` on a
2352        // `size` change (`rebuild_moves_props_and_flags_what_changed`), so
2353        // the very next `layout` pass must already reflect the NEW size's
2354        // padding table — it must not wait for an intervening `paint` to
2355        // retarget the morph first, since the crate's per-frame order
2356        // always runs `layout` before `paint`. Mutating `size` directly
2357        // (the same field write `rebuild` performs) with no `paint` call in
2358        // between reproduces exactly that ordering.
2359        let mut widget = build(&button::<u32, _>("Save", |_| {}).size(ButtonSize::Sm));
2360        layout_with(&mut widget, 1000.0, None);
2361        assert_eq!(
2362            widget.content_metrics().available_label_width,
2363            1000.0 - ButtonSize::Sm.metrics().h_padding * 2.0
2364        );
2365
2366        widget.size = ButtonSize::Xl;
2367        layout_with(&mut widget, 1000.0, None);
2368        assert_eq!(
2369            widget.content_metrics().available_label_width,
2370            1000.0 - ButtonSize::Xl.metrics().h_padding * 2.0,
2371            "layout must resolve the NEW size's padding on its very next pass, \
2372             not the stale one only a later paint would have retargeted"
2373        );
2374    }
2375
2376    #[test]
2377    fn a_plain_buttons_padding_never_moves_during_a_press_morph() {
2378        // Pins the module docs' claim (`super`'s Shape and the press morph
2379        // section): a plain button only ever retargets the radius channel,
2380        // so an in-flight press morph has nothing for `layout` to chase —
2381        // confirming `layout`'s direct-resolve fix above needs no
2382        // `request_layout` companion for this widget's own press morph.
2383        let mut state = 0u32;
2384        let mut widget = build(&button::<u32, _>("Save", |_| {}));
2385        let laid = layout_with(&mut widget, 500.0, None);
2386        let resting_available = widget.content_metrics().available_label_width;
2387
2388        dispatch(
2389            &mut widget,
2390            &mut state,
2391            laid,
2392            &ev(PointerPhase::Down, 10.0, 10.0),
2393        );
2394        // Installs the press's radius-only retarget leg.
2395        paint_at(&mut widget, laid, None);
2396        // Mid-flight, deliberately not settled.
2397        widget.motion.advance(ft_secs(0.05));
2398        layout_with(&mut widget, 500.0, None);
2399        assert_eq!(
2400            widget.content_metrics().available_label_width,
2401            resting_available,
2402            "a plain button's padding target never depends on press state, \
2403             mid-flight or otherwise"
2404        );
2405    }
2406
2407    // ---- events -----------------------------------------------------------
2408
2409    #[test]
2410    fn a_press_released_inside_fires_on_press() {
2411        let mut state = 0u32;
2412        let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2413        let laid = layout_with(&mut widget, 500.0, None);
2414        dispatch(
2415            &mut widget,
2416            &mut state,
2417            laid,
2418            &ev(PointerPhase::Down, 10.0, 10.0),
2419        );
2420        assert!(widget.state.pressed);
2421        dispatch(
2422            &mut widget,
2423            &mut state,
2424            laid,
2425            &ev(PointerPhase::Up, 10.0, 10.0),
2426        );
2427        assert_eq!(state, 1);
2428        assert!(!widget.state.pressed);
2429        assert!(!widget.captured);
2430    }
2431
2432    #[test]
2433    fn a_press_released_outside_fires_nothing() {
2434        let mut state = 0u32;
2435        let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2436        let laid = layout_with(&mut widget, 500.0, None);
2437        dispatch(
2438            &mut widget,
2439            &mut state,
2440            laid,
2441            &ev(PointerPhase::Down, 10.0, 10.0),
2442        );
2443        dispatch(
2444            &mut widget,
2445            &mut state,
2446            laid,
2447            &ev(PointerPhase::Up, 999.0, 10.0),
2448        );
2449        assert_eq!(state, 0);
2450    }
2451
2452    #[test]
2453    fn a_cancelled_press_fires_nothing_and_clears_the_press() {
2454        let mut state = 0u32;
2455        let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2456        let laid = layout_with(&mut widget, 500.0, None);
2457        dispatch(
2458            &mut widget,
2459            &mut state,
2460            laid,
2461            &ev(PointerPhase::Down, 10.0, 10.0),
2462        );
2463        dispatch(
2464            &mut widget,
2465            &mut state,
2466            laid,
2467            &ev(PointerPhase::Cancel, 10.0, 10.0),
2468        );
2469        assert_eq!(state, 0);
2470        assert!(!widget.state.pressed);
2471        assert!(!widget.captured);
2472    }
2473
2474    #[test]
2475    fn only_a_primary_button_presses() {
2476        let mut state = 0u32;
2477        let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2478        let laid = layout_with(&mut widget, 500.0, None);
2479        let secondary = InputEvent::Pointer(PointerEvent {
2480            phase: PointerPhase::Down,
2481            position: Point::new(10.0, 10.0),
2482            button: PointerButton::Secondary,
2483        });
2484        dispatch(&mut widget, &mut state, laid, &secondary);
2485        assert!(!widget.state.pressed);
2486    }
2487
2488    #[test]
2489    fn a_disabled_button_ignores_every_pointer_phase() {
2490        let mut state = 0u32;
2491        let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1).enabled(false));
2492        let laid = layout_with(&mut widget, 500.0, None);
2493        for phase in [
2494            PointerPhase::Down,
2495            PointerPhase::Move,
2496            PointerPhase::Up,
2497            PointerPhase::Cancel,
2498        ] {
2499            dispatch(&mut widget, &mut state, laid, &ev(phase, 10.0, 10.0));
2500        }
2501        assert_eq!(state, 0);
2502        assert!(!widget.state.pressed);
2503    }
2504
2505    #[test]
2506    fn dragging_off_the_button_disarms_the_press_and_dragging_back_rearms_it() {
2507        let mut state = 0u32;
2508        let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2509        let laid = layout_with(&mut widget, 500.0, None);
2510        dispatch(
2511            &mut widget,
2512            &mut state,
2513            laid,
2514            &ev(PointerPhase::Down, 10.0, 10.0),
2515        );
2516        dispatch(
2517            &mut widget,
2518            &mut state,
2519            laid,
2520            &ev(PointerPhase::Move, 999.0, 10.0),
2521        );
2522        assert!(!widget.state.pressed);
2523        dispatch(
2524            &mut widget,
2525            &mut state,
2526            laid,
2527            &ev(PointerPhase::Move, 10.0, 10.0),
2528        );
2529        assert!(widget.state.pressed);
2530        dispatch(
2531            &mut widget,
2532            &mut state,
2533            laid,
2534            &ev(PointerPhase::Up, 10.0, 10.0),
2535        );
2536        assert_eq!(state, 1);
2537    }
2538
2539    #[test]
2540    fn an_uncaptured_move_latches_hover_without_handling_the_event() {
2541        let mut state = 0u32;
2542        let mut widget = build(&button::<u32, _>("Save", |_| {}));
2543        let laid = layout_with(&mut widget, 500.0, None);
2544        dispatch(
2545            &mut widget,
2546            &mut state,
2547            laid,
2548            &ev(PointerPhase::Move, 10.0, 10.0),
2549        );
2550        assert!(
2551            widget.state.hovered,
2552            "the latch is the frame source for gain"
2553        );
2554        dispatch(
2555            &mut widget,
2556            &mut state,
2557            laid,
2558            &ev(PointerPhase::Move, 999.0, 10.0),
2559        );
2560        assert!(!widget.state.hovered);
2561    }
2562
2563    // ---- through a real RenderRoot ----------------------------------------
2564    //
2565    // Hover and focus are *authoritative reads* off `PaintCtx`, seeded by the
2566    // pod chain, so a bare `PaintCtx` can never fake them — only a real tree
2567    // can (the pattern `list_item.rs`'s own hover harness uses). The same
2568    // harness is what makes the semantics pass reachable, since
2569    // `SemanticsCtx::new`/`finish` are `pub(crate)` to `frust-core`.
2570
2571    const HARNESS_WINDOW: Size = Size::new(300.0, 200.0);
2572
2573    struct Harness {
2574        root: frust_core::RenderRoot<u32, ButtonView<u32>>,
2575        state: u32,
2576        tcx: TextContext,
2577        enabled: bool,
2578    }
2579
2580    impl Harness {
2581        fn new(enabled: bool) -> Self {
2582            let mut harness = Self {
2583                root: frust_core::RenderRoot::new(),
2584                state: 0,
2585                tcx: TextContext::new(),
2586                enabled,
2587            };
2588            harness.sync();
2589            harness
2590        }
2591
2592        fn sync(&mut self) {
2593            let enabled = self.enabled;
2594            let mut app =
2595                move |_: &mut u32| button::<u32, _>("Save", |s: &mut u32| *s += 1).enabled(enabled);
2596            self.root.rebuild(&mut app, &mut self.state);
2597            self.root
2598                .layout_with_text(HARNESS_WINDOW, &mut self.tcx as &mut dyn Any);
2599        }
2600
2601        fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
2602            self.root.event(
2603                &mut self.state,
2604                &InputEvent::Pointer(PointerEvent {
2605                    phase,
2606                    position: Point::new(x, y),
2607                    button: PointerButton::Primary,
2608                }),
2609            );
2610        }
2611
2612        fn paint(&mut self) -> Recorder {
2613            let mut rec = Recorder::default();
2614            self.root.paint(&mut rec, FrameTime::ZERO);
2615            rec
2616        }
2617
2618        /// The alpha of the state-layer overlay painted this frame, if any
2619        /// (the container fill is always the first rect).
2620        fn overlay_alpha(&mut self) -> Option<f32> {
2621            let rec = self.paint();
2622            rec.rrects
2623                .get(1)
2624                .map(|(_, _, _, color)| color.components[3])
2625        }
2626    }
2627
2628    #[test]
2629    fn hovering_paints_the_hover_state_layer_and_pressing_outranks_it() {
2630        let mut h = Harness::new(true);
2631        assert_eq!(h.overlay_alpha(), None, "no overlay at rest");
2632
2633        h.dispatch(PointerPhase::Move, 10.0, 10.0);
2634        assert_eq!(
2635            h.overlay_alpha(),
2636            Some(crate::interaction::HOVER_OPACITY),
2637            "the hovered button tints at the M3E hover opacity"
2638        );
2639
2640        h.dispatch(PointerPhase::Down, 10.0, 10.0);
2641        assert_eq!(
2642            h.overlay_alpha(),
2643            Some(crate::interaction::PRESSED_OPACITY),
2644            "pressed outranks hover in the precedence order"
2645        );
2646
2647        h.dispatch(PointerPhase::Up, 10.0, 10.0);
2648        assert_eq!(h.state, 1, "and the release fired the callback");
2649    }
2650
2651    #[test]
2652    fn moving_off_the_button_drops_the_hover_tint() {
2653        let mut h = Harness::new(true);
2654        h.dispatch(PointerPhase::Move, 10.0, 10.0);
2655        assert!(h.overlay_alpha().is_some());
2656        h.dispatch(PointerPhase::Move, 290.0, 190.0);
2657        assert_eq!(h.overlay_alpha(), None);
2658    }
2659
2660    #[test]
2661    fn a_disabled_button_never_tints_and_never_fires() {
2662        let mut h = Harness::new(false);
2663        h.dispatch(PointerPhase::Move, 10.0, 10.0);
2664        assert_eq!(h.overlay_alpha(), None, "a disabled button claims no hover");
2665        h.dispatch(PointerPhase::Down, 10.0, 10.0);
2666        h.dispatch(PointerPhase::Up, 10.0, 10.0);
2667        assert_eq!(h.state, 0);
2668    }
2669
2670    // ---- semantics --------------------------------------------------------
2671
2672    fn semantics_node<V>(logic: impl FnMut(&mut ()) -> V) -> frust::authoring::Node
2673    where
2674        V: frust::authoring::View<()> + 'static,
2675    {
2676        let mut root: frust_core::RenderRoot<(), V> = frust_core::RenderRoot::new();
2677        let mut state = ();
2678        let mut tcx = TextContext::new();
2679        let mut logic = logic;
2680        root.rebuild(&mut logic, &mut state);
2681        root.layout_with_text(HARNESS_WINDOW, &mut tcx as &mut dyn Any);
2682        let update = root.semantics();
2683        let (_, node) = update
2684            .nodes
2685            .iter()
2686            .find(|(id, _)| *id != update.root)
2687            .expect("the button contributes exactly one node");
2688        node.clone()
2689    }
2690
2691    #[test]
2692    fn semantics_report_a_labelled_clickable_button() {
2693        let node = semantics_node(|_| button::<(), _>("Save", |_| {}));
2694        assert_eq!(node.role(), Role::Button);
2695        assert_eq!(node.label(), Some("Save"));
2696        assert!(!node.is_disabled());
2697    }
2698
2699    #[test]
2700    fn a_disabled_button_reports_disabled_semantics() {
2701        let node = semantics_node(|_| button::<(), _>("Save", |_| {}).enabled(false));
2702        assert_eq!(node.role(), Role::Button);
2703        assert!(node.is_disabled());
2704    }
2705}