Skip to main content

frust_material/button/
mod.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/buttons/` tree is itself vendored from m3e_buttons
3// (MIT, © 2026 Mudit Purohit) — `m3e_buttons.dart` + its `components/`,
4// `enums/`, `models/`, `res/`, `styles/` parts.
5// The Dart widget delegates its surface to Flutter's own FilledButton/
6// OutlinedButton/…; this port paints that surface itself (see the module
7// docs' Flutter-plumbing mapping table).
8
9//! The Material 3 Expressive **button**: the catalog's flagship control.
10//!
11//! [`button`] builds the default filled button; [`outlined_button`],
12//! [`tonal_button`], [`elevated_button`] and [`text_button`] are the reference's
13//! other four named constructors ([`ButtonView::variant`] switches between them
14//! after the fact), and [`button_with_icon`] is its `M3EButton.icon` factory —
15//! an icon+label row laid out with the size's own icon size and gap. Every one
16//! of them fires `on_press` on release inside its bounds and leaves its own
17//! state alone: a button reports an action, it confirms no value (see
18//! `docs/CODE_STANDARDS.md`'s Interaction Semantics).
19//!
20//! # Variants
21//!
22//! Container / label roles, from `m3e_button_theme.dart:111` (`container`) and
23//! `:127` (`foreground`):
24//!
25//! | Variant | Container | Label + icon |
26//! |---|---|---|
27//! | [`ButtonVariant::Filled`] (default) | `primary` | `on_primary` |
28//! | [`ButtonVariant::Tonal`] | `secondary_container` | `on_secondary_container` |
29//! | [`ButtonVariant::Elevated`] | `surface_container_low` | `primary` |
30//! | [`ButtonVariant::Outlined`] | transparent + `outline` hairline | `primary` |
31//! | [`ButtonVariant::Text`] | transparent | `primary` |
32//!
33//! Disabled (`m3e_button_constants.dart:27`-`:33`): the label/icon drop to
34//! `on_surface` at 38%, an opaque container to `on_surface` at 12%, an
35//! outline to `on_surface` at 12%; a transparent container stays transparent.
36//!
37//! # Sizes
38//!
39//! Height / horizontal padding / icon / icon-gap, from
40//! `m3e_button_theme.dart:76`'s `_measurementsTable`, and the label type role
41//! from `m3e_base_button_state.dart:169`:
42//!
43//! | Size | Height | Padding | Icon | Gap | Label role |
44//! |---|---|---|---|---|---|
45//! | [`ButtonSize::Xs`] | 32 | 16 | 20 | 8 | `label_small` |
46//! | [`ButtonSize::Sm`] (default) | 40 | 16 | 20 | 8 | `label_medium` |
47//! | [`ButtonSize::Md`] | 56 | 24 | 24 | 8 | `label_large` |
48//! | [`ButtonSize::Lg`] | 96 | 48 | 32 | 12 | `title_medium` |
49//! | [`ButtonSize::Xl`] | 136 | 64 | 40 | 16 | `title_large` |
50//!
51//! A button is never narrower than 48dp (`M3EButtonTheme.minWidthFloor`,
52//! `m3e_button_theme.dart:16`). The reference's `M3EButtonSize.custom`
53//! per-instance measurement override is deliberately not ported here — this
54//! enum is closed, and the decoration-shaped override surface it belongs to is
55//! the same one the gradient seam below is waiting on.
56//!
57//! # Shape and the press morph
58//!
59//! [`ButtonShape::Round`] (the default) is a pill — `height / 2`;
60//! [`ButtonShape::Square`] takes the per-size token radius. Pressing morphs
61//! the container **squarer** and hovering morphs it partway there, both
62//! spring-driven (`m3e_button_content.dart:40`'s `_resolveShapes`):
63//!
64//! | Size | Square | Hovered | Pressed |
65//! |---|---|---|---|
66//! | Xs / Sm | 12 | 10 | 8 |
67//! | Md | 16 | 14 | 12 |
68//! | Lg / Xl | 28 | 22 | 16 |
69//!
70//! The square and pressed columns coincide exactly with this crate's shape
71//! scale, so they resolve from the live [`frust::Theme`] (`shape.medium`/
72//! `large`/`extra_large` and `shape.small`/`medium`/`large`) with the literal
73//! table as the unthemed fallback — the same themed-with-fallback treatment
74//! [`mod@crate::fab`] gives its own radii. The hovered column sits *between*
75//! two tokens (each entry is the midpoint of its size's square and pressed
76//! radii), so it has no token to resolve from and stays literal.
77//!
78//! The morph itself — radius and content padding on one spring — is the
79//! `motion` submodule's `RadiusPaddingMotion`, flung along
80//! `EXPRESSIVE_SPATIAL_PRESS` (380 / 0.55); see that module for the spring
81//! citation and for how a mid-flight retarget stays continuous. A plain
82//! button only ever retargets the radius: its padding target is the per-size
83//! table value and does not move on press (the reference passes a constant
84//! `baseInternalPadding`, `m3e_button_content.dart:7`). The padding channel is
85//! nonetheless real and layout-affecting, because the same primitive is what
86//! the connected/toggle families morph their padding with — for a plain
87//! button, though, `layout` resolves that per-size padding value directly
88//! rather than reading it back off the morph, since the morph's own copy can
89//! lag a runtime size change by a whole pass (`core::ButtonWidget::layout`'s
90//! own comment has the full hazard).
91//!
92//! # Focus ring
93//!
94//! `m3e_focus_ring.dart` draws a 2dp `primary` ring 2dp outside the container,
95//! with the container's own (animating) radius plus that 4dp outset. This port
96//! draws exactly that, gated on [`frust::authoring::PaintCtx::has_focus`] —
97//! the authoritative focus read. **Honest limitation:** frust has no keyboard
98//! focus traversal for a button today (no autofocus, no `FocusNode`, no
99//! Tab-ring), and this widget deliberately does not claim focus on a press
100//! (a tap-shows-a-focus-ring button is the wrong behavior on touch, and
101//! matches neither the reference nor Material's own focus-highlight mode). So
102//! the ring is ported, gated and unit-testable, but nothing in a shipping app
103//! reaches it yet — it becomes live the moment focus routing can reach a
104//! button.
105//!
106//! # Seams the follow-up tasks fill
107//!
108//! Two hook points exist here as documented, no-op-by-default interfaces:
109//!
110//! - [`ButtonDecoration`] — a paint-time fill/overlay/outline triple plus a
111//!   [`foreground_brush`](ButtonDecoration::foreground_brush) resolver,
112//!   called with the morphing [`ButtonSurface`], where the reference's
113//!   `m3eGradientSurfaceBuilder`/`m3eGradientForegroundBuilder` layers live.
114//!   Returning [`DecorationOutcome::Painted`] suppresses the core's own solid
115//!   layer. [`gradient::GradientButtonDecoration`] is the built-in
116//!   implementation that fills every hook with ported gradient paint — see
117//!   that module's docs for the full gradient-class inventory and the
118//!   `PaintScene` gradient-support finding.
119//! - [`OverflowObserver`] — handed the [`ContentMetrics`] every layout
120//!   resolves, including whether the label's natural width exceeded the width
121//!   actually available to it. [`overflow::OverflowStrategy`] (installed via
122//!   [`ButtonView::overflow`]) is what now decides *what to do* about that —
123//!   scroll or defer to a bottom sheet, calling back into an installed
124//!   `OverflowObserver` for the latter — porting the reference's
125//!   `M3EOverflowStrategy` family onto this seam; see that module's docs for
126//!   the full mapping (the family is upstream a `ButtonGroup`-level
127//!   abstraction, not a single button's own prop).
128//!
129//! # Mapping Flutter plumbing that has no frust analogue
130//!
131//! | Reference | Here |
132//! |---|---|
133//! | `WidgetStatesController` + `WidgetStateProperty` resolvers | [`crate::interaction::InteractionState`] plus plain per-state resolver fns; there is no external states controller to inject |
134//! | `FilledButton`/`OutlinedButton`/… delegation | this widget paints its own container, outline, state layer and content |
135//! | `InkSparkle`/`splashFactory` ripple | [`crate::state_layer::StateLayer`] — an M3 state-layer overlay, no ripple engine in this framework |
136//! | `FocusNode`/`autofocus`/`onFocusChange` | `PaintCtx::has_focus` only (see Focus ring above) |
137//! | `Tooltip`, `onLongPress`, `onHover` callbacks | not ported in v1: no tooltip host in the catalog, and neither long-press nor a hover callback has a reference-visual attached to it |
138//! | `enableFeedback` (platform click sound) | no analogue; haptics are the [`crate::interaction::MaterialHaptics`] hook below |
139//!
140//! # Haptics
141//!
142//! `m3e_button_content.dart:179` fires `M3EHaptics.trigger(decoration?.haptic
143//! ?? M3EHapticFeedback.none)` inside its own `onPressed` wrapper — i.e. a
144//! button is silent unless its decoration asked for a signal. This port
145//! matches that exactly: [`ButtonView::haptic`] defaults to
146//! [`HapticSignal::None`] and the fire happens on the same up-inside edge that
147//! runs `on_press`, through [`crate::interaction::MaterialHaptics::fire`]
148//! (a no-op until an app installs a hook).
149//!
150//! # Attribution
151//!
152//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
153//! (Vendored Components)" section (Mudit Purohit / m3e_buttons) and its Module
154//! Attribution Header Convention.
155
156mod core;
157pub mod gradient;
158pub(crate) mod motion;
159mod overflow;
160
161use std::rc::Rc;
162
163use frust::authoring::{AnyView, BuildCtx, ChangeFlags, ChildPod, View};
164use kurbo::Point;
165
166use crate::interaction::{HapticSignal, InteractionState};
167
168use self::core::LabelRun;
169pub use self::core::{
170    ButtonDecoration, ButtonSurface, ContentMetrics, DecorationOutcome, OverflowObserver,
171};
172pub use self::gradient::{
173    GradientAlignment, GradientButtonDecoration, GradientProperty, GradientSpec, GradientStates,
174    LinearGradientSpec, RadialGradientSpec, SweepGradientSpec, constant_gradient, implied_stops,
175};
176use self::motion::RadiusPaddingMotion;
177pub use self::overflow::OverflowStrategy;
178
179/// Which container treatment a button paints — the reference's
180/// `M3EButtonStyle` (`m3e_button_enums.dart:19`). See the [module docs](self)'
181/// Variants table.
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
183pub enum ButtonVariant {
184    /// Solid `primary` container, highest emphasis (the default).
185    #[default]
186    Filled,
187    /// Transparent container with an `outline` hairline.
188    Outlined,
189    /// `secondary_container` container, medium emphasis.
190    Tonal,
191    /// `surface_container_low` container that carries a shadow.
192    Elevated,
193    /// No container and no outline, lowest emphasis.
194    Text,
195}
196
197/// The button's size tier — the reference's `M3EButtonSize`
198/// (`m3e_button_enums.dart:64`). See the [module docs](self)' Sizes table.
199#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200pub enum ButtonSize {
201    /// 32dp tall.
202    Xs,
203    /// 40dp tall (the default).
204    #[default]
205    Sm,
206    /// 56dp tall.
207    Md,
208    /// 96dp tall.
209    Lg,
210    /// 136dp tall.
211    Xl,
212}
213
214/// The button's corner-radius family — the reference's `M3EButtonShape`
215/// (`m3e_button_enums.dart:45`).
216#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
217pub enum ButtonShape {
218    /// A pill: `height / 2` (the default).
219    #[default]
220    Round,
221    /// The per-size token radius. See the [module docs](self)' shape table.
222    Square,
223}
224
225/// Which side of the label an icon sits on — the reference's `IconAlignment`
226/// as consumed by `_M3EButtonIconLayout` (`m3e_button_state.dart:38`).
227#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
228pub enum IconAlignment {
229    /// Leading (the default).
230    #[default]
231    Start,
232    /// Trailing.
233    End,
234}
235
236/// A view-held, typed press callback (erased on build).
237type OnPress<State> = Rc<dyn Fn(&mut State)>;
238
239/// A declarative M3 Expressive button. See the [module docs](self).
240pub struct ButtonView<State: 'static> {
241    label: String,
242    variant: ButtonVariant,
243    size: ButtonSize,
244    shape: ButtonShape,
245    enabled: bool,
246    icon: Option<AnyView<State>>,
247    icon_alignment: IconAlignment,
248    corner_radius: Option<f64>,
249    pressed_radius: Option<f64>,
250    haptic: HapticSignal,
251    decoration: Option<Rc<dyn ButtonDecoration>>,
252    overflow: Option<Rc<dyn OverflowObserver>>,
253    overflow_strategy: OverflowStrategy,
254    on_press: OnPress<State>,
255}
256
257/// Create a filled (highest-emphasis) button labelled `label`, running
258/// `on_press` on release inside its bounds — the reference's default
259/// `M3EButton`/`M3EButton.filled`.
260///
261/// Chain [`ButtonView::size`]/[`ButtonView::shape`]/[`ButtonView::variant`] for
262/// the other tiers, [`ButtonView::icon`] to add a leading icon, and
263/// [`ButtonView::enabled`] to disable it.
264pub fn button<State: 'static, F: Fn(&mut State) + 'static>(
265    label: impl Into<String>,
266    on_press: F,
267) -> ButtonView<State> {
268    ButtonView {
269        label: label.into(),
270        variant: ButtonVariant::Filled,
271        size: ButtonSize::default(),
272        shape: ButtonShape::default(),
273        enabled: true,
274        icon: None,
275        icon_alignment: IconAlignment::default(),
276        corner_radius: None,
277        pressed_radius: None,
278        haptic: HapticSignal::None,
279        decoration: None,
280        overflow: None,
281        overflow_strategy: OverflowStrategy::default(),
282        on_press: Rc::new(on_press),
283    }
284}
285
286/// PascalCase alias for [`button`], matching this catalog's view-fn vocabulary
287/// (`ButtonGroup`, `AssistChip`, …).
288#[allow(non_snake_case)]
289pub fn Button<State: 'static, F: Fn(&mut State) + 'static>(
290    label: impl Into<String>,
291    on_press: F,
292) -> ButtonView<State> {
293    button(label, on_press)
294}
295
296/// Create a filled button — [`button`] under the name the reference's
297/// `M3EButton.filled` constructor uses.
298pub fn filled_button<State: 'static, F: Fn(&mut State) + 'static>(
299    label: impl Into<String>,
300    on_press: F,
301) -> ButtonView<State> {
302    button(label, on_press)
303}
304
305/// Create an outlined (medium-emphasis, hairline-bordered) button —
306/// `M3EButton.outlined`.
307pub fn outlined_button<State: 'static, F: Fn(&mut State) + 'static>(
308    label: impl Into<String>,
309    on_press: F,
310) -> ButtonView<State> {
311    button(label, on_press).variant(ButtonVariant::Outlined)
312}
313
314/// Create a tonal (medium-emphasis, `secondary_container`) button —
315/// `M3EButton.tonal`.
316pub fn tonal_button<State: 'static, F: Fn(&mut State) + 'static>(
317    label: impl Into<String>,
318    on_press: F,
319) -> ButtonView<State> {
320    button(label, on_press).variant(ButtonVariant::Tonal)
321}
322
323/// Create an elevated (medium-emphasis, shadowed) button —
324/// `M3EButton.elevated`.
325pub fn elevated_button<State: 'static, F: Fn(&mut State) + 'static>(
326    label: impl Into<String>,
327    on_press: F,
328) -> ButtonView<State> {
329    button(label, on_press).variant(ButtonVariant::Elevated)
330}
331
332/// Create a text (lowest-emphasis, chrome-free) button — `M3EButton.text`.
333pub fn text_button<State: 'static, F: Fn(&mut State) + 'static>(
334    label: impl Into<String>,
335    on_press: F,
336) -> ButtonView<State> {
337    button(label, on_press).variant(ButtonVariant::Text)
338}
339
340/// Create a filled button laying `icon` out beside `label` — the reference's
341/// `M3EButton.icon` factory (`m3e_buttons.dart:64`).
342///
343/// The icon is measured at the size's own icon dimension and separated from
344/// the label by the size's icon gap (the [module docs](self)' Sizes table);
345/// [`ButtonView::icon_alignment`] moves it to the trailing side. Identical to
346/// `button(label, on_press).icon(icon)` — both spellings exist because the
347/// reference has both a factory and an `iconAlignment`-carrying decoration.
348pub fn button_with_icon<State: 'static, F: Fn(&mut State) + 'static>(
349    icon: impl View<State>,
350    label: impl Into<String>,
351    on_press: F,
352) -> ButtonView<State> {
353    button(label, on_press).icon(icon)
354}
355
356impl<State: 'static> ButtonView<State> {
357    /// Set the container treatment (the [module docs](self)' Variants table).
358    pub fn variant(mut self, variant: ButtonVariant) -> Self {
359        self.variant = variant;
360        self
361    }
362
363    /// Set the size tier (the [module docs](self)' Sizes table).
364    pub fn size(mut self, size: ButtonSize) -> Self {
365        self.size = size;
366        self
367    }
368
369    /// Set the corner-radius family (pill vs. per-size token radius).
370    pub fn shape(mut self, shape: ButtonShape) -> Self {
371        self.shape = shape;
372        self
373    }
374
375    /// Whether the button accepts a press. A disabled button paints the 38% /
376    /// 12% disabled roles, reports disabled semantics, and swallows nothing —
377    /// it simply never arms (`m3e_button_content.dart:174`'s null
378    /// `onPressed`).
379    pub fn enabled(mut self, enabled: bool) -> Self {
380        self.enabled = enabled;
381        self
382    }
383
384    /// Attach a leading icon (see [`button_with_icon`]).
385    pub fn icon(mut self, icon: impl View<State>) -> Self {
386        self.icon = Some(AnyView::new(icon));
387        self
388    }
389
390    /// Put the icon on the trailing side instead of the leading one.
391    pub fn icon_alignment(mut self, alignment: IconAlignment) -> Self {
392        self.icon_alignment = alignment;
393        self
394    }
395
396    /// Override the resting corner radius, ignoring both the shape family and
397    /// the per-size token — the reference's `M3EButtonDecoration.borderRadius`
398    /// (`m3e_button_content.dart:42`). It also becomes the hovered radius, and
399    /// the pressed one unless [`ButtonView::pressed_radius`] is set too, since
400    /// that is the precedence `_resolveShapes` applies.
401    pub fn corner_radius(mut self, radius: f64) -> Self {
402        self.corner_radius = Some(radius);
403        self
404    }
405
406    /// Override the pressed corner radius — the reference's
407    /// `M3EButtonDecoration.pressedRadius`, the highest-precedence pressed
408    /// input.
409    pub fn pressed_radius(mut self, radius: f64) -> Self {
410        self.pressed_radius = Some(radius);
411        self
412    }
413
414    /// The haptic signal to fire on a press. Defaults to
415    /// [`HapticSignal::None`] — matching the reference, where a button is
416    /// silent unless its decoration names a signal (the [module docs](self)'
417    /// Haptics section).
418    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
419        self.haptic = haptic;
420        self
421    }
422
423    /// Install a paint-time decoration layer (the [module docs](self)' Seams
424    /// section).
425    pub fn decoration(mut self, decoration: Rc<dyn ButtonDecoration>) -> Self {
426        self.decoration = Some(decoration);
427        self
428    }
429
430    /// Install a layout-time content-measurement observer (the
431    /// [module docs](self)' Seams section).
432    pub fn overflow_observer(mut self, observer: Rc<dyn OverflowObserver>) -> Self {
433        self.overflow = Some(observer);
434        self
435    }
436
437    /// Select which strategy an overflowing label uses —
438    /// [`OverflowStrategy::None`] (the default) by default. See
439    /// [`OverflowStrategy`]'s own docs for the full three-way contract, and
440    /// [`ButtonView::overflow_observer`] for the hook
441    /// [`OverflowStrategy::BottomSheet`] calls into.
442    pub fn overflow(mut self, strategy: OverflowStrategy) -> Self {
443        self.overflow_strategy = strategy;
444        self
445    }
446}
447
448/// The retained widget for a [`ButtonView`]. Its layout/paint/event core lives
449/// in this module's `core` submodule.
450pub struct ButtonWidget {
451    pub(super) variant: ButtonVariant,
452    pub(super) size: ButtonSize,
453    pub(super) shape: ButtonShape,
454    pub(super) enabled: bool,
455    /// The button's own shaped label run (not a child pod: the label's ink is
456    /// resolved per variant *and* per interaction state at paint time — the
457    /// same reason [`mod@crate::text_field`] owns its runs).
458    label: LabelRun,
459    /// Where `layout` placed the label, in widget-local coordinates.
460    label_origin: Point,
461    icon: Option<ChildPod>,
462    icon_alignment: IconAlignment,
463    corner_radius: Option<f64>,
464    pressed_radius: Option<f64>,
465    haptic: HapticSignal,
466    /// Hover/focus/pressed tracking feeding the state-layer overlay and the
467    /// morph's target radius.
468    state: InteractionState,
469    /// Whether this widget holds the pointer capture a `Down` took.
470    captured: bool,
471    /// The radius+padding press morph.
472    motion: RadiusPaddingMotion,
473    /// What the last layout measured — also what [`OverflowObserver`] is
474    /// handed.
475    metrics: ContentMetrics,
476    decoration: Option<Rc<dyn ButtonDecoration>>,
477    overflow: Option<Rc<dyn OverflowObserver>>,
478    /// Which behavior an overflowing label uses — see [`overflow`]'s module
479    /// docs.
480    pub(super) overflow_strategy: OverflowStrategy,
481    /// [`OverflowStrategy::Scroll`]'s live pan offset, logical px from the
482    /// label's leading edge. Clamped every layout to
483    /// `overflow::max_scroll`'s range, and reset to `0` whenever the label
484    /// stops overflowing or the strategy changes away from `Scroll`.
485    pub(super) scroll_offset: f64,
486    /// The pointer's `x` at the last `Move` seen while captured — the
487    /// previous sample [`OverflowStrategy::Scroll`] diffs a new `Move`
488    /// against to find the drag delta. `None` outside a capture.
489    pub(super) drag_last_x: Option<f64>,
490    on_press: frust::authoring::ErasedCallback,
491}
492
493impl<State: 'static> View<State> for ButtonView<State> {
494    type Element = ButtonWidget;
495
496    fn build(&self, ctx: &mut BuildCtx<'_>) -> ButtonWidget {
497        let icon = self
498            .icon
499            .as_ref()
500            .map(|icon| frust::authoring::build_child(icon, ctx));
501        ButtonWidget {
502            variant: self.variant,
503            size: self.size,
504            shape: self.shape,
505            enabled: self.enabled,
506            label: LabelRun::new(self.label.clone()),
507            label_origin: Point::ZERO,
508            icon,
509            icon_alignment: self.icon_alignment,
510            corner_radius: self.corner_radius,
511            pressed_radius: self.pressed_radius,
512            haptic: self.haptic,
513            state: InteractionState::new(),
514            captured: false,
515            motion: RadiusPaddingMotion::new(),
516            metrics: ContentMetrics::empty(),
517            decoration: self.decoration.clone(),
518            overflow: self.overflow.clone(),
519            overflow_strategy: self.overflow_strategy,
520            scroll_offset: 0.0,
521            drag_last_x: None,
522            on_press: frust::authoring::erase_callback(&self.on_press),
523        }
524    }
525
526    fn rebuild(
527        &self,
528        prev: &Self,
529        element: &mut ButtonWidget,
530        ctx: &mut BuildCtx<'_>,
531    ) -> ChangeFlags {
532        element.on_press = frust::authoring::erase_callback(&self.on_press);
533        let mut flags = ChangeFlags::NONE;
534
535        // `ButtonDecoration`/`OverflowObserver` are trait objects with
536        // nothing to compare by value (the same shape `CardView::rebuild`'s
537        // `on_press` adoption hits), so a swap must be told apart from a
538        // same-`Rc` reinstall by *pointer* identity, not by bare `Option`
539        // presence: flagging on presence alone repaints/relayouts every
540        // decorated button on every unrelated whole-app rebuild, since
541        // `Some(a)` vs `Some(b)` (a genuine swap) and `Some(a)` vs `Some(a)`
542        // (a no-op reinstall) both read as "present on both sides."
543        let decoration_changed = match (&prev.decoration, &self.decoration) {
544            (Some(a), Some(b)) => !Rc::ptr_eq(a, b),
545            (None, None) => false,
546            _ => true,
547        };
548        if decoration_changed {
549            // Paint-time only (`ButtonWidget::paint`'s `fill`/`overlay`/
550            // `outlined` reads) — never measured, never read from `event`.
551            flags |= ChangeFlags::PAINT;
552        }
553        element.decoration = self.decoration.clone();
554
555        let overflow_changed = match (&prev.overflow, &self.overflow) {
556            (Some(a), Some(b)) => !Rc::ptr_eq(a, b),
557            (None, None) => false,
558            _ => true,
559        };
560        if overflow_changed {
561            // Consumed at layout (`observer.measured(...)`) and event time
562            // (the `BottomSheet` deferred-press check) — never read from
563            // paint, so this flags `LAYOUT`, paired with `PAINT` the same
564            // way this rebuild's other layout-affecting adoptions above do
565            // (`label`/`size`/`icon_alignment`/`overflow_strategy`).
566            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
567        }
568        element.overflow = self.overflow.clone();
569
570        // `haptic` is read only from `event` (`ButtonWidget::event`'s `Up`
571        // arm, gated on `HapticSignal::None`) — nothing painted or laid out
572        // depends on it, so adopting a new value needs no invalidation.
573        element.haptic = self.haptic;
574
575        if prev.overflow_strategy != self.overflow_strategy {
576            element.overflow_strategy = self.overflow_strategy;
577            // A live pan position from the old strategy means nothing under
578            // the new one.
579            element.scroll_offset = 0.0;
580            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
581        }
582
583        if prev.label != self.label {
584            element.label.set_content(&self.label);
585            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
586        }
587        if prev.variant != self.variant {
588            element.variant = self.variant;
589            flags |= ChangeFlags::PAINT;
590        }
591        if prev.size != self.size {
592            element.size = self.size;
593            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
594        }
595        if prev.shape != self.shape {
596            element.shape = self.shape;
597            flags |= ChangeFlags::PAINT;
598        }
599        if prev.enabled != self.enabled {
600            element.enabled = self.enabled;
601            if !self.enabled {
602                // A button disabled mid-press keeps neither the press nor the
603                // capture it was holding.
604                element.state.set_pressed(false);
605                element.captured = false;
606            }
607            flags |= ChangeFlags::PAINT;
608        }
609        if prev.icon_alignment != self.icon_alignment {
610            element.icon_alignment = self.icon_alignment;
611            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
612        }
613        if prev.corner_radius != self.corner_radius {
614            element.corner_radius = self.corner_radius;
615            flags |= ChangeFlags::PAINT;
616        }
617        if prev.pressed_radius != self.pressed_radius {
618            element.pressed_radius = self.pressed_radius;
619            flags |= ChangeFlags::PAINT;
620        }
621
622        match (&prev.icon, &self.icon) {
623            (None, None) => {}
624            (Some(p), Some(n)) => {
625                let pod = element.icon.as_mut().expect("icon pod present");
626                flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
627            }
628            (None, Some(n)) => {
629                element.icon = Some(frust::authoring::build_child(n, ctx));
630                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
631            }
632            (Some(p), None) => {
633                let mut pod = element.icon.take().expect("icon pod present");
634                frust::authoring::teardown_child(p, &mut pod, ctx);
635                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
636            }
637        }
638
639        flags
640    }
641
642    fn teardown(&self, element: &mut ButtonWidget, ctx: &mut BuildCtx<'_>) {
643        if let (Some(icon_view), Some(pod)) = (&self.icon, element.icon.as_mut()) {
644            frust::authoring::teardown_child(icon_view, pod, ctx);
645        }
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use frust_widgets::test_support::leaf_any;
653
654    /// Build a widget from a view over any state type — icon-carrying views
655    /// are `()`-stated, since `frust-widgets`' `leaf_any` fixture erases to
656    /// `AnyView<()>`.
657    fn build<S: 'static>(view: &ButtonView<S>) -> ButtonWidget {
658        let mut counter = 0u64;
659        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
660    }
661
662    #[test]
663    fn the_named_constructors_map_onto_the_reference_styles() {
664        // m3e_buttons.dart:115/:137/:159/:181/:203 — filled/tonal/elevated/
665        // outlined/text named constructors; the unnamed one defaults to
666        // filled (`:45`).
667        assert_eq!(
668            button::<u32, _>("a", |_| {}).variant,
669            ButtonVariant::Filled,
670            "the default constructor is filled"
671        );
672        assert_eq!(
673            filled_button::<u32, _>("a", |_| {}).variant,
674            ButtonVariant::Filled
675        );
676        assert_eq!(
677            outlined_button::<u32, _>("a", |_| {}).variant,
678            ButtonVariant::Outlined
679        );
680        assert_eq!(
681            tonal_button::<u32, _>("a", |_| {}).variant,
682            ButtonVariant::Tonal
683        );
684        assert_eq!(
685            elevated_button::<u32, _>("a", |_| {}).variant,
686            ButtonVariant::Elevated
687        );
688        assert_eq!(
689            text_button::<u32, _>("a", |_| {}).variant,
690            ButtonVariant::Text
691        );
692    }
693
694    #[test]
695    fn defaults_match_the_reference_constructor_defaults() {
696        // m3e_buttons.dart:45-:48 — style filled, size sm, shape round,
697        // enabled true; m3e_buttons.dart:312 — haptic none.
698        let view = button::<u32, _>("Save", |_| {});
699        assert_eq!(view.size, ButtonSize::Sm);
700        assert_eq!(view.shape, ButtonShape::Round);
701        assert!(view.enabled);
702        assert_eq!(view.haptic, HapticSignal::None);
703        assert_eq!(view.icon_alignment, IconAlignment::Start);
704        assert!(view.icon.is_none());
705        assert_eq!(view.overflow_strategy, OverflowStrategy::None);
706    }
707
708    #[test]
709    fn the_icon_factory_and_the_builder_method_agree() {
710        let factory = button_with_icon::<(), _>(leaf_any(20.0, 20.0), "Save", |_| {});
711        let builder = button::<(), _>("Save", |_| {}).icon(leaf_any(20.0, 20.0));
712        assert!(factory.icon.is_some());
713        assert!(builder.icon.is_some());
714        assert_eq!(factory.label, builder.label);
715        assert_eq!(factory.variant, builder.variant);
716    }
717
718    #[test]
719    fn build_carries_every_prop_onto_the_widget() {
720        let view = button::<(), _>("Save", |_| {})
721            .variant(ButtonVariant::Tonal)
722            .size(ButtonSize::Lg)
723            .shape(ButtonShape::Square)
724            .enabled(false)
725            .haptic(HapticSignal::Light)
726            .icon(leaf_any(32.0, 32.0))
727            .icon_alignment(IconAlignment::End)
728            .corner_radius(9.0)
729            .pressed_radius(3.0)
730            .overflow(OverflowStrategy::Scroll);
731        let widget = build(&view);
732        assert_eq!(widget.variant, ButtonVariant::Tonal);
733        assert_eq!(widget.size, ButtonSize::Lg);
734        assert_eq!(widget.shape, ButtonShape::Square);
735        assert!(!widget.enabled);
736        assert_eq!(widget.haptic, HapticSignal::Light);
737        assert_eq!(widget.icon_alignment, IconAlignment::End);
738        assert_eq!(widget.corner_radius, Some(9.0));
739        assert_eq!(widget.pressed_radius, Some(3.0));
740        assert!(widget.icon.is_some());
741        assert_eq!(widget.overflow_strategy, OverflowStrategy::Scroll);
742    }
743
744    #[test]
745    fn rebuild_carries_a_changed_overflow_strategy_and_resets_the_scroll_offset() {
746        let prev = button::<u32, _>("Save", |_| {});
747        let next = button::<u32, _>("Save", |_| {}).overflow(OverflowStrategy::BottomSheet);
748        let mut widget = build(&prev);
749        widget.scroll_offset = 12.0;
750        let mut counter = 0u64;
751        let flags =
752            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
753        assert_eq!(widget.overflow_strategy, OverflowStrategy::BottomSheet);
754        assert_eq!(widget.scroll_offset, 0.0);
755        assert!(flags.contains(ChangeFlags::LAYOUT));
756        assert!(flags.contains(ChangeFlags::PAINT));
757    }
758
759    #[test]
760    fn rebuild_moves_props_and_flags_what_changed() {
761        let prev = button::<u32, _>("Save", |_| {});
762        let next = button::<u32, _>("Saved", |_| {})
763            .variant(ButtonVariant::Text)
764            .size(ButtonSize::Xl);
765        let mut widget = build(&prev);
766        let mut counter = 0u64;
767        let flags =
768            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
769        assert_eq!(widget.variant, ButtonVariant::Text);
770        assert_eq!(widget.size, ButtonSize::Xl);
771        assert_eq!(
772            widget.label.content(),
773            "Saved",
774            "the run takes the new text (and drops its cached shaping)"
775        );
776        assert!(flags.contains(ChangeFlags::LAYOUT), "text + size relayout");
777        assert!(flags.contains(ChangeFlags::PAINT));
778    }
779
780    #[test]
781    fn disabling_mid_press_clears_the_press_and_the_capture() {
782        let prev = button::<u32, _>("Save", |_| {});
783        let next = button::<u32, _>("Save", |_| {}).enabled(false);
784        let mut widget = build(&prev);
785        widget.state.set_pressed(true);
786        widget.captured = true;
787        let mut counter = 0u64;
788        View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
789        assert!(!widget.state.pressed);
790        assert!(!widget.captured);
791    }
792
793    // ---- the decoration/overflow-observer/haptic rebuild rider ------------
794
795    struct NoopDecoration;
796    impl ButtonDecoration for NoopDecoration {}
797
798    struct NoopObserver;
799    impl OverflowObserver for NoopObserver {
800        fn measured(&self, _metrics: ContentMetrics) {}
801    }
802
803    #[test]
804    fn rebuild_flags_paint_on_a_decoration_swap_even_though_both_sides_are_some() {
805        let deco_a: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
806        let deco_b: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
807        let prev = button::<u32, _>("Save", |_| {}).decoration(deco_a);
808        let next = button::<u32, _>("Save", |_| {}).decoration(deco_b);
809        let mut widget = build(&prev);
810        let mut counter = 0u64;
811        let flags =
812            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
813        assert!(
814            flags.contains(ChangeFlags::PAINT),
815            "a decoration swap (a distinct Rc on each side) must repaint even \
816             though the Option stays Some on both sides"
817        );
818    }
819
820    #[test]
821    fn rebuild_flags_no_paint_when_the_same_rc_decoration_is_reinstalled() {
822        let deco: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
823        let prev = button::<u32, _>("Save", |_| {}).decoration(deco.clone());
824        let next = button::<u32, _>("Save", |_| {}).decoration(deco);
825        let mut widget = build(&prev);
826        let mut counter = 0u64;
827        let flags =
828            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
829        assert!(
830            !flags.contains(ChangeFlags::PAINT),
831            "reinstalling the identical Rc must not repaint every decorated \
832             button on every unrelated whole-app rebuild"
833        );
834    }
835
836    #[test]
837    fn rebuild_flags_paint_when_a_decoration_is_installed_or_removed() {
838        let deco: Rc<dyn ButtonDecoration> = Rc::new(NoopDecoration);
839        let none = button::<u32, _>("Save", |_| {});
840        let some = button::<u32, _>("Save", |_| {}).decoration(deco);
841
842        let mut widget = build(&none);
843        let mut counter = 0u64;
844        let flags =
845            View::<u32>::rebuild(&some, &none, &mut widget, &mut BuildCtx::new(&mut counter));
846        assert!(
847            flags.contains(ChangeFlags::PAINT),
848            "installing a decoration must repaint"
849        );
850
851        let mut widget = build(&some);
852        let mut counter = 0u64;
853        let flags =
854            View::<u32>::rebuild(&none, &some, &mut widget, &mut BuildCtx::new(&mut counter));
855        assert!(
856            flags.contains(ChangeFlags::PAINT),
857            "removing a decoration must repaint"
858        );
859    }
860
861    #[test]
862    fn rebuild_flags_layout_on_an_overflow_observer_swap() {
863        let obs_a: Rc<dyn OverflowObserver> = Rc::new(NoopObserver);
864        let obs_b: Rc<dyn OverflowObserver> = Rc::new(NoopObserver);
865        let prev = button::<u32, _>("Save", |_| {}).overflow_observer(obs_a);
866        let next = button::<u32, _>("Save", |_| {}).overflow_observer(obs_b);
867        let mut widget = build(&prev);
868        let mut counter = 0u64;
869        let flags =
870            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
871        assert!(
872            flags.contains(ChangeFlags::LAYOUT),
873            "an overflow-observer swap (a distinct Rc on each side) is a \
874             layout-time seam (`measured`/event), not a paint-time one, so \
875             the swap must flag LAYOUT even though the Option stays Some"
876        );
877    }
878
879    #[test]
880    fn rebuild_flags_no_layout_when_the_same_rc_overflow_observer_is_reinstalled() {
881        let obs: Rc<dyn OverflowObserver> = Rc::new(NoopObserver);
882        let prev = button::<u32, _>("Save", |_| {}).overflow_observer(obs.clone());
883        let next = button::<u32, _>("Save", |_| {}).overflow_observer(obs);
884        let mut widget = build(&prev);
885        let mut counter = 0u64;
886        let flags =
887            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
888        assert!(
889            !flags.contains(ChangeFlags::LAYOUT),
890            "reinstalling the identical Rc must not relayout"
891        );
892    }
893
894    #[test]
895    fn rebuild_adopts_a_haptic_change_with_no_invalidation() {
896        // Event-time only (`ButtonWidget::event`'s `Up` arm) — nothing
897        // painted or laid out depends on it, so adopting a new value must
898        // flag nothing, even though the field itself is still adopted.
899        let prev = button::<u32, _>("Save", |_| {});
900        let next = button::<u32, _>("Save", |_| {}).haptic(HapticSignal::Light);
901        let mut widget = build(&prev);
902        let mut counter = 0u64;
903        let flags =
904            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
905        assert_eq!(
906            flags,
907            ChangeFlags::NONE,
908            "a haptic-only change needs no invalidation"
909        );
910        assert_eq!(
911            widget.haptic,
912            HapticSignal::Light,
913            "the value is still adopted despite flagging nothing"
914        );
915    }
916}