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}