Skip to main content

frust_widgets/
button.rs

1//! The `Button` interactive widget: a labelled, rounded pressable
2//! that fires an app-state callback on release *inside* its bounds.
3//!
4//! [`button`] is the declarative view-fn; it produces a [`ButtonView`] carrying
5//! the label and a typed `on_press` closure, which materialises into a retained
6//! [`ButtonWidget`]. The masonry-verified interaction is **fire-on-up-inside**:
7//! a `Down` inside captures the pointer and paints the pressed state; `Move`
8//! only updates the pressed visual (cursor-inside); the callback fires on `Up`
9//! *only if the release lands inside*. A `Cancel` (platform gesture steal) just
10//! clears the pressed state.
11//!
12//! # Keyboard
13//!
14//! An enabled button is a Tab stop ([`Widget::is_focusable`]); while focused, a bare Space or
15//! Enter fires `on_press` exactly as a click does. A primary mouse press also takes focus
16//! (pointer-sourced, so `KeyboardOnly` paints no ring), so the next Tab continues from here; a
17//! touch tap never claims it.
18//!
19//! # Style variants
20//!
21//! [`ButtonStyle`] (`.style(...)`) adds four variants beside the default
22//! [`ButtonStyle::Primary`] (today's only look, preserved byte-for-byte under
23//! every theme): [`ButtonStyle::Secondary`] (raised surface + outline border),
24//! [`ButtonStyle::Ghost`] (transparent + muted label), [`ButtonStyle::Danger`]
25//! (error border/text + an error-faint pressed wash), and [`ButtonStyle::Icon`]
26//! (square, `Secondary`-shaped — sized to fit an icon-only label). Each
27//! style's fill/border/label-role mapping is uniform across every design
28//! language (Glyph's own text-vs-fill accent split already lands on these same
29//! M3 role names) — see [`ButtonStyle::resolve`]/
30//! [`ButtonStyle::label_role`]. `.small()` selects a reduced padding scale;
31//! `.loading(bool)` shows a rotating spinner in place of the label and
32//! suppresses `on_press` while shown (disabled semantics — see
33//! [`Widget::semantics`](struct.ButtonWidget.html#impl-Widget-for-ButtonWidget));
34//! `.disabled(bool)` disables interaction and dims the button's appearance
35//! (suppresses `on_press`, blocks focus acquisition, dims fill/border/label via
36//! alpha multiplication). The spinner freezes (and stops requesting frames)
37//! wherever it currently sits under `Theme.motion.reduce_motion`, the same
38//! skip-animation shape [`crate::material::loading_indicator`]'s morph loop uses.
39//!
40//! Precedence stays token-resolved (`docs/CODE_STANDARDS.md`'s Theming
41//! conventions): every fill/border/label color below is `theme > fallback`
42//! (there is no per-instance color override yet, so the explicit tier of
43//! the usual three-tier precedence has nothing to win over — a future
44//! addition would slot in above the theme resolution in
45//! [`ButtonStyle::resolve`]).
46//!
47//! # Label alignment
48//!
49//! [`ButtonView::label_alignment`] reuses [`crate::Alignment`] (the same type
50//! `Align`/`AlignView` are built on — no button-local alignment type exists)
51//! to position the label within a button that has grown past its natural
52//! wrapped size, e.g. a full-bleed button stretched to fill a row. With no
53//! explicit call, the label defaults to leading-pinned on both axes — today's
54//! only behaviour — *unless* the button has been stretched **wider** than its
55//! natural content, in which case it defaults to horizontally centered; a
56//! stretched *height* never auto-centers (narrower than the reported full-bleed-
57//! width defect), so centering vertically always needs an explicit
58//! `.label_alignment()` call. A natural-width/height button is unaffected
59//! either way, since there is no free space for any alignment fraction to
60//! distribute. [`ButtonStyle::Icon`] ignores `label_alignment` outright — it is
61//! definitionally centered (square, re-centered every layout, see below) —
62//! silently half-applying an alignment there would be worse than ignoring it.
63//! **Not inherited**: `cupertino::cupertino_button` and
64//! `material::{split_button, button_group}` are fully independent
65//! implementations that never call [`button`], so none of them gain this
66//! option.
67//!
68//! # Press-feedback scale
69//!
70//! A `Down` scales the button to `0.96` over the theme's `durations.instant`
71//! (100ms Glyph/Cupertino, 50ms M3) under the `exit` easing curve; `Up`/
72//! `Cancel` springs it back to `1.0` via the theme's `default_spatial`
73//! spring. [`PressAnim`] is a from-scratch inline of `motion::animated`'s
74//! private `ImplicitAnim` lazy-retarget shape — that type is
75//! module-private to `motion::animated`, so it's inlined directly here
76//! rather than exposed via a wrapper, for simplicity. Like every animated
77//! widget in this crate, the driver only advances **during paint**
78//! (`docs/CODE_STANDARDS.md`'s Theming & Animation Conventions) — the event
79//! pass (`Down`/`Up`/`Cancel`) only records the *target* scale, since
80//! `EventCtx` carries no theme to resolve a `Timing` from.
81
82use std::rc::Rc;
83use std::time::Duration;
84
85use frust_core::accesskit::{Action, Role};
86use frust_core::{
87    AnimationController, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Curve, EventCtx,
88    EventResult, FrameTime, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase,
89    SemanticsCtx, Tween, View, Widget, any,
90};
91use frust_theme::Theme;
92use kurbo::{Affine, Arc as KurboArc, Point, RoundedRect, Shape, Size, Vec2};
93use peniko::{Brush, Color};
94
95use crate::authoring::presses;
96use crate::nav::transition::{TransitionDriver, make_driver};
97use crate::shortcuts::{activation, press_claims_focus};
98use crate::text;
99use crate::text::ThemeTextColor;
100use crate::{Alignment, Timing, authoring::PRESSED_OPACITY};
101
102/// Corner radius of the button's rounded-rect background, in logical px (the
103/// unthemed fallback; a theme resolves this from `shape.small`).
104const RADIUS: f64 = 6.0;
105/// Horizontal padding around the label, in logical px. No `Theme` spacing
106/// token exists to resolve this from (`frust-theme` publishes a shape
107/// scale and a type scale, not a padding/spacing scale) — hoisted here as a
108/// named constant rather than left as a bare literal, pending a future
109/// spacing-token addition.
110const PAD_X: f64 = 12.0;
111/// Vertical padding around the label, in logical px. See [`PAD_X`]'s doc
112/// comment — no suitable `Theme` token exists for this metric either.
113const PAD_Y: f64 = 8.0;
114/// Horizontal padding under `.small()` — see [`PAD_X`]'s doc comment (same
115/// no-spacing-token gap; this is a proportionally reduced hand-tuned value,
116/// not a token).
117const SMALL_PAD_X: f64 = 8.0;
118/// Vertical padding under `.small()` — see [`SMALL_PAD_X`].
119const SMALL_PAD_Y: f64 = 4.0;
120/// Resting background fill (unthemed fallback; a theme resolves this from
121/// `colors.primary`).
122const FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
123/// Pressed (darker) background fill (unthemed fallback; a theme resolves this
124/// from a darkened `colors.primary` — see [`pressed_overlay`]).
125const FILL_PRESSED: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
126
127/// Unthemed-fallback [`ButtonStyle::Secondary`]/[`ButtonStyle::Icon`] fill (a
128/// light neutral "raised surface" look pre-theme — [`FILL`]/[`FILL_PRESSED`]
129/// above aren't M3-sourced either; there is no M3 anchor to match without a
130/// theme).
131const SECONDARY_FILL: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
132/// Unthemed-fallback Secondary/Icon pressed fill (see [`SECONDARY_FILL`]).
133const SECONDARY_FILL_PRESSED: Color = Color::from_rgb8(0xD1, 0xD5, 0xDB);
134/// Unthemed-fallback Secondary/Icon outline border (see [`SECONDARY_FILL`]).
135const SECONDARY_BORDER: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
136/// Unthemed-fallback [`ButtonStyle::Ghost`] pressed-wash ink (see
137/// [`SECONDARY_FILL`]'s no-M3-anchor note).
138const GHOST_PRESSED_INK: Color = Color::from_rgb8(0x11, 0x18, 0x27);
139/// Unthemed-fallback [`ButtonStyle::Danger`] border/text color.
140const DANGER_BORDER: Color = Color::from_rgb8(0xDC, 0x26, 0x26);
141/// Unthemed-fallback Danger pressed-wash fill (see [`DANGER_BORDER`]).
142const DANGER_PRESSED_WASH: Color = Color::from_rgb8(0xFE, 0xE2, 0xE2);
143/// Unthemed-fallback ink color for every style's label/spinner (matches
144/// `TextStyle::default()`'s own color — see [`ButtonStyle::resolve_ink`]).
145const UNTHEMED_INK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
146
147/// The border stroke width, in logical px (a hairline, matching
148/// `material::card`'s outlined-variant precedent).
149const BORDER_WIDTH: f64 = 1.0;
150/// Flattening tolerance for the border's rounded-rect stroke path — see
151/// `material::card`'s identical precedent/rationale.
152const BORDER_TOLERANCE: f64 = 0.1;
153
154/// The fixed multiplier applied to a style's resting fill's RGB to synthesize
155/// its pressed fill under a theme (a stand-in reproducing today's press
156/// contrast, pending a future M3 tonal state-layers addition). `0.82` darkens
157/// by roughly the same amount today's `FILL`→`FILL_PRESSED` step does.
158const PRESSED_DARKEN: f32 = 0.82;
159
160/// Alpha multiplier applied to a disabled button's fill, border, and label. A
161/// disabled button dims the *resolved* theme color's alpha rather than swapping
162/// in a dedicated "disabled" token, at every resolution point (paint-time fill/
163/// border/ink), so it behaves identically under Material, Cupertino, Glyph, and
164/// the unthemed fallback constants. Material 3 puts a disabled container at 12%,
165/// but buttons are a primary interactive target (unlike passive text fields),
166/// so this uses a gentler 38% to maintain visibility while signaling
167/// unavailability (same multiplier as [`frust_text`]/`TextInput`'s
168/// `DISABLED_CONTENT_ALPHA`, Material 3 disabled content token, source:
169/// https://m3.material.io/components/buttons/specs, retrieved 2026-08-01).
170const DISABLED_ALPHA: f32 = 0.38;
171
172/// The press-feedback pivot scale while pressed.
173const PRESSED_SCALE: f64 = 0.96;
174/// The rest (unpressed) scale.
175const REST_SCALE: f64 = 1.0;
176/// Unthemed-fallback press-feedback duration, applied on
177/// both directions when no theme is threaded. A theme instead resolves
178/// `motion.durations.instant`, which differs per baseline (50ms M3, 100ms
179/// Glyph/Cupertino — see `frust-theme::motion`'s module docs).
180const FALLBACK_PRESS_DURATION: Duration = Duration::from_millis(100);
181
182/// Loading-spinner radius, as a fraction of the button's own content height
183/// (keeps it inside the padding — no icon/spinner size token exists to
184/// resolve this from instead).
185const SPINNER_RADIUS_RATIO: f64 = 0.4;
186/// Loading-spinner minimum radius, in logical px (keeps a `.small()` button's
187/// spinner from degenerating to an invisible dot).
188const SPINNER_MIN_RADIUS: f64 = 5.0;
189/// Loading-spinner stroke width, in logical px.
190const SPINNER_STROKE_WIDTH: f64 = 2.0;
191/// Loading-spinner sweep angle (a partial ring, the universal "activity
192/// spinner" shape), in radians — 270°.
193const SPINNER_SWEEP: f64 = std::f64::consts::PI * 1.5;
194/// Loading-spinner full-rotation period, in ms.
195///
196/// **Community-approximate**: no design-token source publishes a spinner
197/// rotation speed; ~900ms is the cadence common indeterminate
198/// activity-indicator implementations converge on.
199const SPINNER_PERIOD_MS: u64 = 900;
200
201/// Darken a color by scaling its RGB components toward black by `factor`,
202/// leaving alpha untouched. Used to synthesize a style's pressed fill from a
203/// themed resting fill (see [`ButtonStyle::resolve`]).
204fn pressed_overlay(color: Color, factor: f32) -> Color {
205    let c = color.components;
206    Color::new([c[0] * factor, c[1] * factor, c[2] * factor, c[3]])
207}
208
209/// Return `color` with its alpha channel replaced by `alpha` (mirrors
210/// `material::state_layer`/`material::card`'s identically-named helper).
211fn with_alpha(color: Color, alpha: f32) -> Color {
212    let c = color.components;
213    Color::new([c[0], c[1], c[2], alpha])
214}
215
216/// Return `color` with its alpha channel multiplied by `DISABLED_ALPHA` to dim
217/// a disabled button's appearance. Mirrors `TextInput`'s dimming pattern.
218fn disabled_alpha(color: Color) -> Color {
219    let c = color.components;
220    Color::new([c[0], c[1], c[2], c[3] * DISABLED_ALPHA])
221}
222
223/// Build the affine that scales uniformly by `scale` about the absolute
224/// point `pivot` — mirrors `motion::animated::scale_about` (module-private
225/// there); duplicated here per this module's inlining note.
226fn scale_about(pivot: Point, scale: f64) -> Affine {
227    Affine::translate((pivot.x, pivot.y))
228        * Affine::scale(scale)
229        * Affine::translate((-pivot.x, -pivot.y))
230}
231
232/// A view-held, typed press callback (erased to [`crate::authoring::ErasedCallback`] on build).
233type OnPress<State> = Rc<dyn Fn(&mut State)>;
234
235/// Visual style variant for [`Button`]. Additive: the default
236/// preserves today's only look byte-for-byte under every theme.
237/// See the [module docs](self) for the full role-mapping intent.
238#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
239pub enum ButtonStyle {
240    /// Filled `primary` background + `on_primary` label — today's only style.
241    #[default]
242    Primary,
243    /// Raised `surface_container_high` fill + `outline` border + `on_surface`
244    /// label — a secondary/neutral action.
245    Secondary,
246    /// Transparent fill + `on_surface_variant` (muted) label — a
247    /// low-emphasis action; pressed shows a faint `on_surface` wash at
248    /// [`PRESSED_OPACITY`].
249    Ghost,
250    /// Transparent fill + `error` border/label — a destructive action;
251    /// pressed shows the theme's `error_container` faint wash.
252    Danger,
253    /// Square, [`ButtonStyle::Secondary`]-shaped — sized to fit an icon-only
254    /// label rather than wrapping arbitrary text width.
255    Icon,
256}
257
258impl ButtonStyle {
259    /// The themed label color role this style paints its text with (see
260    /// `text::ThemeTextColor`). Consulted both to build the label child (so
261    /// it resolves its own color at *its* layout pass) and to color the
262    /// loading spinner identically.
263    fn label_role(self) -> ThemeTextColor {
264        match self {
265            ButtonStyle::Primary => ThemeTextColor::OnPrimary,
266            ButtonStyle::Secondary | ButtonStyle::Icon => ThemeTextColor::OnSurface,
267            ButtonStyle::Ghost => ThemeTextColor::OnSurfaceVariant,
268            ButtonStyle::Danger => ThemeTextColor::Error,
269        }
270    }
271
272    /// The resolved ink color for this style's loading spinner — the same
273    /// color its label would paint with under `theme`, computed directly
274    /// (rather than through `ThemeTextColor`, a `Text`-widget-only
275    /// abstraction) since the spinner is a raw stroked path, not a nested
276    /// `Text` child. Unthemed: [`UNTHEMED_INK`] — matches `TextStyle::default`'s
277    /// own color, since an unthemed label ignores its role entirely (see
278    /// `text`'s module docs).
279    fn resolve_ink(self, theme: Option<&Theme>) -> Color {
280        match theme {
281            Some(theme) => {
282                let scheme = theme.scheme();
283                match self {
284                    ButtonStyle::Primary => scheme.on_primary,
285                    ButtonStyle::Secondary | ButtonStyle::Icon => scheme.on_surface,
286                    ButtonStyle::Ghost => scheme.on_surface_variant,
287                    ButtonStyle::Danger => scheme.error,
288                }
289            }
290            None => UNTHEMED_INK,
291        }
292    }
293
294    /// The resolved `(resting fill, pressed fill, optional border color)` for
295    /// this style. Themed: per-style `ColorScheme` roles (see the
296    /// [module docs](self)). Unthemed: this style's own fallback constants —
297    /// [`ButtonStyle::Primary`]'s exactly reproduce [`FILL`]/[`FILL_PRESSED`],
298    /// preserving today's pre-theme rendering byte-for-byte.
299    fn resolve(self, theme: Option<&Theme>) -> StylePaint {
300        match theme {
301            Some(theme) => {
302                let scheme = theme.scheme();
303                match self {
304                    ButtonStyle::Primary => StylePaint {
305                        fill: scheme.primary,
306                        fill_pressed: pressed_overlay(scheme.primary, PRESSED_DARKEN),
307                        border: None,
308                    },
309                    ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
310                        fill: scheme.surface_container_high,
311                        fill_pressed: pressed_overlay(
312                            scheme.surface_container_high,
313                            PRESSED_DARKEN,
314                        ),
315                        border: Some(scheme.outline),
316                    },
317                    ButtonStyle::Ghost => StylePaint {
318                        fill: Color::TRANSPARENT,
319                        fill_pressed: with_alpha(scheme.on_surface, PRESSED_OPACITY),
320                        border: None,
321                    },
322                    ButtonStyle::Danger => StylePaint {
323                        fill: Color::TRANSPARENT,
324                        fill_pressed: scheme.error_container,
325                        border: Some(scheme.error),
326                    },
327                }
328            }
329            None => match self {
330                ButtonStyle::Primary => StylePaint {
331                    fill: FILL,
332                    fill_pressed: FILL_PRESSED,
333                    border: None,
334                },
335                ButtonStyle::Secondary | ButtonStyle::Icon => StylePaint {
336                    fill: SECONDARY_FILL,
337                    fill_pressed: SECONDARY_FILL_PRESSED,
338                    border: Some(SECONDARY_BORDER),
339                },
340                ButtonStyle::Ghost => StylePaint {
341                    fill: Color::TRANSPARENT,
342                    fill_pressed: with_alpha(GHOST_PRESSED_INK, PRESSED_OPACITY),
343                    border: None,
344                },
345                ButtonStyle::Danger => StylePaint {
346                    fill: Color::TRANSPARENT,
347                    fill_pressed: DANGER_PRESSED_WASH,
348                    border: Some(DANGER_BORDER),
349                },
350            },
351        }
352    }
353}
354
355/// The resolved per-style paint values — see [`ButtonStyle::resolve`].
356struct StylePaint {
357    fill: Color,
358    fill_pressed: Color,
359    border: Option<Color>,
360}
361
362/// Resolve the effective press-feedback [`Timing`]: pressed (Down) uses
363/// `durations.instant` under the theme's `exit` easing; releasing (Up/Cancel)
364/// uses the theme's `default_spatial` spring — see the [module docs](self).
365/// Falls back to [`FALLBACK_PRESS_DURATION`] under a plain ease when no theme
366/// is threaded.
367fn resolve_press_timing(theme: Option<&Theme>, pressed: bool) -> Timing {
368    match theme {
369        Some(theme) => {
370            if pressed {
371                let instant_ms = theme.motion.durations.instant.max(0.0);
372                Timing::Duration(
373                    Duration::from_secs_f64(instant_ms / 1000.0),
374                    theme.motion.easing.exit,
375                )
376            } else {
377                Timing::Spring(theme.motion.default_spatial)
378            }
379        }
380        None => {
381            let curve = if pressed {
382                Curve::EaseIn
383            } else {
384                Curve::EaseOut
385            };
386            Timing::Duration(FALLBACK_PRESS_DURATION, curve)
387        }
388    }
389}
390
391/// An inlined implicit-scale driver for the press-feedback animation — the
392/// same lazy-retarget shape `motion::animated`'s module-private `ImplicitAnim`
393/// uses, reproduced here per this module's inlining note (see the
394/// [module docs](self)). `Down`/`Up`/`Cancel` (the event pass, which carries
395/// no theme) only call [`PressAnim::set_pressed`]; `paint` (which does carry
396/// one) resolves the direction's [`Timing`] and calls [`PressAnim::advance`],
397/// which lazily launches the retarget the first time it sees the target
398/// disagree with what's actually driving.
399struct PressAnim {
400    target: f64,
401    driving_target: f64,
402    tween: Tween<f64>,
403    driver: TransitionDriver,
404}
405
406impl PressAnim {
407    /// A fresh driver settled at rest — no animate-in on first mount.
408    fn new() -> Self {
409        let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, Curve::Linear));
410        Self {
411            target: REST_SCALE,
412            driving_target: REST_SCALE,
413            tween: Tween::new(REST_SCALE, REST_SCALE),
414            driver,
415        }
416    }
417
418    /// The current interpolated scale.
419    fn value(&self) -> f64 {
420        self.tween.lerp(self.driver.value())
421    }
422
423    /// Record the target scale for the next [`Self::advance`] call (called
424    /// from the event pass — see the [module docs](self)).
425    fn set_pressed(&mut self, pressed: bool) {
426        self.target = if pressed { PRESSED_SCALE } else { REST_SCALE };
427    }
428
429    /// Advance one frame at `now` under `timing`. Returns whether still
430    /// animating (the caller should `PaintCtx::request_frame`).
431    fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
432        if self.target != self.driving_target {
433            let from = self.value();
434            self.tween = Tween::new(from, self.target);
435            self.driving_target = self.target;
436            let (driver, _) = make_driver(timing);
437            self.driver = driver;
438        }
439        self.driver.advance(now).animating
440    }
441}
442
443/// Build the type-erased label view, tagged with `style`'s themed color role
444/// (see [`ButtonStyle::label_role`]) so the label reads correctly against its
445/// style's fill (an unthemed button keeps its black label — see `text`'s
446/// module docs). Shared by build/rebuild/teardown so the role stays
447/// consistent across the child's whole lifecycle.
448fn label_view<State: 'static>(label: String, style: ButtonStyle) -> frust_core::AnyView<State> {
449    any::<State, _>(text(label).themed_role(style.label_role()))
450}
451
452/// A declarative pressable button. See the [module docs](self).
453pub struct ButtonView<State: 'static> {
454    label: String,
455    on_press: OnPress<State>,
456    style: ButtonStyle,
457    small: bool,
458    loading: bool,
459    disabled: bool,
460    /// `None` = the default stretch-aware behaviour — see the [module
461    /// docs](self)'s "Label alignment" section.
462    label_alignment: Option<Alignment>,
463}
464
465/// Create a button labelled `label` that runs `on_press` against the app state
466/// when released inside its bounds.
467pub fn button<State: 'static, F: Fn(&mut State) + 'static>(
468    label: impl Into<String>,
469    on_press: F,
470) -> ButtonView<State> {
471    ButtonView {
472        label: label.into(),
473        on_press: Rc::new(on_press),
474        style: ButtonStyle::default(),
475        small: false,
476        loading: false,
477        disabled: false,
478        label_alignment: None,
479    }
480}
481
482/// PascalCase alias for [`button`], matching the container view-fn vocabulary.
483#[allow(non_snake_case)]
484pub fn Button<State: 'static, F: Fn(&mut State) + 'static>(
485    label: impl Into<String>,
486    on_press: F,
487) -> ButtonView<State> {
488    button(label, on_press)
489}
490
491impl<State: 'static> ButtonView<State> {
492    /// Select the visual style (default [`ButtonStyle::Primary`]).
493    pub fn style(mut self, style: ButtonStyle) -> Self {
494        self.style = style;
495        self
496    }
497
498    /// Use the reduced `.small()` padding scale.
499    pub fn small(mut self) -> Self {
500        self.small = true;
501        self
502    }
503
504    /// Show a loading spinner in place of the label while `loading` is
505    /// `true`, suppressing `on_press` and reporting disabled semantics for as
506    /// long as it's shown.
507    pub fn loading(mut self, loading: bool) -> Self {
508        self.loading = loading;
509        self
510    }
511
512    /// Disable the button while `disabled` is `true`, suppressing `on_press`,
513    /// blocking focus acquisition, and dimming the appearance (fill, border,
514    /// label) by multiplying their alpha by [`DISABLED_ALPHA`]. Precedence:
515    /// if both `.disabled(true)` and `.loading(true)`, disabled takes effect
516    /// (both suppress interaction and report disabled semantics anyway).
517    pub fn disabled(mut self, disabled: bool) -> Self {
518        self.disabled = disabled;
519        self
520    }
521
522    /// Explicitly position the label within a stretched button, overriding
523    /// the stretch-aware default on both axes — see the [module
524    /// docs](self)'s "Label alignment" section. Ignored under
525    /// [`ButtonStyle::Icon`], which is always centered.
526    pub fn label_alignment(mut self, alignment: Alignment) -> Self {
527        self.label_alignment = Some(alignment);
528        self
529    }
530}
531
532/// The retained widget for a [`ButtonView`]. The label is a nested
533/// [`crate::TextWidget`] owned as a [`ChildPod`].
534pub struct ButtonWidget {
535    label: ChildPod,
536    /// The label text, retained for the semantics node's accessible name (the
537    /// label lives inside the `label` pod as a `TextWidget`; a button is a single
538    /// a11y node, so it reads its name from here rather than recursing).
539    label_text: String,
540    /// The pressed *visual* state (background darkens, and the press-scale
541    /// animation targets `PRESSED_SCALE`). Follows the cursor in/out while
542    /// captured, and is purely cosmetic.
543    pressed: bool,
544    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
545    /// Gates all `Move`/`Up` handling so a hover `Move` (dispatched by the
546    /// desktop shell on every cursor motion) never latches `pressed` or fires
547    /// the callback without a preceding press.
548    captured: bool,
549    on_press: crate::authoring::ErasedCallback,
550    style: ButtonStyle,
551    small: bool,
552    loading: bool,
553    /// Whether the button is disabled (suppresses press, blocks focus, dims appearance).
554    disabled: bool,
555    /// See [`ButtonView::label_alignment`] / the [module docs](self)'s
556    /// "Label alignment" section.
557    label_alignment: Option<Alignment>,
558    /// The press-feedback scale driver — see the [module docs](self).
559    press: PressAnim,
560    /// The loading-spinner rotation controller — always `repeat()`ing
561    /// once started in `build`; only advanced/painted while `loading`.
562    spinner: AnimationController,
563}
564
565/// Whether a widget-local `pos` lies within a `size`-sized box anchored at the
566/// origin (the button's own bounds).
567fn inside(pos: Point, size: Size) -> bool {
568    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
569}
570
571impl ButtonWidget {
572    /// The corner radius: themed `shape.small` (8dp — one step up from today's 6px
573    /// fallback, the closest M3 token; a visually negligible change), resolved
574    /// against the box so it never exceeds a pill. Unthemed: the [`RADIUS`]
575    /// constant exactly. Shared by every style, including
576    /// [`ButtonStyle::Icon`] ("secondary-shaped" — see the [module docs](self)).
577    fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
578        match theme {
579            Some(theme) => {
580                frust_theme::ShapeScale::resolve(theme.shape.small, size.width, size.height)
581            }
582            None => RADIUS,
583        }
584    }
585
586    /// Resolve the label's origin for every non-Icon style — see the [module
587    /// docs](self)'s "Label alignment" section. `final_size` is the button's
588    /// own post-`BoxConstraints` box; `content_size` is the label's natural
589    /// wrapped size (label + padding, pre-constrain), used to detect
590    /// stretch. Reuses [`Alignment`]'s `-1.0..=1.0` fraction convention
591    /// directly (its `fraction` helper is private to `align.rs`, so the
592    /// two-line remap is duplicated here rather than exposed just for this).
593    fn resolve_label_origin(
594        alignment: Option<Alignment>,
595        pad_x: f64,
596        pad_y: f64,
597        final_size: Size,
598        content_size: Size,
599    ) -> Point {
600        // Free space beyond the label's natural content box on each axis —
601        // zero unless a `BoxConstraints` min has stretched the button past
602        // it. Clamped at zero defensively: `final_size` should never shrink
603        // below `content_size` (padding never underflows), but a clamp here
604        // costs nothing and avoids ever pushing the label negative.
605        let free_x = (final_size.width - content_size.width).max(0.0);
606        let free_y = (final_size.height - content_size.height).max(0.0);
607        // No explicit alignment: leading-pinned on both axes (today's only
608        // behaviour) unless the button has been stretched *wider* than its
609        // natural content, which defaults the horizontal axis to centered.
610        // Vertical stretch never auto-centers (narrower than the reported
611        // full-bleed-*width* defect) — an explicit call is the only way to
612        // center vertically.
613        let effective = alignment
614            .unwrap_or_else(|| Alignment::new(if free_x > 0.0 { 0.0 } else { -1.0 }, -1.0));
615        let frac = |component: f64| (component + 1.0) / 2.0;
616        Point::new(
617            pad_x + free_x * frac(effective.x),
618            pad_y + free_y * frac(effective.y),
619        )
620    }
621
622    /// Paint the loading spinner (a rotating partial ring) centered on the
623    /// button, in `color`.
624    fn paint_spinner(&self, ctx: &PaintCtx, scene: &mut dyn PaintScene, color: Color) {
625        let size = ctx.size();
626        let center_local = Point::new(size.width / 2.0, size.height / 2.0);
627        let radius = (size.height / 2.0 * SPINNER_RADIUS_RATIO).max(SPINNER_MIN_RADIUS);
628        let angle = self.spinner.value() * std::f64::consts::TAU;
629        let arc = KurboArc::new(
630            center_local,
631            Vec2::new(radius, radius),
632            angle,
633            SPINNER_SWEEP,
634            0.0,
635        );
636        let path = arc.to_path(0.1);
637        scene.stroke_path(
638            ctx.origin(),
639            &path,
640            SPINNER_STROKE_WIDTH,
641            &Brush::Solid(color),
642        );
643    }
644}
645
646impl<State: 'static> View<State> for ButtonView<State> {
647    type Element = ButtonWidget;
648
649    fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonWidget {
650        let label_view = label_view::<State>(self.label.clone(), self.style);
651        let mut spinner = AnimationController::new(Duration::from_millis(SPINNER_PERIOD_MS))
652            .with_curve(Curve::Linear);
653        spinner.repeat();
654        ButtonWidget {
655            label: crate::authoring::build_child(&label_view, ctx),
656            label_text: self.label.clone(),
657            pressed: false,
658            captured: false,
659            on_press: crate::authoring::erase_callback(&self.on_press),
660            style: self.style,
661            small: self.small,
662            loading: self.loading,
663            disabled: self.disabled,
664            label_alignment: self.label_alignment,
665            press: PressAnim::new(),
666            spinner,
667        }
668    }
669
670    fn rebuild(
671        &self,
672        prev: &Self,
673        element: &mut ButtonWidget,
674        ctx: &mut BuildCtx<'_>,
675    ) -> ChangeFlags {
676        // Closures are not comparable — always reinstall the adapter.
677        element.on_press = crate::authoring::erase_callback(&self.on_press);
678        let mut flags = ChangeFlags::NONE;
679        if prev.label != self.label || prev.style != self.style {
680            element.label_text = self.label.clone();
681            let prev_view = label_view::<State>(prev.label.clone(), prev.style);
682            let next_view = label_view::<State>(self.label.clone(), self.style);
683            flags |=
684                crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
685        }
686        if prev.style != self.style {
687            element.style = self.style;
688            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
689        }
690        if prev.small != self.small {
691            element.small = self.small;
692            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
693        }
694        if prev.label_alignment != self.label_alignment {
695            element.label_alignment = self.label_alignment;
696            // Alignment only moves the label's origin, never the button's
697            // own painted fill/border, but a new origin still needs a
698            // relayout pass to take effect (`ChildPod::set_origin` isn't
699            // itself a repaint trigger).
700            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
701        }
702        if prev.loading != self.loading {
703            element.loading = self.loading;
704            flags |= ChangeFlags::PAINT;
705            if self.loading {
706                // Loading suppresses the whole event pass from here on (see
707                // `Widget::event`), so a still-armed press would never see
708                // its terminating Up/Cancel — disarm it now rather than
709                // leave a stale capture flag behind.
710                element.pressed = false;
711                element.captured = false;
712                element.press.set_pressed(false);
713            }
714        }
715        if prev.disabled != self.disabled {
716            element.disabled = self.disabled;
717            flags |= ChangeFlags::PAINT;
718            if self.disabled {
719                // Disabled suppresses the whole event pass from here on (see
720                // `Widget::event`), so a still-armed press would never see
721                // its terminating Up/Cancel — disarm it now.
722                element.pressed = false;
723                element.captured = false;
724                element.press.set_pressed(false);
725            }
726        }
727        flags
728    }
729
730    fn teardown(&self, element: &mut ButtonWidget, ctx: &mut BuildCtx<'_>) {
731        let label_view = label_view::<State>(self.label.clone(), self.style);
732        crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
733    }
734}
735
736impl Widget for ButtonWidget {
737    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
738        let (pad_x, pad_y) = if self.small {
739            (SMALL_PAD_X, SMALL_PAD_Y)
740        } else {
741            (PAD_X, PAD_Y)
742        };
743        // Lay the label out inside the padded content box, then grow to wrap it.
744        let inset = Size::new(pad_x * 2.0, pad_y * 2.0);
745        let inner_max = Size::new(
746            (bc.max().width - inset.width).max(0.0),
747            (bc.max().height - inset.height).max(0.0),
748        );
749        let label_size = self
750            .label
751            .layout_child(ctx, &BoxConstraints::loose(inner_max));
752        let mut size = Size::new(
753            label_size.width + inset.width,
754            label_size.height + inset.height,
755        );
756        // The label's *natural* wrapped size (label + padding), pre-constrain
757        // — used below to detect stretch (`resolve_label_origin`) and, for
758        // Icon, to compute the pre-square origin exactly as before this
759        // option existed.
760        let content_size = size;
761        if self.style == ButtonStyle::Icon {
762            // Square, secondary-shaped (module docs): grow the shorter side to
763            // match the longer one, then re-center the label inside it.
764            // `label_alignment` is ignored here — Icon is always centered
765            // (see the module docs' "Label alignment" section).
766            let side = size.width.max(size.height);
767            size = Size::new(side, side);
768            self.label.set_origin(Point::new(
769                (side - label_size.width) / 2.0,
770                (side - label_size.height) / 2.0,
771            ));
772        } else {
773            let final_size = bc.constrain(size);
774            self.label.set_origin(Self::resolve_label_origin(
775                self.label_alignment,
776                pad_x,
777                pad_y,
778                final_size,
779                content_size,
780            ));
781        }
782        bc.constrain(size)
783    }
784
785    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
786        // Resolve every theme-derived value up front, before any `&mut ctx`
787        // use below (`request_frame`) — `theme` borrows `ctx` shared, and its
788        // last use must precede a later mutable reborrow for this to
789        // typecheck under NLL (the reason `ink`/`press_timing` are resolved
790        // here rather than lazily, next to where each is consumed).
791        let theme = Theme::from_paint_ctx(ctx);
792        let mut paint = self.style.resolve(theme);
793        let radius = Self::resolve_radius(theme, ctx.size());
794        // Press-feedback scale: `Down`/`Up`/`Cancel` only recorded the target
795        // (see the module docs); resolve the direction's `Timing` now that a
796        // theme is in scope.
797        let press_timing = resolve_press_timing(theme, self.pressed);
798        let mut ink = self.style.resolve_ink(theme);
799        // Resolved now (last use of the shared `theme` borrow — see the
800        // comment above) so the `loading` branch below can check it without
801        // re-borrowing `theme` across the intervening `&mut ctx` calls.
802        let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
803
804        // Apply disabled dimming to fill, border, and ink (label/spinner color).
805        // This multiplies the resolved theme color's alpha, so it behaves
806        // identically under Material, Cupertino, Glyph, and the unthemed
807        // fallback constants.
808        if self.disabled {
809            paint.fill = disabled_alpha(paint.fill);
810            paint.fill_pressed = disabled_alpha(paint.fill_pressed);
811            if let Some(ref mut border) = paint.border {
812                *border = disabled_alpha(*border);
813            }
814            ink = disabled_alpha(ink);
815        }
816
817        let fill = if self.pressed {
818            paint.fill_pressed
819        } else {
820            paint.fill
821        };
822
823        if self.press.advance(ctx.frame_time(), press_timing) {
824            ctx.request_frame();
825        }
826        let scale = self.press.value();
827        let origin = ctx.origin();
828        let size = ctx.size();
829        let pivot = Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
830        scene.push_transform(scale_about(pivot, scale));
831
832        if fill != Color::TRANSPARENT {
833            scene.fill_rounded_rect(origin, size, radius, fill);
834        }
835        if let Some(border) = paint.border {
836            // Inset by half the stroke width so the hairline paints fully
837            // inside the button's own bounds (a stroke is centered on its
838            // path) — mirrors `material::card`'s outlined-variant precedent.
839            let half = BORDER_WIDTH / 2.0;
840            let rr = RoundedRect::new(
841                half,
842                half,
843                size.width - half,
844                size.height - half,
845                (radius - half).max(0.0),
846            );
847            let path = rr.to_path(BORDER_TOLERANCE);
848            scene.stroke_path(origin, &path, BORDER_WIDTH, &Brush::Solid(border));
849        }
850
851        if self.loading {
852            // `reduce_motion` freezes the spinner wherever it currently sits
853            // and stops requesting frames — the same skip-animation shape
854            // `material::loading_indicator`'s morph loop uses (and
855            // `glyph::skeleton`'s shimmer).
856            if !reduce_motion {
857                self.spinner.advance(ctx.frame_time());
858                // The loading-spinner is a perpetual decorative loop — its exact
859                // cadence is imperceptible, so the mobile frame gate may pace it.
860                ctx.request_frame_paced();
861            }
862            self.paint_spinner(ctx, scene, ink);
863        } else {
864            self.label.paint_child(ctx, scene);
865        }
866
867        scene.pop_transform();
868    }
869
870    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
871        // Loading and disabled both suppress on_press and every other interaction
872        // (disabled semantics — see `Widget::semantics` and the module docs).
873        if self.loading || self.disabled {
874            return EventResult::Ignored;
875        }
876        if activation(ctx, event) {
877            (self.on_press)(ctx);
878            ctx.request_redraw();
879            return EventResult::Handled;
880        }
881        let InputEvent::Pointer(p) = event else {
882            return EventResult::Ignored;
883        };
884        match p.phase {
885            PointerPhase::Down => {
886                if !presses(p) {
887                    return EventResult::Ignored;
888                }
889                if press_claims_focus(ctx) {
890                    ctx.request_focus();
891                }
892                self.pressed = true;
893                self.captured = true;
894                self.press.set_pressed(true);
895                ctx.capture_pointer();
896                ctx.request_redraw();
897                EventResult::Handled
898            }
899            PointerPhase::Move => {
900                // Only a press we armed on `Down` tracks the cursor; a hover
901                // `Move` (no prior press) is not ours.
902                if !self.captured {
903                    return EventResult::Ignored;
904                }
905                // Visual only: track whether the cursor is still over the button.
906                self.pressed = inside(p.position, ctx.size());
907                self.press.set_pressed(self.pressed);
908                ctx.request_redraw();
909                EventResult::Handled
910            }
911            PointerPhase::Up => {
912                if !self.captured {
913                    return EventResult::Ignored;
914                }
915                // Fire on up-inside only (masonry semantics).
916                if inside(p.position, ctx.size()) {
917                    (self.on_press)(ctx);
918                }
919                self.pressed = false;
920                self.captured = false;
921                self.press.set_pressed(false);
922                ctx.request_redraw();
923                EventResult::Handled
924            }
925            PointerPhase::Cancel => {
926                if !self.captured {
927                    return EventResult::Ignored;
928                }
929                self.pressed = false;
930                self.captured = false;
931                self.press.set_pressed(false);
932                ctx.request_redraw();
933                EventResult::Handled
934            }
935        }
936    }
937
938    fn semantics(&self, ctx: &mut SemanticsCtx) {
939        // A button is a single a11y node (Role::Button) labelled by its text; it
940        // does not expose its inner label as a separate child node. It advertises
941        // the Click action it fires on release — unless loading or disabled,
942        // which both report disabled semantics instead.
943        ctx.push_node(Role::Button, |node| {
944            node.set_label(self.label_text.as_str());
945            if self.loading || self.disabled {
946                node.set_disabled();
947            } else {
948                node.add_action(Action::Click);
949            }
950        });
951    }
952
953    /// A Tab stop unless disabled (a loading button keeps its focus). A primary mouse press also
954    /// takes focus — see the module docs' Keyboard section.
955    fn is_focusable(&self) -> bool {
956        !self.disabled
957    }
958
959    crate::authoring::visit_children!(label);
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use std::any::Any;
966
967    #[derive(Default)]
968    struct Counter {
969        presses: u32,
970    }
971
972    /// Build a button widget over `Counter` state, with a known 100x40 size for
973    /// the inside/outside geometry (set directly, avoiding a text-context layout).
974    fn widget() -> ButtonWidget {
975        let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1);
976        let mut counter = 0u64;
977        View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
978    }
979
980    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
981        InputEvent::Pointer(frust_core::PointerEvent {
982            phase,
983            position: Point::new(x, y),
984            button: frust_core::PointerButton::Primary,
985        })
986    }
987
988    /// The same event on the secondary (right) button.
989    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
990        InputEvent::Pointer(frust_core::PointerEvent {
991            phase,
992            position: Point::new(x, y),
993            button: frust_core::PointerButton::Secondary,
994        })
995    }
996
997    fn dispatch(w: &mut ButtonWidget, state: &mut Counter, event: &InputEvent) {
998        let state_any: &mut dyn Any = state;
999        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1000        w.event(&mut ctx, event);
1001    }
1002
1003    #[test]
1004    fn a_secondary_press_neither_presses_nor_captures_nor_fires() {
1005        let mut w = widget();
1006        let mut state = Counter::default();
1007        dispatch(
1008            &mut w,
1009            &mut state,
1010            &secondary_ev(PointerPhase::Down, 10.0, 10.0),
1011        );
1012        assert!(!w.pressed, "no pressed chrome on a right-click");
1013        assert!(!w.captured, "and no capture for the shell to wedge on");
1014        dispatch(
1015            &mut w,
1016            &mut state,
1017            &secondary_ev(PointerPhase::Up, 10.0, 10.0),
1018        );
1019        assert_eq!(state.presses, 0);
1020
1021        // The primary gesture is untouched by the guard.
1022        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1023        assert!(w.pressed);
1024        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1025        assert_eq!(state.presses, 1);
1026    }
1027
1028    #[test]
1029    fn down_then_up_inside_fires_once() {
1030        let mut w = widget();
1031        let mut state = Counter::default();
1032        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1033        assert!(w.pressed);
1034        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
1035        assert_eq!(state.presses, 1);
1036        assert!(!w.pressed);
1037    }
1038
1039    #[test]
1040    fn down_inside_move_out_up_outside_does_not_fire() {
1041        let mut w = widget();
1042        let mut state = Counter::default();
1043        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1044        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
1045        assert!(!w.pressed, "moving out clears the pressed visual");
1046        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 10.0));
1047        assert_eq!(state.presses, 0, "up outside must not fire");
1048    }
1049
1050    #[test]
1051    fn cancel_clears_pressed_without_firing() {
1052        let mut w = widget();
1053        let mut state = Counter::default();
1054        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1055        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1056        assert!(!w.pressed);
1057        assert_eq!(state.presses, 0);
1058    }
1059
1060    #[test]
1061    fn hover_move_without_down_is_ignored_noop() {
1062        let mut w = widget();
1063        let mut state = Counter::default();
1064        // A cursor drifting over the button with no prior press must not latch
1065        // pressed, fire, or request a redraw.
1066        let state_any: &mut dyn Any = &mut state;
1067        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1068        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
1069        assert!(matches!(result, EventResult::Ignored));
1070        assert!(!w.pressed, "hover must not press");
1071        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
1072        assert_eq!(state.presses, 0);
1073    }
1074
1075    #[test]
1076    fn up_without_down_does_not_fire() {
1077        let mut w = widget();
1078        let mut state = Counter::default();
1079        let state_any: &mut dyn Any = &mut state;
1080        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1081        let result = w.event(&mut ctx, &ev(PointerPhase::Up, 20.0, 20.0));
1082        assert!(matches!(result, EventResult::Ignored));
1083        assert_eq!(state.presses, 0, "an unarmed Up must never fire");
1084    }
1085
1086    #[test]
1087    fn cancel_clears_armed_state() {
1088        let mut w = widget();
1089        let mut state = Counter::default();
1090        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1091        assert!(w.captured);
1092        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1093        assert!(!w.captured, "Cancel disarms the press");
1094        // A subsequent hover Move must not re-press or fire.
1095        let state_any: &mut dyn Any = &mut state;
1096        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1097        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 12.0, 12.0));
1098        assert!(matches!(result, EventResult::Ignored));
1099        assert!(!w.pressed);
1100    }
1101
1102    /// A recording scene that captures each rounded rect's `(radius, color)`
1103    /// plus stroke calls' `(width, color)` and push_transform/pop_transform
1104    /// counts — extended from the original `RRectRecorder` to cover
1105    /// the border/press-scale paint paths.
1106    #[derive(Default)]
1107    struct RRectRecorder {
1108        rrects: Vec<(f64, Color)>,
1109        strokes: Vec<(f64, Color)>,
1110        transforms: Vec<Affine>,
1111        transform_pops: u32,
1112    }
1113
1114    impl PaintScene for RRectRecorder {
1115        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1116        fn draw_text(&mut self, _o: Point, _t: &str) {}
1117        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, radius: f64, color: Color) {
1118            self.rrects.push((radius, color));
1119        }
1120        fn stroke_path(
1121            &mut self,
1122            _origin: Point,
1123            _path: &kurbo::BezPath,
1124            width: f64,
1125            brush: &Brush,
1126        ) {
1127            if let Brush::Solid(color) = brush {
1128                self.strokes.push((width, *color));
1129            }
1130        }
1131        fn push_transform(&mut self, transform: Affine) {
1132            self.transforms.push(transform);
1133        }
1134        fn pop_transform(&mut self) {
1135            self.transform_pops += 1;
1136        }
1137    }
1138
1139    fn paint_bg(w: &mut ButtonWidget, theme: Option<&frust_theme::Theme>) -> (f64, Color) {
1140        let mut rec = RRectRecorder::default();
1141        let mut ctx = match theme {
1142            Some(t) => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(t),
1143            None => PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)),
1144        };
1145        w.paint(&mut ctx, &mut rec);
1146        *rec.rrects.first().expect("button paints its background")
1147    }
1148
1149    #[test]
1150    fn unthemed_paint_uses_fallback_constants() {
1151        // Parity: no theme → exactly today's fill and radius.
1152        let mut w = widget();
1153        assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
1154        // Pressed uses the darker constant, unchanged.
1155        w.pressed = true;
1156        assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL_PRESSED));
1157    }
1158
1159    #[test]
1160    fn themed_paint_resolves_primary_and_shape_small() {
1161        let theme = frust_theme::Theme::neutral();
1162        let mut w = widget();
1163        let (radius, color) = paint_bg(&mut w, Some(&theme));
1164        assert_eq!(color, theme.scheme().primary, "resting fill is primary");
1165        assert_eq!(radius, theme.shape.small, "radius is shape.small (8dp)");
1166        // Pressed fill is a darkened primary (not the unthemed constant).
1167        w.pressed = true;
1168        let (_, pressed) = paint_bg(&mut w, Some(&theme));
1169        assert_eq!(
1170            pressed,
1171            pressed_overlay(theme.scheme().primary, PRESSED_DARKEN)
1172        );
1173    }
1174
1175    #[test]
1176    fn move_back_inside_then_up_fires() {
1177        // out then back in: up-inside fires (masonry re-hover behaviour).
1178        let mut w = widget();
1179        let mut state = Counter::default();
1180        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1181        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
1182        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 20.0, 10.0));
1183        assert!(w.pressed);
1184        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 10.0));
1185        assert_eq!(state.presses, 1);
1186    }
1187
1188    // --- Style variants, small/loading, press-scale ------------------------
1189
1190    fn styled_widget(style: ButtonStyle) -> ButtonWidget {
1191        let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).style(style);
1192        let mut counter = 0u64;
1193        View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
1194    }
1195
1196    #[test]
1197    fn default_style_is_primary_and_matches_pre_task22_rendering() {
1198        // Default-style rendering is byte-identical to today's under both no
1199        // theme and M3 — proven against the exact pre-existing assertions
1200        // above (same constants, same theme roles), plus an explicit
1201        // `ButtonStyle::default()` identity check here.
1202        assert_eq!(ButtonStyle::default(), ButtonStyle::Primary);
1203        let mut w = widget();
1204        assert_eq!(w.style, ButtonStyle::Primary);
1205        assert_eq!(paint_bg(&mut w, None), (RADIUS, FILL));
1206        let theme = frust_theme::Theme::neutral();
1207        let mut w2 = widget();
1208        assert_eq!(paint_bg(&mut w2, Some(&theme)).1, theme.scheme().primary);
1209    }
1210
1211    #[test]
1212    fn secondary_style_paints_raised_surface_and_outline_border() {
1213        let theme = frust_theme::Theme::neutral();
1214        let mut w = styled_widget(ButtonStyle::Secondary);
1215        let mut rec = RRectRecorder::default();
1216        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1217        w.paint(&mut ctx, &mut rec);
1218        assert_eq!(rec.rrects[0].1, theme.scheme().surface_container_high);
1219        assert_eq!(rec.strokes[0].1, theme.scheme().outline);
1220    }
1221
1222    #[test]
1223    fn ghost_style_is_transparent_at_rest_and_washes_on_press() {
1224        let theme = frust_theme::Theme::neutral();
1225        let mut w = styled_widget(ButtonStyle::Ghost);
1226        let mut rec = RRectRecorder::default();
1227        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1228        w.paint(&mut ctx, &mut rec);
1229        assert!(
1230            rec.rrects.is_empty(),
1231            "a transparent resting fill paints no rect"
1232        );
1233        w.pressed = true;
1234        let mut rec2 = RRectRecorder::default();
1235        let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1236        w.paint(&mut ctx2, &mut rec2);
1237        assert_eq!(
1238            rec2.rrects[0].1,
1239            with_alpha(theme.scheme().on_surface, PRESSED_OPACITY)
1240        );
1241    }
1242
1243    #[test]
1244    fn danger_style_paints_error_border_and_error_faint_pressed_wash() {
1245        let theme = frust_theme::Theme::neutral();
1246        let mut w = styled_widget(ButtonStyle::Danger);
1247        let mut rec = RRectRecorder::default();
1248        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1249        w.paint(&mut ctx, &mut rec);
1250        assert!(rec.rrects.is_empty(), "Danger is transparent at rest");
1251        assert_eq!(rec.strokes[0].1, theme.scheme().error);
1252        w.pressed = true;
1253        let mut rec2 = RRectRecorder::default();
1254        let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1255        w.paint(&mut ctx2, &mut rec2);
1256        assert_eq!(rec2.rrects[0].1, theme.scheme().error_container);
1257    }
1258
1259    #[test]
1260    fn per_style_fill_border_label_hold_across_baselines() {
1261        // The same style -> role mapping (per the module docs' "uniform
1262        // across languages") reads straight off `ColorScheme` fields rather
1263        // than hardcoding a per-baseline table, so this asserts it against a
1264        // baseline matrix rather than a single one (each style is already
1265        // covered field-by-field by the `secondary`/`ghost`/`danger`_style_*
1266        // tests above). A design system's own baseline is exercised by that
1267        // design system's own tests; the mapping asserted here is
1268        // language-neutral by construction.
1269        let baselines = [frust_theme::Theme::neutral()];
1270        for theme in &baselines {
1271            let scheme = theme.scheme();
1272
1273            // Secondary: raised surface + outline border, on_surface label role.
1274            let mut secondary = styled_widget(ButtonStyle::Secondary);
1275            assert_eq!(
1276                ButtonStyle::Secondary.label_role(),
1277                ThemeTextColor::OnSurface
1278            );
1279            let mut rec = RRectRecorder::default();
1280            let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1281            secondary.paint(&mut ctx, &mut rec);
1282            assert_eq!(rec.rrects[0].1, scheme.surface_container_high);
1283            assert_eq!(rec.strokes[0].1, scheme.outline);
1284
1285            // Ghost: transparent at rest, on_surface_variant (muted) label role.
1286            assert_eq!(
1287                ButtonStyle::Ghost.label_role(),
1288                ThemeTextColor::OnSurfaceVariant
1289            );
1290            let mut ghost = styled_widget(ButtonStyle::Ghost);
1291            let mut rec_g = RRectRecorder::default();
1292            let mut ctx_g = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1293            ghost.paint(&mut ctx_g, &mut rec_g);
1294            assert!(rec_g.rrects.is_empty());
1295
1296            // Danger: error border + error label role, error_container pressed wash.
1297            assert_eq!(ButtonStyle::Danger.label_role(), ThemeTextColor::Error);
1298            let mut danger = styled_widget(ButtonStyle::Danger);
1299            danger.pressed = true;
1300            let mut rec_d = RRectRecorder::default();
1301            let mut ctx_d = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(theme);
1302            danger.paint(&mut ctx_d, &mut rec_d);
1303            assert_eq!(rec_d.rrects[0].1, scheme.error_container);
1304        }
1305    }
1306
1307    #[test]
1308    fn icon_style_layout_is_square() {
1309        let mut w = styled_widget(ButtonStyle::Icon);
1310        let mut text_ctx = frust_text::TextContext::new();
1311        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1312        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1313        assert_eq!(size.width, size.height, "Icon style must be square");
1314    }
1315
1316    #[test]
1317    fn small_uses_reduced_padding() {
1318        let normal_view = button::<Counter, _>("go", |_: &mut Counter| {});
1319        let small_view = button::<Counter, _>("go", |_: &mut Counter| {}).small();
1320        let mut counter = 0u64;
1321        let mut normal_w = View::<Counter>::build(&normal_view, &mut BuildCtx::new(&mut counter));
1322        let mut small_w = View::<Counter>::build(&small_view, &mut BuildCtx::new(&mut counter));
1323
1324        let mut text_ctx = frust_text::TextContext::new();
1325        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1326        let normal_size =
1327            normal_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1328        let small_size = small_w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1329        assert!(
1330            small_size.width < normal_size.width && small_size.height < normal_size.height,
1331            "small() must produce a smaller laid-out box: small={small_size:?} normal={normal_size:?}"
1332        );
1333    }
1334
1335    // --- Label alignment ----------------------------------------------------
1336
1337    /// Lay a button out under `bc` and return `(size, label_origin)`.
1338    fn layout_with(w: &mut ButtonWidget, bc: &BoxConstraints) -> (Size, Point) {
1339        let mut text_ctx = frust_text::TextContext::new();
1340        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
1341        let size = w.layout(&mut lctx, bc);
1342        (size, w.label.origin())
1343    }
1344
1345    #[test]
1346    fn natural_width_button_label_origin_is_unchanged_by_default() {
1347        // A loose constraint the label never grows into: the button wraps to
1348        // its natural content size, so there's no free space for any
1349        // alignment fraction to distribute — the label stays pinned at
1350        // (PAD_X, PAD_Y), byte-identical to pre-option behaviour.
1351        let view = button::<Counter, _>("go", |_: &mut Counter| {});
1352        let mut counter = 0u64;
1353        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1354        let (size, origin) = layout_with(&mut w, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1355        assert!(size.width < 200.0, "button must not have been stretched");
1356        assert_eq!(origin, Point::new(PAD_X, PAD_Y));
1357    }
1358
1359    #[test]
1360    fn stretched_button_defaults_to_centered_label() {
1361        // A width-only min constraint forces the button wider than its
1362        // natural content — with no explicit `.label_alignment()`, the
1363        // label defaults to horizontally centered.
1364        let view = button::<Counter, _>("go", |_: &mut Counter| {});
1365        let mut counter = 0u64;
1366        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1367        let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
1368        let (size, origin) = layout_with(&mut w, &bc);
1369        assert_eq!(size.width, 200.0, "min forces the full stretched width");
1370        let label_width = w.label.size().width;
1371        let expected_x = (size.width - label_width) / 2.0;
1372        assert!(
1373            (origin.x - expected_x).abs() < 1e-6,
1374            "stretched button must default to a centered label: got {origin:?}, expected x={expected_x}"
1375        );
1376        // Height was not stretched — the default never auto-centers that axis.
1377        assert_eq!(origin.y, PAD_Y);
1378    }
1379
1380    #[test]
1381    fn explicit_leading_alignment_overrides_the_stretched_default() {
1382        let view =
1383            button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::TOP_LEFT);
1384        let mut counter = 0u64;
1385        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1386        let bc = BoxConstraints::new(Size::new(200.0, 0.0), Size::new(200.0, 200.0));
1387        let (size, origin) = layout_with(&mut w, &bc);
1388        assert_eq!(size.width, 200.0, "min forces the full stretched width");
1389        assert_eq!(
1390            origin,
1391            Point::new(PAD_X, PAD_Y),
1392            "an explicit leading alignment pins at the padding even when stretched"
1393        );
1394    }
1395
1396    #[test]
1397    fn explicit_center_alignment_also_centers_vertically_when_stretched() {
1398        // Height auto-centering never happens by default, but an explicit
1399        // `Alignment::CENTER` still applies to both axes.
1400        let view =
1401            button::<Counter, _>("go", |_: &mut Counter| {}).label_alignment(Alignment::CENTER);
1402        let mut counter = 0u64;
1403        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1404        let bc = BoxConstraints::tight(Size::new(200.0, 100.0));
1405        let (size, origin) = layout_with(&mut w, &bc);
1406        assert_eq!(size, Size::new(200.0, 100.0));
1407        assert!(
1408            origin.y > PAD_Y,
1409            "an explicit CENTER alignment must also center vertically: got {origin:?}"
1410        );
1411    }
1412
1413    #[test]
1414    fn icon_style_ignores_explicit_label_alignment() {
1415        // Icon is definitionally centered (module docs) — an explicit
1416        // `.label_alignment()` must not change its square-centered layout;
1417        // the widget's own layout must be identical with or without one.
1418        let plain = button::<Counter, _>("i", |_: &mut Counter| {}).style(ButtonStyle::Icon);
1419        let with_alignment = button::<Counter, _>("i", |_: &mut Counter| {})
1420            .style(ButtonStyle::Icon)
1421            .label_alignment(Alignment::TOP_LEFT);
1422        let mut counter = 0u64;
1423        let mut plain_w = View::<Counter>::build(&plain, &mut BuildCtx::new(&mut counter));
1424        let mut aligned_w =
1425            View::<Counter>::build(&with_alignment, &mut BuildCtx::new(&mut counter));
1426        let bc = BoxConstraints::loose(Size::new(200.0, 200.0));
1427        let (plain_size, plain_origin) = layout_with(&mut plain_w, &bc);
1428        let (aligned_size, aligned_origin) = layout_with(&mut aligned_w, &bc);
1429        assert_eq!(
1430            plain_size.width, plain_size.height,
1431            "Icon style must be square"
1432        );
1433        assert_eq!(
1434            (plain_size, plain_origin),
1435            (aligned_size, aligned_origin),
1436            "an explicit label_alignment must be ignored under ButtonStyle::Icon"
1437        );
1438    }
1439
1440    #[test]
1441    fn loading_suppresses_on_press_and_reports_disabled() {
1442        let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).loading(true);
1443        let mut counter = 0u64;
1444        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1445        let mut state = Counter::default();
1446        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1447        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1448        assert_eq!(state.presses, 0, "loading must suppress on_press");
1449    }
1450
1451    #[test]
1452    fn disabled_suppresses_on_press() {
1453        let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
1454        let mut counter = 0u64;
1455        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1456        let mut state = Counter::default();
1457        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1458        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1459        assert_eq!(state.presses, 0, "disabled must suppress on_press");
1460    }
1461
1462    #[test]
1463    fn disabled_blocks_focus_and_press_suppression() {
1464        // A disabled button returns Ignored on a Down, preventing pointer capture
1465        // and press state setup — identical to loading's suppression.
1466        let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1).disabled(true);
1467        let mut counter = 0u64;
1468        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1469        let mut state = Counter::default();
1470        let state_any: &mut dyn Any = &mut state;
1471        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1472        let result = w.event(&mut ctx, &ev(PointerPhase::Down, 10.0, 10.0));
1473        assert!(
1474            matches!(result, EventResult::Ignored),
1475            "disabled must return Ignored, not Handled"
1476        );
1477        assert!(!w.captured, "disabled must not capture pointer");
1478        assert!(!w.pressed, "disabled must not set pressed state");
1479    }
1480
1481    #[test]
1482    fn disabled_dims_appearance_by_alpha_multiplication() {
1483        // A disabled button multiplies its resolved theme colors' alpha by
1484        // DISABLED_ALPHA without changing the colors themselves, so the
1485        // dimming works identically under Material, Cupertino, Glyph, and
1486        // unthemed modes.
1487        let theme = frust_theme::Theme::neutral();
1488        let mut w = widget();
1489        w.disabled = true;
1490        let mut rec = RRectRecorder::default();
1491        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1492        w.paint(&mut ctx, &mut rec);
1493
1494        // The resting fill should be the primary color dimmed by DISABLED_ALPHA.
1495        let undimmed = theme.scheme().primary;
1496        let dimmed = disabled_alpha(undimmed);
1497        assert_eq!(
1498            rec.rrects[0].1, dimmed,
1499            "disabled button must dim the fill by multiplying alpha: got {:?}, expected {:?}",
1500            rec.rrects[0].1, dimmed
1501        );
1502    }
1503
1504    #[test]
1505    fn disabled_and_loading_interaction() {
1506        // Disabled takes precedence over loading — if both are true, the button
1507        // is treated as disabled (both suppress interaction anyway, but we test
1508        // the precedence semantics is respected by verifying press suppression).
1509        let view = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1)
1510            .loading(true)
1511            .disabled(true);
1512        let mut counter = 0u64;
1513        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
1514        let mut state = Counter::default();
1515        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1516        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1517        assert_eq!(state.presses, 0, "disabled+loading must suppress on_press");
1518        // Both flags stay set in the widget (neither clears the other on rebuild).
1519        assert!(w.loading, "loading flag is preserved");
1520        assert!(w.disabled, "disabled flag is preserved");
1521    }
1522
1523    /// `reduce_motion` freezes the loading spinner wherever it currently sits
1524    /// and stops requesting frames — the same skip-animation shape
1525    /// `material::loading_indicator`'s morph loop uses: without this, an
1526    /// always-`.loading(true)` button — e.g. a disabled-look demo — spins
1527    /// forever regardless of a header toggle forcing
1528    /// `Theme.motion.reduce_motion`. The spinner requests frames via the
1529    /// paced (CosmeticLoop) class, letting the mobile frame gate throttle it
1530    /// to the theme's `cosmetic_loop_rate`.
1531    #[test]
1532    fn loading_spinner_freezes_and_stops_requesting_frames_under_reduce_motion() {
1533        let mut w = widget();
1534        w.loading = true;
1535
1536        let mut theme = frust_theme::Theme::neutral();
1537        theme.motion.reduce_motion = false;
1538        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1539        let mut scene = RRectRecorder::default();
1540        w.paint(&mut ctx, &mut scene);
1541        assert!(
1542            ctx.needs_frame(),
1543            "with reduce_motion off, the loading spinner must keep requesting frames"
1544        );
1545        assert!(
1546            ctx.needs_frame_paced_only(),
1547            "the loading spinner is a CosmeticLoop request — the frame gate must be able to pace it"
1548        );
1549
1550        theme.motion.reduce_motion = true;
1551        let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(100.0, 40.0)).with_theme(&theme);
1552        let mut scene2 = RRectRecorder::default();
1553        w.paint(&mut ctx2, &mut scene2);
1554        assert!(
1555            !ctx2.needs_frame(),
1556            "with reduce_motion on, the loading spinner must stop requesting frames"
1557        );
1558    }
1559
1560    #[test]
1561    fn press_scale_timeline_down_mid_up_cancel() {
1562        // Down -> mid-anim scale < 1.0 -> Up restores; a fresh Down -> Cancel
1563        // also restores without firing. Every
1564        // `dispatch` below drives the real event path (which itself calls
1565        // `PressAnim::set_pressed`); the retained driver is then advanced
1566        // directly at chosen frame times — mirrors `motion::animated`'s test
1567        // precedent, since `PaintCtx::set_frame_time` is crate-private, so a
1568        // widget test cannot drive `paint` at an arbitrary frame time.
1569        let mut w = widget();
1570        let mut state = Counter::default();
1571
1572        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1573        let down_timing = resolve_press_timing(None, true);
1574        assert!(
1575            w.press.advance(FrameTime::from_nanos(0), down_timing),
1576            "the seed frame must still report animating"
1577        );
1578        // Advance partway into the 100ms fallback duration.
1579        let mid = FrameTime::from_nanos(50_000_000);
1580        w.press.advance(mid, down_timing);
1581        assert!(
1582            w.press.value() < 1.0,
1583            "mid-press the scale must read below 1.0, got {}",
1584            w.press.value()
1585        );
1586
1587        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1588        assert_eq!(state.presses, 1);
1589        let up_timing = resolve_press_timing(None, false);
1590        // Settle the release animation (bounded loop, mirrors `motion::animated`'s
1591        // settle-and-assert precedent).
1592        let mut t = mid.as_nanos();
1593        let mut still_animating = true;
1594        for _ in 0..1000 {
1595            still_animating = w.press.advance(FrameTime::from_nanos(t), up_timing);
1596            if !still_animating {
1597                break;
1598            }
1599            t += 1_000_000; // +1ms
1600        }
1601        assert!(
1602            !still_animating,
1603            "the release animation must settle within a bounded number of steps"
1604        );
1605        assert!(
1606            (w.press.value() - REST_SCALE).abs() < 1e-6,
1607            "Up must restore to REST_SCALE, got {}",
1608            w.press.value()
1609        );
1610
1611        // Cancel path: press again, then Cancel restores without firing again.
1612        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1613        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
1614        assert_eq!(state.presses, 1, "Cancel must not fire");
1615        assert_eq!(w.press.target, REST_SCALE, "Cancel retargets to rest");
1616    }
1617
1618    mod keyboard {
1619        use super::*;
1620        use frust_core::{DiscardScene, Key, KeyEvent, Modifiers, NamedKey, RenderRoot};
1621
1622        fn key(key: Key, modifiers: Modifiers) -> InputEvent {
1623            InputEvent::Key(KeyEvent {
1624                key,
1625                modifiers,
1626                repeat: false,
1627            })
1628        }
1629
1630        fn space() -> InputEvent {
1631            key(Key::Character(" ".to_string()), Modifiers::default())
1632        }
1633
1634        fn root(disabled: bool) -> RenderRoot<Counter, crate::FlexView<Counter>> {
1635            let mut root = RenderRoot::new();
1636            let mut logic = move |_: &mut Counter| {
1637                let go = button::<Counter, _>("go", |s: &mut Counter| s.presses += 1);
1638                crate::column().child(go.disabled(disabled))
1639            };
1640            root.rebuild(&mut logic, &mut Counter::default());
1641            let mut tcx = frust_text::TextContext::new();
1642            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1643            root.paint(&mut DiscardScene, FrameTime::ZERO);
1644            root
1645        }
1646
1647        #[test]
1648        fn an_enabled_button_is_focusable_and_a_disabled_one_is_not() {
1649            assert!(widget().is_focusable());
1650            let view = button::<Counter, _>("go", |_| {}).disabled(true);
1651            let w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut 0u64));
1652            assert!(!w.is_focusable());
1653        }
1654
1655        #[test]
1656        fn tab_focuses_the_button_then_space_or_enter_presses_it() {
1657            let mut root = root(false);
1658            let mut state = Counter::default();
1659            root.event(&mut state, &space());
1660            assert_eq!(state.presses, 0, "an unfocused button takes no keys");
1661            root.event(
1662                &mut state,
1663                &key(Key::Named(NamedKey::Tab), Modifiers::default()),
1664            );
1665            assert!(root.is_focus_active());
1666            assert!(root.event(&mut state, &space()).handled);
1667            let enter = key(Key::Named(NamedKey::Enter), Modifiers::default());
1668            assert!(root.event(&mut state, &enter).handled);
1669            assert_eq!(state.presses, 2);
1670            let ctrl = Modifiers {
1671                ctrl: true,
1672                ..Modifiers::default()
1673            };
1674            root.event(&mut state, &key(Key::Named(NamedKey::Enter), ctrl));
1675            assert_eq!(state.presses, 2, "a chorded Enter is left for shortcuts");
1676        }
1677
1678        #[test]
1679        fn a_click_claims_focus_and_a_disabled_button_is_skipped() {
1680            let mut root = root(false);
1681            let mut state = Counter::default();
1682            root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1683            root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1684            assert_eq!(state.presses, 1);
1685            assert!(root.is_focus_active(), "a press claims focus");
1686
1687            let mut root = self::root(true);
1688            root.event(
1689                &mut state,
1690                &key(Key::Named(NamedKey::Tab), Modifiers::default()),
1691            );
1692            assert!(!root.is_focus_active(), "no stop to land on");
1693        }
1694
1695        #[test]
1696        fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
1697            let mut root: RenderRoot<Counter, crate::FlexView<Counter>> = RenderRoot::new();
1698            let mut logic = |_: &mut Counter| {
1699                crate::column()
1700                    .child(button::<Counter, _>("a", |s: &mut Counter| s.presses += 1))
1701                    .child(button::<Counter, _>("b", |s: &mut Counter| s.presses += 10))
1702            };
1703            let mut state = Counter::default();
1704            root.rebuild(&mut logic, &mut state);
1705            let mut tcx = frust_text::TextContext::new();
1706            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1707            root.paint(&mut DiscardScene, FrameTime::ZERO);
1708            root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1709            root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1710            assert!(root.is_focus_active());
1711            root.event(
1712                &mut state,
1713                &key(Key::Named(NamedKey::Tab), Modifiers::default()),
1714            );
1715            root.event(&mut state, &space());
1716            assert_eq!(state.presses, 11, "Tab went to the second button");
1717        }
1718
1719        #[test]
1720        fn a_touch_tap_presses_without_claiming_focus() {
1721            let mut root = root(false);
1722            let mut state = Counter::default();
1723            for phase in [PointerPhase::Down, PointerPhase::Up] {
1724                let InputEvent::Pointer(event) = ev(phase, 5.0, 5.0) else {
1725                    unreachable!()
1726                };
1727                let contact = InputEvent::PointerContact {
1728                    pointer_id: frust_core::event::PointerId::touch(0),
1729                    event,
1730                };
1731                root.event(&mut state, &contact);
1732            }
1733            assert_eq!(state.presses, 1);
1734            assert!(!root.is_focus_active(), "a tap leaves focus alone");
1735        }
1736
1737        #[test]
1738        fn an_unfocused_key_event_is_ignored() {
1739            let mut w = widget();
1740            let mut state = Counter::default();
1741            let state_any: &mut dyn Any = &mut state;
1742            let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 40.0));
1743            assert_eq!(w.event(&mut ctx, &space()), EventResult::Ignored);
1744        }
1745    }
1746}