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