Skip to main content

frust_material/
icon_button.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/icon_buttons/` tree is itself vendored from the
3// `icon_button_m3e` package (MIT, © 2026 Mudit Purohit) — `m3e_icon_buttons.dart`
4// + its `components/`, `enums/`, `styles/` parts.
5// Porting decisions: the Dart widget hands its surface to Flutter's own
6// `IconButton` (itself wrapped in `IconTheme.merge` for ambient icon
7// size/color and `ListenableBuilder` for its three pointer notifiers); this
8// port paints the surface directly and constrains/lays out its icon child
9// the same way `super::button` does its own icon child — no ambient
10// recolor, see the module docs' Icon color section. The press/hover shape
11// morph reuses `crate::button::motion::RadiusPaddingMotion` directly (its
12// padding channel pinned to zero — every icon-button call site passes zero
13// `internalLeft/Right/Top/Bottom` to the Dart primitive too) — see this
14// module's `RadiusPaddingMotion reuse` section for why this is now a direct
15// import rather than the parallel port it started as.
16
17//! The Material 3 Expressive **icon button**: 4 variants × 5 sizes × round/
18//! square × 3 widths, with toggle (selected-icon swap), a badge slot, and
19//! the same press/hover shape morph [`mod@crate::button`] uses.
20//!
21//! [`icon_button`] builds the default standard icon button;
22//! [`filled_icon_button`], [`tonal_icon_button`] and [`outlined_icon_button`]
23//! are the reference's other three named constructors
24//! ([`IconButtonView::variant`] switches between them after the fact,
25//! mirroring [`mod@crate::button`]'s own named-constructor shape). Every one
26//! fires `on_press` on release inside its bounds and leaves its own props
27//! alone — `is_selected` is a caller-owned prop, never self-mutated
28//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics; see *Toggle* below).
29//!
30//! # Relationship to `frust::icon_button`
31//!
32//! Baseline `frust-widgets` ships its own, much simpler
33//! [`frust::icon_button`] — a single-variant, transparent-at-rest pressable
34//! glyph with no size tiers, no toggle, no badge. This module reuses the
35//! same plain `icon_button`/`IconButton` names in `frust_material` on
36//! purpose: the established cross-crate naming precedent
37//! [`mod@crate::checkbox`]/[`mod@crate::radio`] already set against their
38//! own baseline counterparts. An app picks exactly one by import path
39//! (`frust_material::icon_button` vs. `frust::icon_button`), never both at
40//! the same call site.
41//!
42//! # Variants
43//!
44//! Container / content roles, from `m3e_icon_button_build.dart:153`
45//! (`_variantColors`) — the *only* table where `is_selected` changes a
46//! color, and only for [`IconButtonVariant::Standard`]:
47//!
48//! | Variant | Container | Content |
49//! |---|---|---|
50//! | [`IconButtonVariant::Standard`] (default) | transparent | `primary` if selected else `on_surface_variant` |
51//! | [`IconButtonVariant::Filled`] | `primary` | `on_primary` |
52//! | [`IconButtonVariant::Tonal`] | `secondary_container` | `on_secondary_container` |
53//! | [`IconButtonVariant::Outlined`] | transparent + `outline` hairline | `primary` |
54//!
55//! **Verified from source, not obvious:** unlike [`mod@crate::button`]'s
56//! `resolve_colors`, `_variantColors` carries **no disabled branch at all**
57//! — an icon button built with `enabled(false)` paints the exact same inks
58//! as an enabled one (`_morphButtonStyle`'s `foregroundColor`/
59//! `backgroundColor` resolvers are state-independent constants, bypassing
60//! Flutter's own `IconButton` disabled dimming). This port matches that
61//! exactly: `enabled` only gates input handling (see *Enabled/disabled*),
62//! never color.
63//!
64//! # Sizes and widths
65//!
66//! Icon glyph size, and the visual (painted) box vs. the tap-target box, by
67//! size × width — `m3e_icon_button_theme.dart:39`-`:101`. **Visual and
68//! target are deliberately distinct measurements**: on [`IconButtonSize::Xs`]
69//! and [`IconButtonSize::Sm`] the painted box is smaller than the 48dp
70//! minimum touch target, so the outer hit region pads out to 48dp while the
71//! visible container stays smaller — from [`IconButtonSize::Md`] up, the two
72//! already coincide (or the wide variant's target already exceeds 48dp), so
73//! nothing pads further. [`IconButtonWidget::layout`] returns the *target*
74//! size and centers the *visual* box inside it; both are pinned per size in
75//! this module's tests.
76//!
77//! | Size | Icon | Visual (default/narrow/wide) | Target (default/narrow/wide) |
78//! |---|---|---|---|
79//! | [`IconButtonSize::Xs`] | 20 | 32×32 / 28×32 / 40×32 | 48×48 / 48×48 / 48×48 |
80//! | [`IconButtonSize::Sm`] (default) | 24 | 40×40 / 32×40 / 52×40 | 48×48 / 48×48 / 52×48 |
81//! | [`IconButtonSize::Md`] | 24 | 56×56 / 48×56 / 72×56 | 56×56 / 48×56 / 72×56 |
82//! | [`IconButtonSize::Lg`] | 32 | 96×96 / 64×96 / 128×96 | 96×96 / 64×96 / 128×96 |
83//! | [`IconButtonSize::Xl`] | 40 | 136×136 / 104×136 / 184×136 | 136×136 / 104×136 / 184×136 |
84//!
85//! [`IconButtonWidth::Standard`] is the reference's `defaultWidth` (renamed
86//! to avoid colliding with Rust's `Default` trait); [`IconButtonWidth::Narrow`]
87//! and [`IconButtonWidth::Wide`] adjust only the box width, never its height.
88//!
89//! # Shape and the press/hover morph
90//!
91//! [`IconButtonShape::Round`] (the default) and [`IconButtonShape::Square`]
92//! each resolve a per-size resting radius (`m3e_icon_button_theme.dart:103`-
93//! `:117`); pressing morphs toward a squarer radius and hovering morphs
94//! partway there (`m3e_icon_button_shapes.dart:42`'s `effectiveRadius`,
95//! `:119`-`:134`'s pressed/hovered tables):
96//!
97//! | Size | Round (rest) | Square (rest) | Hovered | Pressed |
98//! |---|---|---|---|---|
99//! | Xs | 16 | 8 | 10 | 6 |
100//! | Sm | 20 | 10 | 12 | 8 |
101//! | Md | 28 | 14 | 16 | 11 |
102//! | Lg | 48 | 24 | 28 | 19 |
103//! | Xl | 68 | 34 | 40 | 27 |
104//!
105//! Unlike [`mod@crate::button`]'s own radius tables, none of these four
106//! coincide cleanly across every size with [`frust::Theme`]'s shape scale
107//! (`shape_scale()`'s `small`/`medium`/`large`/… tokens) — the round table
108//! matches at four of five sizes but breaks at Xl (68 has no token), so
109//! this module keeps every radius a literal per-size constant rather than a
110//! partial, size-inconsistent theme resolution.
111//!
112//! The morph itself is [`crate::button::motion::RadiusPaddingMotion`],
113//! reused directly from [`mod@crate::button`] — see this module's
114//! `RadiusPaddingMotion reuse` section — flung along the same named spring
115//! [`mod@crate::button::motion`] uses,
116//! [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`] (380/0.55).
117//!
118//! # `RadiusPaddingMotion` reuse
119//!
120//! [`mod@crate::button`]'s `RadiusPaddingMotion` is the reference's shared
121//! morph primitive (`M3ERadiusAndPaddingMotion`), used by both the Dart
122//! button *and* icon-button families via one imported file
123//! (`m3e_icon_buttons.dart:14`). This crate's own port of that primitive
124//! lives at `button::motion::RadiusPaddingMotion`, `pub(crate)` and now
125//! declared inside a `pub(crate) mod motion;` in `button/mod.rs`, so this
126//! sibling module imports it directly rather than carrying a parallel copy.
127//! An earlier version of this module *did* carry a copy (`RadiusMorph`) —
128//! `button/mod.rs`'s `mod motion;` was still private then, and a same-wave
129//! task held `button/` read-only, so widening it was out of reach; a later
130//! task (owning `button/` for real) closed that gap by widening the
131//! declaration and doing this swap. Every icon-button width/size/variant
132//! passes `internalLeft/Right/Top/Bottom: 0` to `M3ERadiusAndPaddingMotion`
133//! (`m3e_icon_button_build.dart:191`-`:194`), so the padding channel the
134//! button family needs never actually moves here — this module pins it to
135//! `ContentPadding::symmetric(0.0)` on every retarget and never reads
136//! `RadiusPaddingMotion::padding` back. `press_spring_matches_expressive_spatial_press`
137//! (this module's tests) still pins the spring constant this module flings
138//! along against `crate::button::motion::PRESS_SPRING`, now the *same*
139//! constant both families share rather than two copies kept equal by hand.
140//!
141//! # Toggle
142//!
143//! [`IconButtonView::selected`] and [`IconButtonView::selected_icon`]
144//! together are the reference's optional toggle pair
145//! (`isToggle = widget.isSelected != null || widget.selectedIcon != null`,
146//! `m3e_icon_button_build.dart:12`-`13`): a plain icon button sets neither.
147//! When toggling, [`IconButtonShape`] **flips** while selected
148//! (`m3e_icon_button_shapes.dart:16`-`27`'s `restVariant`) and the *active*
149//! icon becomes `selected_icon` (falling back to `icon` if unset) —
150//! `is_selected` is read every build/rebuild as a prop, never written by
151//! this widget: a press only ever fires `on_press`, exactly like every
152//! other controlled component in this crate (`docs/CODE_STANDARDS.md`'s
153//! Interaction Semantics).
154//!
155//! # Enabled/disabled
156//!
157//! [`IconButtonView::enabled`] (default `true`) gates input handling only —
158//! see the *Variants* section above for why colors never dim. A button
159//! disabled mid-press drops both the visual press and the pointer capture
160//! it was holding, mirroring [`mod@crate::button`]'s own rule.
161//!
162//! # Badge
163//!
164//! [`IconButtonView::badge`] ports what `_wrapWithBadge`/`_buildBadge`
165//! actually paint for this family (`m3e_icon_button_build.dart:293`-`:350`)
166//! — **not** the separate, not-yet-built shared badges family this catalog
167//! will eventually carry. [`BadgeValue::Count`] rounds/clamps to `0..=999_999`; `0`
168//! paints a small unlabeled dot ([`DOT_DIAMETER`], the reference's
169//! `smallSize: 8`); anything else paints a labeled pill.
170//! [`BadgeValue::Text`] paints a labeled pill directly, or nothing at all
171//! for an empty string (`v.isEmpty`, `:332`). The pill height/padding are
172//! Flutter's own `Badge` widget defaults (`largeSize`/`padding`, not
173//! vendored into this repo's ported tree, so not line-cited the way every
174//! other constant here is) rather than an m3e-specific token. The badge is
175//! purely decorative paint, positioned at the visual box's top-end corner
176//! (`PositionedDirectional(top: 0, end: 0)`, `:306`) — it never hit-tests
177//! and never affects layout of anything else.
178//!
179//! # Icon color: caller-supplied, not ambient-retinted
180//!
181//! The Dart widget wraps its active icon in `IconTheme.merge(color:
182//! colors.fg, ...)` so any descendant `Icon` inherits the resolved content
183//! ink ambiently. This framework has no ambient icon-theme propagation, and
184//! [`mod@crate::button`]'s own icon child has exactly the same gap (it lays
185//! its `icon: Option<ChildPod>` out at the size token without recoloring
186//! it) — this port matches that established precedent rather than inventing
187//! one: the icon/`selected_icon` views a caller supplies are expected to
188//! carry their own ink (e.g. `frust_material::icons::CHECK` built through
189//! `frust::icon(..).color(..)`).
190//!
191//! # Not ported in v1
192//!
193//! `tooltip` (no tooltip host in the catalog — the same gap
194//! [`mod@crate::button`]'s own module doc records), `visualSize` (a
195//! decoration-shaped per-instance override with no builder surface yet),
196//! and [`M3EIconButtonDecoration`]'s gradient fill/overlay/outline seam (the
197//! same follow-up shape [`mod@crate::button`]'s `ButtonDecoration` fills for
198//! the button family, not yet extended here).
199//!
200//! # Attribution
201//!
202//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright
203//! Holders (Vendored Components)" section (Mudit Purohit / `icon_button_m3e`)
204//! and its Module Attribution Header Convention.
205
206use std::rc::Rc;
207
208use frust::Theme;
209use frust::authoring::text::{TextContext, TextLayout, TextStyle};
210use frust::authoring::{
211    Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, EventCtx,
212    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
213    View, Widget,
214};
215use kurbo::{Point, RoundedRect, Shape, Size};
216use peniko::{Brush, Color};
217
218use crate::button::motion::{ContentPadding, RadiusPaddingMotion};
219use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
220use crate::press::presses;
221
222// ---- Icon glyph size (m3e_icon_button_theme.dart:39-45) --------------------
223
224/// The icon glyph's side length for each [`IconButtonSize`]
225/// (`M3EIconButtonTheme._icon`, `m3e_icon_button_theme.dart:39`-`:45`).
226fn icon_glyph_size(size: IconButtonSize) -> f64 {
227    match size {
228        IconButtonSize::Xs => 20.0,
229        IconButtonSize::Sm | IconButtonSize::Md => 24.0,
230        IconButtonSize::Lg => 32.0,
231        IconButtonSize::Xl => 40.0,
232    }
233}
234
235// ---- Visual / target size tables (m3e_icon_button_theme.dart:47-101) -------
236
237/// The painted container box for `size`/`width`
238/// (`M3EIconButtonTheme._visual`, `m3e_icon_button_theme.dart:47`-`:73`).
239/// See the [module docs](self)' Sizes and widths table.
240fn visual_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
241    use IconButtonWidth::{Narrow, Standard, Wide};
242    match size {
243        IconButtonSize::Xs => match width {
244            Standard => Size::new(32.0, 32.0),
245            Narrow => Size::new(28.0, 32.0),
246            Wide => Size::new(40.0, 32.0),
247        },
248        IconButtonSize::Sm => match width {
249            Standard => Size::new(40.0, 40.0),
250            Narrow => Size::new(32.0, 40.0),
251            Wide => Size::new(52.0, 40.0),
252        },
253        IconButtonSize::Md => match width {
254            Standard => Size::new(56.0, 56.0),
255            Narrow => Size::new(48.0, 56.0),
256            Wide => Size::new(72.0, 56.0),
257        },
258        IconButtonSize::Lg => match width {
259            Standard => Size::new(96.0, 96.0),
260            Narrow => Size::new(64.0, 96.0),
261            Wide => Size::new(128.0, 96.0),
262        },
263        IconButtonSize::Xl => match width {
264            Standard => Size::new(136.0, 136.0),
265            Narrow => Size::new(104.0, 136.0),
266            Wide => Size::new(184.0, 136.0),
267        },
268    }
269}
270
271/// The tap-target box for `size`/`width`, before the [module docs](self)'
272/// visual-vs-target `max` reconciliation (`M3EIconButtonTheme._target`,
273/// `m3e_icon_button_theme.dart:75`-`:101`).
274fn theme_target_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
275    use IconButtonWidth::{Narrow, Standard, Wide};
276    match size {
277        IconButtonSize::Xs => Size::new(48.0, 48.0),
278        IconButtonSize::Sm => match width {
279            Standard | Narrow => Size::new(48.0, 48.0),
280            Wide => Size::new(52.0, 48.0),
281        },
282        IconButtonSize::Md => match width {
283            Standard => Size::new(56.0, 56.0),
284            Narrow => Size::new(48.0, 56.0),
285            Wide => Size::new(72.0, 56.0),
286        },
287        IconButtonSize::Lg => match width {
288            Standard => Size::new(96.0, 96.0),
289            Narrow => Size::new(64.0, 96.0),
290            Wide => Size::new(128.0, 96.0),
291        },
292        IconButtonSize::Xl => match width {
293            Standard => Size::new(136.0, 136.0),
294            Narrow => Size::new(104.0, 136.0),
295            Wide => Size::new(184.0, 136.0),
296        },
297    }
298}
299
300/// The outer tap-target box actually laid out: `theme_target_size` widened
301/// (never narrowed) to at least the visual box on each axis
302/// (`_resolveLayoutSizes`, `m3e_icon_button_build.dart:57`-`:70`). With no
303/// `visualSize` override ported (see the [module docs](self)), this always
304/// agrees with `theme_target_size` — the table is already self-consistent —
305/// but the `max` is kept for fidelity and documented intent.
306fn target_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
307    let visual = visual_size(size, width);
308    let target = theme_target_size(size, width);
309    Size::new(
310        target.width.max(visual.width),
311        target.height.max(visual.height),
312    )
313}
314
315// ---- Radius tables (m3e_icon_button_theme.dart:103-134) --------------------
316
317/// The round shape's resting radius (`_radiusRestRound`,
318/// `m3e_icon_button_theme.dart:103`-`:109`).
319fn radius_rest_round(size: IconButtonSize) -> f64 {
320    match size {
321        IconButtonSize::Xs => 16.0,
322        IconButtonSize::Sm => 20.0,
323        IconButtonSize::Md => 28.0,
324        IconButtonSize::Lg => 48.0,
325        IconButtonSize::Xl => 68.0,
326    }
327}
328
329/// The square shape's resting radius (`_radiusRestSquare`,
330/// `m3e_icon_button_theme.dart:111`-`:117`).
331fn radius_rest_square(size: IconButtonSize) -> f64 {
332    match size {
333        IconButtonSize::Xs => 8.0,
334        IconButtonSize::Sm => 10.0,
335        IconButtonSize::Md => 14.0,
336        IconButtonSize::Lg => 24.0,
337        IconButtonSize::Xl => 34.0,
338    }
339}
340
341/// The pressed radius, both shapes converge to it while pressed
342/// (`_radiusPressed`, `m3e_icon_button_theme.dart:119`-`:125`).
343fn radius_pressed(size: IconButtonSize) -> f64 {
344    match size {
345        IconButtonSize::Xs => 6.0,
346        IconButtonSize::Sm => 8.0,
347        IconButtonSize::Md => 11.0,
348        IconButtonSize::Lg => 19.0,
349        IconButtonSize::Xl => 27.0,
350    }
351}
352
353/// The hovered radius, between resting and pressed
354/// (`_radiusHovered`, `m3e_icon_button_theme.dart:127`-`:134`).
355fn radius_hovered(size: IconButtonSize) -> f64 {
356    match size {
357        IconButtonSize::Xs => 10.0,
358        IconButtonSize::Sm => 12.0,
359        IconButtonSize::Md => 16.0,
360        IconButtonSize::Lg => 28.0,
361        IconButtonSize::Xl => 40.0,
362    }
363}
364
365/// The resting shape variant, flipped while a toggle is selected
366/// (`M3EIconButtonShapes.restVariant`, `m3e_icon_button_shapes.dart:16`-`27`).
367fn rest_variant(is_toggle: bool, is_selected: bool, base: IconButtonShape) -> IconButtonShape {
368    if is_toggle && is_selected {
369        base.flipped()
370    } else {
371        base
372    }
373}
374
375/// The resting radius for a resolved shape variant
376/// (`M3EIconButtonShapes.restingRadius`, `m3e_icon_button_shapes.dart:29`-`40`).
377fn resting_radius(size: IconButtonSize, variant: IconButtonShape) -> f64 {
378    match variant {
379        IconButtonShape::Round => radius_rest_round(size),
380        IconButtonShape::Square => radius_rest_square(size),
381    }
382}
383
384/// The corner radius to paint this frame — pressed beats hovered beats
385/// resting, exactly `M3EIconButtonShapes.effectiveRadius`
386/// (`m3e_icon_button_shapes.dart:42`-`:67`).
387fn effective_radius(
388    size: IconButtonSize,
389    base_shape: IconButtonShape,
390    is_toggle: bool,
391    is_selected: bool,
392    state: InteractionState,
393) -> f64 {
394    let variant = rest_variant(is_toggle, is_selected, base_shape);
395    if state.pressed {
396        radius_pressed(size)
397    } else if state.hovered {
398        radius_hovered(size)
399    } else {
400        resting_radius(size, variant)
401    }
402}
403
404// ---- Colors (m3e_icon_button_build.dart:125-180) ---------------------------
405
406/// The hairline stroke width for [`IconButtonVariant::Outlined`], in logical
407/// px (`M3EIconButtonTheme.outlineWidth`'s default, `m3e_icon_button_theme.dart:12`).
408const OUTLINE_WIDTH: f64 = 1.0;
409
410/// Flattening tolerance for the `kurbo` rounded-rect paths this module
411/// strokes (matches [`mod@crate::button::core`]'s `PATH_TOLERANCE`).
412const PATH_TOLERANCE: f64 = 0.1;
413
414/// Unthemed-fallback `primary`.
415const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
416/// Unthemed-fallback `on_primary`.
417const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
418/// Unthemed-fallback `secondary_container`.
419const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
420/// Unthemed-fallback `on_secondary_container`.
421const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
422/// Unthemed-fallback `on_surface_variant`.
423const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
424/// Unthemed-fallback `outline`.
425const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
426
427/// The resolved container/content/outline inks for one paint pass.
428#[derive(Clone, Copy, Debug, PartialEq)]
429struct IconButtonColors {
430    container: Color,
431    content: Color,
432    outline: Option<Color>,
433}
434
435/// `_variantColors` (`m3e_icon_button_build.dart:153`-`:180`) — see the
436/// [module docs](self)' Variants table. This crate's own decoration seam
437/// (`M3EIconButtonDecoration`'s override path,
438/// `m3e_icon_button_build.dart:125`-`:151`) is not ported in v1 (see the
439/// module docs' Not ported section), so this *is* the full color
440/// resolution, not just the defaults a decoration could override.
441fn resolve_colors(
442    theme: Option<&Theme>,
443    variant: IconButtonVariant,
444    selected: bool,
445) -> IconButtonColors {
446    let (
447        primary,
448        on_primary,
449        secondary_container,
450        on_secondary_container,
451        on_surface_variant,
452        outline,
453    ) = match theme {
454        Some(theme) => {
455            let s = theme.scheme();
456            (
457                s.primary,
458                s.on_primary,
459                s.secondary_container,
460                s.on_secondary_container,
461                s.on_surface_variant,
462                s.outline,
463            )
464        }
465        None => (
466            PRIMARY,
467            ON_PRIMARY,
468            SECONDARY_CONTAINER,
469            ON_SECONDARY_CONTAINER,
470            ON_SURFACE_VARIANT,
471            OUTLINE,
472        ),
473    };
474    match variant {
475        IconButtonVariant::Standard => IconButtonColors {
476            container: Color::TRANSPARENT,
477            content: if selected {
478                primary
479            } else {
480                on_surface_variant
481            },
482            outline: None,
483        },
484        IconButtonVariant::Filled => IconButtonColors {
485            container: primary,
486            content: on_primary,
487            outline: None,
488        },
489        IconButtonVariant::Tonal => IconButtonColors {
490            container: secondary_container,
491            content: on_secondary_container,
492            outline: None,
493        },
494        IconButtonVariant::Outlined => IconButtonColors {
495            container: Color::TRANSPARENT,
496            content: primary,
497            outline: Some(outline),
498        },
499    }
500}
501
502/// The `(background, content)` roles a badge paints in
503/// (`scheme.primary`/`scheme.onPrimary`, every branch of `_buildBadge`,
504/// `m3e_icon_button_build.dart:311`-`:350`).
505fn badge_colors(theme: Option<&Theme>) -> (Color, Color) {
506    match theme {
507        Some(theme) => (theme.scheme().primary, theme.scheme().on_primary),
508        None => (PRIMARY, ON_PRIMARY),
509    }
510}
511
512/// `color` with its alpha channel replaced by `alpha`.
513fn with_alpha(color: Color, alpha: f32) -> Color {
514    let c = color.components;
515    Color::new([c[0], c[1], c[2], alpha])
516}
517
518// ---- The press/hover shape morph -------------------------------------------
519//
520// See the module docs' `RadiusPaddingMotion reuse` section: the morph itself
521// is `crate::button::motion::RadiusPaddingMotion`, imported directly rather
522// than reimplemented — this module supplies only the zero-padding target
523// every retarget pins ([`ZERO_PADDING`]) and never reads the padding channel
524// back.
525
526/// The padding channel [`crate::button::motion::RadiusPaddingMotion`] carries
527/// but this family never animates — every icon-button call site passes zero
528/// `internalLeft/Right/Top/Bottom` to the reference's shared primitive
529/// (`m3e_icon_button_build.dart:191`-`:194`).
530const ZERO_PADDING: ContentPadding = ContentPadding::symmetric(0.0);
531
532// ---- Badge -------------------------------------------------------------
533
534/// A badge value for [`IconButtonView::badge`] — the reference's
535/// `Object? badgeValue` (a `num` or a `String`,
536/// `m3e_icon_button_build.dart:311`-`:350`), given a closed, typed shape
537/// instead.
538#[derive(Clone, Debug, PartialEq)]
539pub enum BadgeValue {
540    /// A numeric badge, rounded and clamped to `0..=999_999`
541    /// (`v.round().clamp(0, 999999)`, `:317`). `0` paints an unlabeled dot.
542    Count(u32),
543    /// A text badge. An empty string paints nothing (`v.isEmpty`, `:332`).
544    Text(String),
545}
546
547/// What a badge slot resolves to for one paint pass — the union of `_buildBadge`'s
548/// three return shapes (`null` / a bare dot / a labeled pill).
549#[derive(Clone, Debug, PartialEq)]
550enum ResolvedBadge {
551    None,
552    Dot,
553    Label(String),
554}
555
556fn resolve_badge(value: &Option<BadgeValue>) -> ResolvedBadge {
557    match value {
558        None => ResolvedBadge::None,
559        Some(BadgeValue::Count(n)) => {
560            let clamped = (*n).min(999_999);
561            if clamped == 0 {
562                ResolvedBadge::Dot
563            } else {
564                ResolvedBadge::Label(clamped.to_string())
565            }
566        }
567        Some(BadgeValue::Text(s)) => {
568            if s.is_empty() {
569                ResolvedBadge::None
570            } else {
571                ResolvedBadge::Label(s.clone())
572            }
573        }
574    }
575}
576
577/// The dot badge's diameter, in logical px (`Badge(smallSize: 8, ...)`,
578/// `m3e_icon_button_build.dart:320`).
579const DOT_DIAMETER: f64 = 8.0;
580/// The labeled badge pill's height, in logical px — Flutter's own `Badge`
581/// widget default (`largeSize`), not vendored into this repo's ported tree
582/// (see the [module docs](self)' Badge section).
583const BADGE_HEIGHT: f64 = 16.0;
584/// Horizontal padding inside a labeled badge pill around its text, in
585/// logical px — Flutter's own `Badge` widget default padding.
586const BADGE_H_PAD: f64 = 4.0;
587/// The badge label's font size, in logical px — this crate's own
588/// `label_small`-tier size (matches `button::core::LABEL_SMALL`'s `11.0`).
589const BADGE_TEXT_SIZE: f32 = 11.0;
590
591/// A small, paint-time-rebrushed text run for a badge's label — mirrors
592/// [`mod@crate::button::core`]'s `LabelRun` shape (lazily shaped, re-brushed
593/// at paint time so the ink can change independently of the cached
594/// shaping), scoped down to what a short badge label needs: no wrapping, no
595/// overflow, no natural-vs-fitted distinction. `button::core::LabelRun` is
596/// `pub(super)` to `button` and out of scope to import (see the module
597/// docs), so this is a parallel, badge-scoped copy of the same idiom. The
598/// cache is keyed on the content *and* the resolved style, so a theme swap
599/// that changes the family reshapes instead of serving the old face.
600struct BadgeRun {
601    content: String,
602    /// The style the cached layout was shaped with.
603    style: TextStyle,
604    layout: Option<TextLayout>,
605}
606
607/// The badge label's resolved style: [`BADGE_TEXT_SIZE`] stays literal, and
608/// the family is borrowed from the live theme's `labelSmall` — the role whose
609/// 11px that size matches — at layout (unthemed: the platform system UI
610/// family), so a theme swap or a font picker restyles the label along with
611/// the rest of the catalog.
612fn badge_style(theme: Option<&Theme>) -> TextStyle {
613    let mut style = TextStyle::new(BADGE_TEXT_SIZE, Color::BLACK);
614    if let Some(theme) = theme {
615        style.family = theme.type_scale.label_small.family.clone();
616    }
617    style
618}
619
620impl BadgeRun {
621    fn new() -> Self {
622        Self {
623            content: String::new(),
624            style: badge_style(None),
625            layout: None,
626        }
627    }
628
629    fn set_content(&mut self, content: &str) {
630        if self.content != content {
631            self.content = content.to_string();
632            self.layout = None;
633        }
634    }
635
636    /// Shape (or reuse) the run in `style`, returning its measured size.
637    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
638        if self.style != *style {
639            self.style = style.clone();
640            self.layout = None;
641        }
642        if let Some(layout) = &self.layout {
643            return layout.size();
644        }
645        let text_ctx = ctx.text_context::<TextContext>();
646        let laid = text_ctx.layout(&self.content, &self.style, None);
647        let size = laid.size();
648        self.layout = Some(laid);
649        size
650    }
651
652    fn size(&self) -> Size {
653        self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
654    }
655
656    /// Paint the run at `origin` in `color`, overriding the shaping ink.
657    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
658        let Some(layout) = &self.layout else {
659            return;
660        };
661        for mut run in layout.to_scene_runs(origin) {
662            run.brush = Brush::Solid(color);
663            scene.draw_glyph_run(run);
664        }
665    }
666}
667
668// ---- Enums ------------------------------------------------------------
669
670/// Which container treatment an icon button paints — the reference's
671/// `M3EIconButtonVariant` (`m3e_icon_button_enums.dart:41`-`:53`). See the
672/// [module docs](self)' Variants table.
673#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
674pub enum IconButtonVariant {
675    /// Transparent container, `on_surface_variant` content (`primary` when
676    /// selected) — the default.
677    #[default]
678    Standard,
679    /// Solid `primary` container, `on_primary` content.
680    Filled,
681    /// `secondary_container` container, `on_secondary_container` content.
682    Tonal,
683    /// Transparent container with an `outline` hairline, `primary` content.
684    Outlined,
685}
686
687/// The icon button's size tier — the reference's `M3EIconButtonSize`
688/// (`m3e_icon_button_enums.dart:2`-`:17`). See the [module docs](self)'
689/// Sizes and widths table.
690#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
691pub enum IconButtonSize {
692    /// Extra-small.
693    Xs,
694    /// Small (the default).
695    #[default]
696    Sm,
697    /// Medium.
698    Md,
699    /// Large.
700    Lg,
701    /// Extra-large.
702    Xl,
703}
704
705/// The icon button's resting shape family — the reference's
706/// `M3EIconButtonShapeVariant` (`m3e_icon_button_enums.dart:32`-`:38`).
707#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
708pub enum IconButtonShape {
709    /// A pill (the default).
710    #[default]
711    Round,
712    /// A rounded square.
713    Square,
714}
715
716impl IconButtonShape {
717    /// The other shape — what a selected toggle flips to
718    /// (`M3EIconButtonShapes.restVariant`).
719    fn flipped(self) -> Self {
720        match self {
721            Self::Round => Self::Square,
722            Self::Square => Self::Round,
723        }
724    }
725}
726
727/// The icon button's container width tier — the reference's
728/// `M3EIconButtonWidth` (`m3e_icon_button_enums.dart:20`-`:29`).
729/// [`Self::Standard`] is the reference's `defaultWidth`, renamed to avoid
730/// colliding with Rust's `Default` trait (see the [module docs](self)).
731#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
732pub enum IconButtonWidth {
733    /// The size's own default width (the default).
734    #[default]
735    Standard,
736    /// Narrower than the default.
737    Narrow,
738    /// Wider than the default.
739    Wide,
740}
741
742// ---- View ---------------------------------------------------------------
743
744/// A view-held, typed press callback (erased on build).
745type OnPress<State> = Rc<dyn Fn(&mut State)>;
746
747/// A declarative M3 Expressive icon button. See the [module docs](self).
748pub struct IconButtonView<State: 'static> {
749    icon: AnyView<State>,
750    selected_icon: Option<AnyView<State>>,
751    variant: IconButtonVariant,
752    size: IconButtonSize,
753    shape: IconButtonShape,
754    width: IconButtonWidth,
755    is_selected: Option<bool>,
756    enabled: bool,
757    haptic: HapticSignal,
758    badge_value: Option<BadgeValue>,
759    suppress_ink: bool,
760    semantic_label: Option<String>,
761    on_press: OnPress<State>,
762}
763
764/// Create a standard (transparent, `on_surface_variant`) icon button
765/// painting `icon`, running `on_press` on release inside its bounds — the
766/// reference's default `M3EIconButton`.
767pub fn icon_button<State: 'static, F: Fn(&mut State) + 'static>(
768    icon: AnyView<State>,
769    on_press: F,
770) -> IconButtonView<State> {
771    IconButtonView {
772        icon,
773        selected_icon: None,
774        variant: IconButtonVariant::default(),
775        size: IconButtonSize::default(),
776        shape: IconButtonShape::default(),
777        width: IconButtonWidth::default(),
778        is_selected: None,
779        enabled: true,
780        haptic: HapticSignal::None,
781        badge_value: None,
782        suppress_ink: false,
783        semantic_label: None,
784        on_press: Rc::new(on_press),
785    }
786}
787
788/// PascalCase alias for [`icon_button`], matching this catalog's view-fn
789/// vocabulary (`Button`, `AssistChip`, …).
790#[allow(non_snake_case)]
791pub fn IconButton<State: 'static, F: Fn(&mut State) + 'static>(
792    icon: AnyView<State>,
793    on_press: F,
794) -> IconButtonView<State> {
795    icon_button(icon, on_press)
796}
797
798/// Create a filled icon button — [`icon_button`] with
799/// [`IconButtonVariant::Filled`].
800pub fn filled_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
801    icon: AnyView<State>,
802    on_press: F,
803) -> IconButtonView<State> {
804    icon_button(icon, on_press).variant(IconButtonVariant::Filled)
805}
806
807/// Create a tonal icon button — [`icon_button`] with
808/// [`IconButtonVariant::Tonal`].
809pub fn tonal_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
810    icon: AnyView<State>,
811    on_press: F,
812) -> IconButtonView<State> {
813    icon_button(icon, on_press).variant(IconButtonVariant::Tonal)
814}
815
816/// Create an outlined icon button — [`icon_button`] with
817/// [`IconButtonVariant::Outlined`].
818pub fn outlined_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
819    icon: AnyView<State>,
820    on_press: F,
821) -> IconButtonView<State> {
822    icon_button(icon, on_press).variant(IconButtonVariant::Outlined)
823}
824
825impl<State: 'static> IconButtonView<State> {
826    /// Set the container treatment (the [module docs](self)' Variants table).
827    pub fn variant(mut self, variant: IconButtonVariant) -> Self {
828        self.variant = variant;
829        self
830    }
831
832    /// Set the size tier (the [module docs](self)' Sizes and widths table).
833    pub fn size(mut self, size: IconButtonSize) -> Self {
834        self.size = size;
835        self
836    }
837
838    /// Set the resting shape family.
839    pub fn shape(mut self, shape: IconButtonShape) -> Self {
840        self.shape = shape;
841        self
842    }
843
844    /// Set the container width tier.
845    pub fn width(mut self, width: IconButtonWidth) -> Self {
846        self.width = width;
847        self
848    }
849
850    /// Mark this a toggle icon button carrying `selected`, per the
851    /// [module docs](self)' Toggle section — never self-mutated by a press.
852    pub fn selected(mut self, selected: bool) -> Self {
853        self.is_selected = Some(selected);
854        self
855    }
856
857    /// The icon painted while selected, falling back to the base `icon` when
858    /// unset — also makes this a toggle icon button on its own even without
859    /// [`Self::selected`] (the [module docs](self)' Toggle section).
860    pub fn selected_icon(mut self, icon: AnyView<State>) -> Self {
861        self.selected_icon = Some(icon);
862        self
863    }
864
865    /// Whether the button accepts a press (default `true`). Never dims
866    /// color — see the [module docs](self)' Variants section.
867    pub fn enabled(mut self, enabled: bool) -> Self {
868        self.enabled = enabled;
869        self
870    }
871
872    /// The haptic signal to fire on a press. Defaults to
873    /// [`HapticSignal::None`].
874    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
875        self.haptic = haptic;
876        self
877    }
878
879    /// Attach a badge (the [module docs](self)' Badge section).
880    pub fn badge(mut self, value: BadgeValue) -> Self {
881        self.badge_value = Some(value);
882        self
883    }
884
885    /// Suppress the hover/focus/pressed state-layer overlay (the
886    /// reference's `suppressInk`, which forces both its splash *and* its
887    /// overlay color transparent — this framework has no ripple, so
888    /// suppressing the state layer is the whole effect).
889    pub fn suppress_ink(mut self, suppress: bool) -> Self {
890        self.suppress_ink = suppress;
891        self
892    }
893
894    /// The accessible label, when the icon alone doesn't name the action.
895    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
896        self.semantic_label = Some(label.into());
897        self
898    }
899
900    /// The currently active icon view — `selected_icon` while selected (if
901    /// set), else the base `icon`. Shared by `build`/`rebuild`/`teardown` so
902    /// the three lifecycle methods can never disagree about which view is
903    /// live.
904    fn active_icon(&self) -> &AnyView<State> {
905        let selected = self.is_selected.unwrap_or(false);
906        if selected {
907            self.selected_icon.as_ref().unwrap_or(&self.icon)
908        } else {
909            &self.icon
910        }
911    }
912
913    /// Whether this is a toggle icon button at all
914    /// (`isToggle = widget.isSelected != null || widget.selectedIcon != null`,
915    /// `m3e_icon_button_build.dart:12`-`:13`).
916    fn is_toggle(&self) -> bool {
917        self.is_selected.is_some() || self.selected_icon.is_some()
918    }
919}
920
921/// The retained widget for an [`IconButtonView`]. See the [module docs](self).
922pub struct IconButtonWidget {
923    variant: IconButtonVariant,
924    size: IconButtonSize,
925    shape: IconButtonShape,
926    width: IconButtonWidth,
927    is_selected: Option<bool>,
928    is_toggle: bool,
929    enabled: bool,
930    haptic: HapticSignal,
931    badge: ResolvedBadge,
932    suppress_ink: bool,
933    semantic_label: Option<String>,
934    icon: ChildPod,
935    badge_run: BadgeRun,
936    /// The last-laid-out badge pill size (irrelevant/zero for `Dot`/`None`).
937    badge_size: Size,
938    /// The last-laid-out visual box size, cached from `layout` for `paint`.
939    visual: Size,
940    /// Where `layout` centered the visual box inside the target box.
941    visual_origin: Point,
942    state: InteractionState,
943    captured: bool,
944    /// The shared button-family press/hover morph, reused directly — see the
945    /// module docs' `RadiusPaddingMotion reuse` section. Its padding channel
946    /// is pinned to [`ZERO_PADDING`] and never read back.
947    motion: RadiusPaddingMotion,
948    on_press: frust::authoring::ErasedCallback,
949}
950
951impl<State: 'static> View<State> for IconButtonView<State> {
952    type Element = IconButtonWidget;
953
954    fn build(&self, ctx: &mut BuildCtx<'_>) -> IconButtonWidget {
955        IconButtonWidget {
956            variant: self.variant,
957            size: self.size,
958            shape: self.shape,
959            width: self.width,
960            is_selected: self.is_selected,
961            is_toggle: self.is_toggle(),
962            enabled: self.enabled,
963            haptic: self.haptic,
964            badge: resolve_badge(&self.badge_value),
965            suppress_ink: self.suppress_ink,
966            semantic_label: self.semantic_label.clone(),
967            icon: frust::authoring::build_child(self.active_icon(), ctx),
968            badge_run: BadgeRun::new(),
969            badge_size: Size::ZERO,
970            visual: Size::ZERO,
971            visual_origin: Point::ZERO,
972            state: InteractionState::new(),
973            captured: false,
974            motion: RadiusPaddingMotion::new(),
975            on_press: frust::authoring::erase_callback(&self.on_press),
976        }
977    }
978
979    fn rebuild(
980        &self,
981        prev: &Self,
982        element: &mut IconButtonWidget,
983        ctx: &mut BuildCtx<'_>,
984    ) -> ChangeFlags {
985        element.on_press = frust::authoring::erase_callback(&self.on_press);
986        element.haptic = self.haptic;
987        element.semantic_label = self.semantic_label.clone();
988        let mut flags = ChangeFlags::NONE;
989
990        if prev.variant != self.variant {
991            element.variant = self.variant;
992            flags |= ChangeFlags::PAINT;
993        }
994        if prev.size != self.size {
995            element.size = self.size;
996            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
997        }
998        if prev.shape != self.shape {
999            element.shape = self.shape;
1000            flags |= ChangeFlags::PAINT;
1001        }
1002        if prev.width != self.width {
1003            element.width = self.width;
1004            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1005        }
1006        if prev.enabled != self.enabled {
1007            element.enabled = self.enabled;
1008            if !self.enabled {
1009                // Disabled mid-press keeps neither the press nor the
1010                // capture it was holding (mirrors `button::ButtonWidget`).
1011                element.state.set_pressed(false);
1012                element.captured = false;
1013            }
1014            flags |= ChangeFlags::PAINT;
1015        }
1016        if prev.suppress_ink != self.suppress_ink {
1017            element.suppress_ink = self.suppress_ink;
1018            flags |= ChangeFlags::PAINT;
1019        }
1020
1021        let is_toggle = self.is_toggle();
1022        if element.is_toggle != is_toggle || element.is_selected != self.is_selected {
1023            element.is_toggle = is_toggle;
1024            element.is_selected = self.is_selected;
1025            flags |= ChangeFlags::PAINT;
1026        }
1027
1028        let next_badge = resolve_badge(&self.badge_value);
1029        if element.badge != next_badge {
1030            element.badge = next_badge;
1031            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1032        }
1033
1034        flags |= frust::authoring::rebuild_child(
1035            prev.active_icon(),
1036            self.active_icon(),
1037            &mut element.icon,
1038            ctx,
1039        );
1040
1041        flags
1042    }
1043
1044    fn teardown(&self, element: &mut IconButtonWidget, ctx: &mut BuildCtx<'_>) {
1045        frust::authoring::teardown_child(self.active_icon(), &mut element.icon, ctx);
1046    }
1047}
1048
1049// ---- Widget: layout/paint/event/semantics ----------------------------------
1050
1051fn inside(pos: Point, size: Size) -> bool {
1052    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
1053}
1054
1055/// The rounded rect to *stroke* for a `width`-wide hairline lying fully
1056/// inside a container of `size` with corner `radius` (mirrors
1057/// [`mod@crate::button::core`]'s identically-shaped helper).
1058fn inset_stroke_rect(size: Size, radius: f64, width: f64) -> RoundedRect {
1059    let half = width / 2.0;
1060    RoundedRect::new(
1061        half,
1062        half,
1063        size.width - half,
1064        size.height - half,
1065        (radius - half).max(0.0),
1066    )
1067}
1068
1069impl Widget for IconButtonWidget {
1070    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1071        let visual = visual_size(self.size, self.width);
1072        let target = target_size(self.size, self.width);
1073        let visual_origin = Point::new(
1074            (target.width - visual.width) / 2.0,
1075            (target.height - visual.height) / 2.0,
1076        );
1077
1078        let icon_dim = icon_glyph_size(self.size);
1079        let icon_measured = self
1080            .icon
1081            .layout_child(ctx, &BoxConstraints::tight(Size::new(icon_dim, icon_dim)));
1082        self.icon.set_origin(Point::new(
1083            visual_origin.x + (visual.width - icon_measured.width) / 2.0,
1084            visual_origin.y + (visual.height - icon_measured.height) / 2.0,
1085        ));
1086
1087        self.badge_size = match &self.badge {
1088            ResolvedBadge::None => Size::ZERO,
1089            ResolvedBadge::Dot => Size::new(DOT_DIAMETER, DOT_DIAMETER),
1090            ResolvedBadge::Label(text) => {
1091                self.badge_run.set_content(text);
1092                let style = badge_style(Theme::from_layout_ctx(ctx));
1093                let text_size = self.badge_run.shape(ctx, &style);
1094                let width = (text_size.width + BADGE_H_PAD * 2.0).max(BADGE_HEIGHT);
1095                Size::new(width, BADGE_HEIGHT)
1096            }
1097        };
1098
1099        self.visual = visual;
1100        self.visual_origin = visual_origin;
1101        bc.constrain(target)
1102    }
1103
1104    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1105        let outer_origin = ctx.origin();
1106
1107        // Authoritative hover/focus reads; both inert while disabled
1108        // (mirrors `button::ButtonWidget::paint`).
1109        self.state.set_hovered(self.enabled && ctx.is_hovered());
1110        self.state.set_focused(self.enabled && ctx.has_focus());
1111
1112        let selected = self.is_selected.unwrap_or(false);
1113        let (colors, radius_target, badge_bg, badge_fg) = {
1114            let theme = Theme::from_paint_ctx(ctx);
1115            let colors = resolve_colors(theme, self.variant, selected);
1116            let radius =
1117                effective_radius(self.size, self.shape, self.is_toggle, selected, self.state);
1118            let (bg, fg) = badge_colors(theme);
1119            (colors, radius, bg, fg)
1120        };
1121
1122        if self.motion.retarget(radius_target, ZERO_PADDING) {
1123            ctx.request_frame();
1124        }
1125        if self.motion.advance(ctx.frame_time()) {
1126            ctx.request_frame();
1127        }
1128        let radius = self.motion.radius();
1129
1130        let visual_origin = Point::new(
1131            outer_origin.x + self.visual_origin.x,
1132            outer_origin.y + self.visual_origin.y,
1133        );
1134
1135        if colors.container.components[3] > 0.0 {
1136            scene.fill_rounded_rect(visual_origin, self.visual, radius, colors.container);
1137        }
1138        if let Some(outline) = colors.outline {
1139            let path =
1140                inset_stroke_rect(self.visual, radius, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1141            scene.stroke_path(visual_origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1142        }
1143        if !self.suppress_ink && self.enabled {
1144            let opacity = self.state.resolve_opacity();
1145            if opacity > 0.0 {
1146                scene.fill_rounded_rect(
1147                    visual_origin,
1148                    self.visual,
1149                    radius,
1150                    with_alpha(colors.content, opacity),
1151                );
1152            }
1153        }
1154
1155        self.icon.paint_child(ctx, scene);
1156
1157        match &self.badge {
1158            ResolvedBadge::None => {}
1159            ResolvedBadge::Dot => {
1160                let d = DOT_DIAMETER;
1161                let dot_origin =
1162                    Point::new(visual_origin.x + self.visual.width - d, visual_origin.y);
1163                scene.fill_rounded_rect(dot_origin, Size::new(d, d), d / 2.0, badge_bg);
1164            }
1165            ResolvedBadge::Label(_) => {
1166                let size = self.badge_size;
1167                let pill_origin = Point::new(
1168                    visual_origin.x + self.visual.width - size.width,
1169                    visual_origin.y,
1170                );
1171                scene.fill_rounded_rect(pill_origin, size, size.height / 2.0, badge_bg);
1172                let text_size = self.badge_run.size();
1173                let text_origin = Point::new(
1174                    pill_origin.x + (size.width - text_size.width) / 2.0,
1175                    pill_origin.y + (size.height - text_size.height) / 2.0,
1176                );
1177                self.badge_run.paint(text_origin, badge_fg, scene);
1178            }
1179        }
1180    }
1181
1182    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1183        if !self.enabled {
1184            return EventResult::Ignored;
1185        }
1186        let InputEvent::Pointer(p) = event else {
1187            return EventResult::Ignored;
1188        };
1189        match p.phase {
1190            PointerPhase::Down => {
1191                if !presses(p) {
1192                    return EventResult::Ignored;
1193                }
1194                self.state.set_pressed(true);
1195                self.captured = true;
1196                ctx.capture_pointer();
1197                ctx.request_redraw();
1198                EventResult::Handled
1199            }
1200            PointerPhase::Move => {
1201                let over = inside(p.position, ctx.size());
1202                if !self.captured {
1203                    if over {
1204                        ctx.claim_hover();
1205                        ctx.set_cursor(CursorIcon::Pointer);
1206                    }
1207                    if self.state.set_hovered(over) {
1208                        ctx.request_redraw();
1209                    }
1210                    return EventResult::Ignored;
1211                }
1212                ctx.set_cursor(CursorIcon::Pointer);
1213                if self.state.set_pressed(over) {
1214                    ctx.request_redraw();
1215                }
1216                EventResult::Handled
1217            }
1218            PointerPhase::Up => {
1219                if !self.captured {
1220                    return EventResult::Ignored;
1221                }
1222                if inside(p.position, ctx.size()) {
1223                    if self.haptic != HapticSignal::None {
1224                        MaterialHaptics::fire(self.haptic);
1225                    }
1226                    (self.on_press)(ctx);
1227                }
1228                self.state.set_pressed(false);
1229                self.captured = false;
1230                ctx.request_redraw();
1231                EventResult::Handled
1232            }
1233            PointerPhase::Cancel => {
1234                if !self.captured {
1235                    return EventResult::Ignored;
1236                }
1237                self.state.set_pressed(false);
1238                self.captured = false;
1239                ctx.request_redraw();
1240                EventResult::Handled
1241            }
1242        }
1243    }
1244
1245    fn semantics(&self, ctx: &mut SemanticsCtx) {
1246        ctx.push_node(Role::Button, |node| {
1247            if let Some(label) = &self.semantic_label {
1248                node.set_label(label.as_str());
1249            }
1250            node.set_selected(self.is_selected.unwrap_or(false));
1251            if self.enabled {
1252                node.add_action(Action::Click);
1253            } else {
1254                node.set_disabled();
1255            }
1256        });
1257    }
1258
1259    frust::authoring::visit_children!(icon);
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264    use super::*;
1265    use frust::FrameTime;
1266    use frust::authoring::scene::GlyphRun;
1267    use frust::authoring::text::FontFamily;
1268    use frust::authoring::{BuildCtx, PointerButton, PointerEvent, View};
1269    use frust_widgets::test_support::leaf_any;
1270    use std::any::Any;
1271    use std::cell::RefCell;
1272
1273    fn build<S: 'static>(view: &IconButtonView<S>) -> IconButtonWidget {
1274        let mut counter = 0u64;
1275        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1276    }
1277
1278    fn ft_secs(s: f64) -> FrameTime {
1279        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1280    }
1281
1282    fn settle_motion(motion: &mut RadiusPaddingMotion) {
1283        let mut t = 0.0;
1284        for _ in 0..600 {
1285            t += 1.0 / 60.0;
1286            if !motion.advance(ft_secs(t)) {
1287                return;
1288            }
1289        }
1290        panic!("the press morph never settled");
1291    }
1292
1293    fn settle_morph(widget: &mut IconButtonWidget) {
1294        settle_motion(&mut widget.motion);
1295    }
1296
1297    #[derive(Default)]
1298    struct Recorder {
1299        rrects: Vec<(Point, Size, f64, Color)>,
1300        strokes: Vec<(Point, f64, Color)>,
1301        /// `(origin, ink, glyph count)` — mirrors `button::core`'s Recorder,
1302        /// which likewise reads only the run's transform/brush/glyph count,
1303        /// never its shaped text (`GlyphRun` carries no text string, only
1304        /// resolved glyph ids).
1305        runs: Vec<(Point, Color, usize)>,
1306    }
1307
1308    impl PaintScene for Recorder {
1309        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1310        fn draw_text(&mut self, _o: Point, _t: &str) {}
1311        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1312            self.rrects.push((o, s, radius, color));
1313        }
1314        fn draw_shadow(&mut self, _o: Point, _s: Size, _radius: f64, _std_dev: f64, _color: Color) {
1315        }
1316        fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
1317            let color = match brush {
1318                Brush::Solid(c) => *c,
1319                _ => Color::TRANSPARENT,
1320            };
1321            self.strokes.push((o, width, color));
1322        }
1323        fn draw_glyph_run(&mut self, run: GlyphRun) {
1324            let t = run.transform.translation();
1325            let color = match &run.brush {
1326                Brush::Solid(c) => *c,
1327                _ => Color::TRANSPARENT,
1328            };
1329            self.runs
1330                .push((Point::new(t.x, t.y), color, run.glyphs.len()));
1331        }
1332    }
1333
1334    fn layout_with(widget: &mut IconButtonWidget, max: f64) -> Size {
1335        let mut tcx = frust::authoring::text::TextContext::new();
1336        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1337        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(max, max)))
1338    }
1339
1340    fn paint_at(widget: &mut IconButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
1341        let mut rec = Recorder::default();
1342        let mut pctx = PaintCtx::new(Point::ZERO, size);
1343        if let Some(theme) = theme {
1344            pctx = pctx.with_theme(theme as &dyn Any);
1345        }
1346        widget.paint(&mut pctx, &mut rec);
1347        rec
1348    }
1349
1350    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1351        InputEvent::Pointer(PointerEvent {
1352            phase,
1353            position: Point::new(x, y),
1354            button: PointerButton::Primary,
1355        })
1356    }
1357
1358    fn dispatch<S: 'static>(
1359        widget: &mut IconButtonWidget,
1360        state: &mut S,
1361        size: Size,
1362        event: &InputEvent,
1363    ) {
1364        let state_any: &mut dyn Any = state;
1365        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1366        widget.event(&mut ctx, event);
1367    }
1368
1369    // ---- defaults -----------------------------------------------------
1370
1371    #[test]
1372    fn defaults_match_the_reference_constructor_defaults() {
1373        // m3e_icon_buttons.dart:49-52 — variant standard, size sm, shape
1374        // round, width defaultWidth; suppressInk false (:58).
1375        let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {});
1376        assert_eq!(view.variant, IconButtonVariant::Standard);
1377        assert_eq!(view.size, IconButtonSize::Sm);
1378        assert_eq!(view.shape, IconButtonShape::Round);
1379        assert_eq!(view.width, IconButtonWidth::Standard);
1380        assert!(view.enabled);
1381        assert!(!view.suppress_ink);
1382        assert_eq!(view.haptic, HapticSignal::None);
1383        assert!(!view.is_toggle());
1384    }
1385
1386    #[test]
1387    fn the_named_constructors_map_onto_the_reference_variants() {
1388        assert_eq!(
1389            filled_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
1390            IconButtonVariant::Filled
1391        );
1392        assert_eq!(
1393            tonal_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
1394            IconButtonVariant::Tonal
1395        );
1396        assert_eq!(
1397            outlined_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
1398            IconButtonVariant::Outlined
1399        );
1400    }
1401
1402    // ---- size/width tables ---------------------------------------------
1403
1404    const ALL_SIZES: [IconButtonSize; 5] = [
1405        IconButtonSize::Xs,
1406        IconButtonSize::Sm,
1407        IconButtonSize::Md,
1408        IconButtonSize::Lg,
1409        IconButtonSize::Xl,
1410    ];
1411
1412    #[test]
1413    fn icon_glyph_sizes_match_the_reference_table() {
1414        // m3e_icon_button_theme.dart:39-45.
1415        let expected = [
1416            (IconButtonSize::Xs, 20.0),
1417            (IconButtonSize::Sm, 24.0),
1418            (IconButtonSize::Md, 24.0),
1419            (IconButtonSize::Lg, 32.0),
1420            (IconButtonSize::Xl, 40.0),
1421        ];
1422        for (size, icon) in expected {
1423            assert_eq!(icon_glyph_size(size), icon, "{size:?}");
1424        }
1425    }
1426
1427    #[test]
1428    fn visual_sizes_match_the_reference_table_for_every_size_and_width() {
1429        // m3e_icon_button_theme.dart:47-73.
1430        use IconButtonWidth::{Narrow, Standard, Wide};
1431        let expected: [(IconButtonSize, Size, Size, Size); 5] = [
1432            (
1433                IconButtonSize::Xs,
1434                Size::new(32.0, 32.0),
1435                Size::new(28.0, 32.0),
1436                Size::new(40.0, 32.0),
1437            ),
1438            (
1439                IconButtonSize::Sm,
1440                Size::new(40.0, 40.0),
1441                Size::new(32.0, 40.0),
1442                Size::new(52.0, 40.0),
1443            ),
1444            (
1445                IconButtonSize::Md,
1446                Size::new(56.0, 56.0),
1447                Size::new(48.0, 56.0),
1448                Size::new(72.0, 56.0),
1449            ),
1450            (
1451                IconButtonSize::Lg,
1452                Size::new(96.0, 96.0),
1453                Size::new(64.0, 96.0),
1454                Size::new(128.0, 96.0),
1455            ),
1456            (
1457                IconButtonSize::Xl,
1458                Size::new(136.0, 136.0),
1459                Size::new(104.0, 136.0),
1460                Size::new(184.0, 136.0),
1461            ),
1462        ];
1463        for (size, default, narrow, wide) in expected {
1464            assert_eq!(visual_size(size, Standard), default, "{size:?} default");
1465            assert_eq!(visual_size(size, Narrow), narrow, "{size:?} narrow");
1466            assert_eq!(visual_size(size, Wide), wide, "{size:?} wide");
1467        }
1468    }
1469
1470    #[test]
1471    fn target_sizes_match_the_reference_table_for_every_size_and_width() {
1472        // m3e_icon_button_theme.dart:75-101.
1473        use IconButtonWidth::{Narrow, Standard, Wide};
1474        let expected: [(IconButtonSize, Size, Size, Size); 5] = [
1475            (
1476                IconButtonSize::Xs,
1477                Size::new(48.0, 48.0),
1478                Size::new(48.0, 48.0),
1479                Size::new(48.0, 48.0),
1480            ),
1481            (
1482                IconButtonSize::Sm,
1483                Size::new(48.0, 48.0),
1484                Size::new(48.0, 48.0),
1485                Size::new(52.0, 48.0),
1486            ),
1487            (
1488                IconButtonSize::Md,
1489                Size::new(56.0, 56.0),
1490                Size::new(48.0, 56.0),
1491                Size::new(72.0, 56.0),
1492            ),
1493            (
1494                IconButtonSize::Lg,
1495                Size::new(96.0, 96.0),
1496                Size::new(64.0, 96.0),
1497                Size::new(128.0, 96.0),
1498            ),
1499            (
1500                IconButtonSize::Xl,
1501                Size::new(136.0, 136.0),
1502                Size::new(104.0, 136.0),
1503                Size::new(184.0, 136.0),
1504            ),
1505        ];
1506        for (size, default, narrow, wide) in expected {
1507            assert_eq!(target_size(size, Standard), default, "{size:?} default");
1508            assert_eq!(target_size(size, Narrow), narrow, "{size:?} narrow");
1509            assert_eq!(target_size(size, Wide), wide, "{size:?} wide");
1510        }
1511    }
1512
1513    #[test]
1514    fn xs_and_sm_visual_is_smaller_than_the_48dp_target_but_the_target_never_is() {
1515        // The module docs' visual-vs-target distinction, pinned for every
1516        // size: on Xs/Sm the painted box is below the 48dp minimum touch
1517        // target; from Md up the two already coincide.
1518        for size in ALL_SIZES {
1519            for width in [
1520                IconButtonWidth::Standard,
1521                IconButtonWidth::Narrow,
1522                IconButtonWidth::Wide,
1523            ] {
1524                let visual = visual_size(size, width);
1525                let target = target_size(size, width);
1526                assert!(
1527                    target.width >= 48.0 && target.height >= 48.0,
1528                    "{size:?}/{width:?} target below 48dp"
1529                );
1530                assert!(target.width >= visual.width && target.height >= visual.height);
1531            }
1532        }
1533        assert!(visual_size(IconButtonSize::Xs, IconButtonWidth::Standard).width < 48.0);
1534        assert!(visual_size(IconButtonSize::Sm, IconButtonWidth::Standard).width < 48.0);
1535    }
1536
1537    // ---- radius tables ---------------------------------------------------
1538
1539    #[test]
1540    fn radius_tables_match_the_reference() {
1541        // m3e_icon_button_theme.dart:103-134.
1542        let expected: [(IconButtonSize, f64, f64, f64, f64); 5] = [
1543            (IconButtonSize::Xs, 16.0, 8.0, 6.0, 10.0),
1544            (IconButtonSize::Sm, 20.0, 10.0, 8.0, 12.0),
1545            (IconButtonSize::Md, 28.0, 14.0, 11.0, 16.0),
1546            (IconButtonSize::Lg, 48.0, 24.0, 19.0, 28.0),
1547            (IconButtonSize::Xl, 68.0, 34.0, 27.0, 40.0),
1548        ];
1549        for (size, round, square, pressed, hovered) in expected {
1550            assert_eq!(radius_rest_round(size), round, "{size:?} round");
1551            assert_eq!(radius_rest_square(size), square, "{size:?} square");
1552            assert_eq!(radius_pressed(size), pressed, "{size:?} pressed");
1553            assert_eq!(radius_hovered(size), hovered, "{size:?} hovered");
1554        }
1555    }
1556
1557    #[test]
1558    fn a_toggle_flips_shape_only_while_selected() {
1559        // m3e_icon_button_shapes.dart:16-27.
1560        assert_eq!(
1561            rest_variant(true, true, IconButtonShape::Round),
1562            IconButtonShape::Square
1563        );
1564        assert_eq!(
1565            rest_variant(true, true, IconButtonShape::Square),
1566            IconButtonShape::Round
1567        );
1568        assert_eq!(
1569            rest_variant(true, false, IconButtonShape::Round),
1570            IconButtonShape::Round
1571        );
1572        assert_eq!(
1573            rest_variant(false, true, IconButtonShape::Round),
1574            IconButtonShape::Round,
1575            "not a toggle at all: no flip even if selected is somehow true"
1576        );
1577    }
1578
1579    #[test]
1580    fn effective_radius_precedence_is_pressed_then_hovered_then_resting() {
1581        let mut state = InteractionState::new();
1582        assert_eq!(
1583            effective_radius(
1584                IconButtonSize::Sm,
1585                IconButtonShape::Round,
1586                false,
1587                false,
1588                state
1589            ),
1590            radius_rest_round(IconButtonSize::Sm)
1591        );
1592        state.set_hovered(true);
1593        assert_eq!(
1594            effective_radius(
1595                IconButtonSize::Sm,
1596                IconButtonShape::Round,
1597                false,
1598                false,
1599                state
1600            ),
1601            radius_hovered(IconButtonSize::Sm)
1602        );
1603        state.set_pressed(true);
1604        assert_eq!(
1605            effective_radius(
1606                IconButtonSize::Sm,
1607                IconButtonShape::Round,
1608                false,
1609                false,
1610                state
1611            ),
1612            radius_pressed(IconButtonSize::Sm)
1613        );
1614    }
1615
1616    // ---- colors ----------------------------------------------------------
1617
1618    #[test]
1619    fn unthemed_colors_match_the_reference_variant_table() {
1620        // m3e_icon_button_build.dart:153-180.
1621        let standard = resolve_colors(None, IconButtonVariant::Standard, false);
1622        assert_eq!(standard.container, Color::TRANSPARENT);
1623        assert_eq!(standard.content, ON_SURFACE_VARIANT);
1624        assert_eq!(standard.outline, None);
1625
1626        let standard_selected = resolve_colors(None, IconButtonVariant::Standard, true);
1627        assert_eq!(
1628            standard_selected.content, PRIMARY,
1629            "selected standard swaps content to primary — the one color the toggle changes"
1630        );
1631
1632        let filled = resolve_colors(None, IconButtonVariant::Filled, false);
1633        assert_eq!(filled.container, PRIMARY);
1634        assert_eq!(filled.content, ON_PRIMARY);
1635
1636        let tonal = resolve_colors(None, IconButtonVariant::Tonal, false);
1637        assert_eq!(tonal.container, SECONDARY_CONTAINER);
1638        assert_eq!(tonal.content, ON_SECONDARY_CONTAINER);
1639
1640        let outlined = resolve_colors(None, IconButtonVariant::Outlined, false);
1641        assert_eq!(outlined.container, Color::TRANSPARENT);
1642        assert_eq!(outlined.content, PRIMARY);
1643        assert_eq!(outlined.outline, Some(OUTLINE));
1644    }
1645
1646    #[test]
1647    fn only_standard_swaps_color_on_selection() {
1648        for variant in [
1649            IconButtonVariant::Filled,
1650            IconButtonVariant::Tonal,
1651            IconButtonVariant::Outlined,
1652        ] {
1653            let unselected = resolve_colors(None, variant, false);
1654            let selected = resolve_colors(None, variant, true);
1655            assert_eq!(
1656                unselected, selected,
1657                "{variant:?} must not change color on selection"
1658            );
1659        }
1660    }
1661
1662    // ---- press morph -------------------------------------------------------
1663
1664    #[test]
1665    fn press_spring_matches_expressive_spatial_press() {
1666        // Now the *same* constant both button families fling along (the
1667        // `RadiusPaddingMotion` reuse this module's own docs describe), not
1668        // two copies pinned equal by hand — this test still exists as the
1669        // tripwire that a future change to either family's spring is felt
1670        // here too.
1671        let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
1672        let spring = crate::button::motion::PRESS_SPRING;
1673        assert_eq!(spring.stiffness, token.stiffness);
1674        assert_eq!(spring.damping_ratio, token.damping_ratio);
1675        assert_eq!(spring.mass, 1.0);
1676        assert_eq!(spring.stiffness, 380.0);
1677        assert_eq!(spring.damping_ratio, 0.55);
1678    }
1679
1680    #[test]
1681    fn the_morph_snaps_on_mount_and_springs_on_a_later_retarget() {
1682        let mut motion = RadiusPaddingMotion::new();
1683        assert!(
1684            !motion.retarget(20.0, ZERO_PADDING),
1685            "the mount frame seeds, it does not animate"
1686        );
1687        assert_eq!(motion.radius(), 20.0);
1688        assert!(
1689            motion.retarget(8.0, ZERO_PADDING),
1690            "a later retarget starts a leg"
1691        );
1692        assert_eq!(motion.radius(), 20.0, "the leg starts where it was");
1693        settle_motion(&mut motion);
1694        assert_eq!(motion.radius(), 8.0);
1695    }
1696
1697    #[test]
1698    fn a_sub_tolerance_retarget_is_ignored() {
1699        let mut motion = RadiusPaddingMotion::new();
1700        motion.retarget(20.0, ZERO_PADDING);
1701        let tolerance = crate::button::motion::RETARGET_TOLERANCE;
1702        assert!(!motion.retarget(20.0 + tolerance / 2.0, ZERO_PADDING));
1703        assert_eq!(motion.target_radius(), 20.0);
1704    }
1705
1706    #[test]
1707    fn a_press_and_release_cycle_settles_at_the_resting_radius() {
1708        let presses = Rc::new(RefCell::new(0u32));
1709        let counted = presses.clone();
1710        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), move |_| {
1711            *counted.borrow_mut() += 1;
1712        });
1713        let mut widget = build(&view);
1714        let size = layout_with(&mut widget, 200.0);
1715
1716        // Mount frame: paint once to seed the morph at the resting radius.
1717        paint_at(&mut widget, size, None);
1718        settle_morph(&mut widget);
1719        let resting = widget.motion.radius();
1720        assert_eq!(resting, radius_rest_round(IconButtonSize::Sm));
1721
1722        // Press: dispatch Down inside, then re-paint to retarget + advance.
1723        let mut state = ();
1724        dispatch(
1725            &mut widget,
1726            &mut state,
1727            size,
1728            &ev(PointerPhase::Down, 10.0, 10.0),
1729        );
1730        paint_at(&mut widget, size, None);
1731        settle_morph(&mut widget);
1732        assert_eq!(widget.motion.radius(), radius_pressed(IconButtonSize::Sm));
1733
1734        // Release: Up inside fires on_press and clears pressed.
1735        dispatch(
1736            &mut widget,
1737            &mut state,
1738            size,
1739            &ev(PointerPhase::Up, 10.0, 10.0),
1740        );
1741        assert_eq!(*presses.borrow(), 1, "on_press fired exactly once");
1742        paint_at(&mut widget, size, None);
1743        settle_morph(&mut widget);
1744        assert_eq!(widget.motion.radius(), resting);
1745    }
1746
1747    // ---- badge ----------------------------------------------------------
1748
1749    #[test]
1750    fn badge_resolution_matches_build_badge() {
1751        // m3e_icon_button_build.dart:311-350.
1752        assert_eq!(resolve_badge(&None), ResolvedBadge::None);
1753        assert_eq!(
1754            resolve_badge(&Some(BadgeValue::Count(0))),
1755            ResolvedBadge::Dot
1756        );
1757        assert_eq!(
1758            resolve_badge(&Some(BadgeValue::Count(3))),
1759            ResolvedBadge::Label("3".to_string())
1760        );
1761        assert_eq!(
1762            resolve_badge(&Some(BadgeValue::Count(1_000_000))),
1763            ResolvedBadge::Label("999999".to_string()),
1764            "clamped to 999_999"
1765        );
1766        assert_eq!(
1767            resolve_badge(&Some(BadgeValue::Text(String::new()))),
1768            ResolvedBadge::None
1769        );
1770        assert_eq!(
1771            resolve_badge(&Some(BadgeValue::Text("NEW".to_string()))),
1772            ResolvedBadge::Label("NEW".to_string())
1773        );
1774    }
1775
1776    #[test]
1777    fn a_dot_badge_paints_one_small_filled_circle_at_the_top_end_corner() {
1778        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(0));
1779        let mut widget = build(&view);
1780        let size = layout_with(&mut widget, 200.0);
1781        let rec = paint_at(&mut widget, size, None);
1782
1783        let (origin, dot_size, radius, color) = rec
1784            .rrects
1785            .iter()
1786            .find(|(_, s, _, _)| *s == Size::new(DOT_DIAMETER, DOT_DIAMETER))
1787            .copied()
1788            .expect("a dot-sized rounded rect is painted");
1789        let _ = dot_size;
1790        assert_eq!(radius, DOT_DIAMETER / 2.0);
1791        assert_eq!(color, PRIMARY);
1792        // Top-end corner of the visual box.
1793        let visual = visual_size(IconButtonSize::Sm, IconButtonWidth::Standard);
1794        let visual_origin = widget.visual_origin;
1795        assert_eq!(origin.y, visual_origin.y);
1796        assert_eq!(origin.x, visual_origin.x + visual.width - DOT_DIAMETER);
1797    }
1798
1799    #[test]
1800    fn a_count_badge_paints_a_labeled_pill_with_the_digit_text() {
1801        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1802        let mut widget = build(&view);
1803        let size = layout_with(&mut widget, 200.0);
1804        let rec = paint_at(&mut widget, size, None);
1805
1806        assert!(
1807            rec.rrects.iter().any(|(_, s, r, c)| *s == widget.badge_size
1808                && *r == BADGE_HEIGHT / 2.0
1809                && *c == PRIMARY),
1810            "the pill background is painted at primary"
1811        );
1812        assert!(
1813            rec.runs
1814                .iter()
1815                .any(|(_, ink, glyphs)| *ink == ON_PRIMARY && *glyphs == 1),
1816            "the badge label's one-digit glyph run is painted at on_primary"
1817        );
1818    }
1819
1820    // ---- Badge typeface: the label's family follows the live theme ----------
1821
1822    /// A baseline theme whose `labelSmall` role names `family`.
1823    fn theme_with_label_small(family: FontFamily) -> Theme {
1824        let mut theme = crate::baseline();
1825        theme.type_scale.label_small.family = family;
1826        theme
1827    }
1828
1829    /// Lay `widget` out against `tcx`, threading `theme` the way the render
1830    /// root does.
1831    fn layout_themed(widget: &mut IconButtonWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1832        let mut ctx =
1833            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1834        widget.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1835    }
1836
1837    #[test]
1838    fn a_badge_label_takes_its_family_from_label_small() {
1839        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1840        let mut widget = build(&view);
1841        let mut tcx = TextContext::new();
1842        let probe = FontFamily::named("Icon Badge Role Probe");
1843        layout_themed(
1844            &mut widget,
1845            &mut tcx,
1846            Some(&theme_with_label_small(probe.clone())),
1847        );
1848        assert_eq!(widget.badge_run.style.family, probe);
1849        // Only the family is themed: the badge's own 11px stays.
1850        assert_eq!(widget.badge_run.style.size, BADGE_TEXT_SIZE);
1851    }
1852
1853    #[test]
1854    fn without_a_theme_the_badge_label_keeps_the_unthemed_style() {
1855        let unthemed = TextStyle::new(BADGE_TEXT_SIZE, Color::BLACK);
1856        assert_eq!(badge_style(None), unthemed);
1857        assert_eq!(unthemed.family, FontFamily::SystemUi);
1858
1859        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1860        let mut widget = build(&view);
1861        let mut tcx = TextContext::new();
1862        layout_themed(&mut widget, &mut tcx, None);
1863        assert_eq!(widget.badge_run.style, unthemed);
1864    }
1865
1866    #[test]
1867    fn a_theme_swap_reshapes_the_cached_badge_label() {
1868        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1869        let mut widget = build(&view);
1870        let mut tcx = TextContext::new();
1871        let first = theme_with_label_small(FontFamily::named("Icon Badge Swap Probe A"));
1872        layout_themed(&mut widget, &mut tcx, Some(&first));
1873
1874        // Control: the same theme again reuses the cached run outright.
1875        let settled = tcx.shape_cache_stats();
1876        layout_themed(&mut widget, &mut tcx, Some(&first));
1877        assert_eq!(
1878            tcx.shape_cache_stats(),
1879            settled,
1880            "an unchanged theme reshapes nothing"
1881        );
1882
1883        let second = theme_with_label_small(FontFamily::named("Icon Badge Swap Probe B"));
1884        layout_themed(&mut widget, &mut tcx, Some(&second));
1885        assert!(
1886            tcx.shape_cache_stats().shapes > settled.shapes,
1887            "a family swap must reshape the badge label, not serve the old face"
1888        );
1889    }
1890
1891    #[test]
1892    fn no_badge_value_paints_no_badge_geometry() {
1893        let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {});
1894        let mut widget = build(&view);
1895        let size = layout_with(&mut widget, 200.0);
1896        let rec = paint_at(&mut widget, size, None);
1897        assert!(
1898            rec.rrects
1899                .iter()
1900                .all(|(_, s, _, _)| *s != Size::new(DOT_DIAMETER, DOT_DIAMETER)),
1901            "no dot painted"
1902        );
1903        assert!(rec.runs.is_empty(), "no badge label painted");
1904    }
1905
1906    // ---- toggle / selected icon swap ------------------------------------
1907
1908    #[test]
1909    fn an_outlined_icon_button_paints_a_hairline_stroke() {
1910        let view =
1911            icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant(IconButtonVariant::Outlined);
1912        let mut widget = build(&view);
1913        let size = layout_with(&mut widget, 200.0);
1914        let rec = paint_at(&mut widget, size, None);
1915        assert_eq!(rec.strokes.len(), 1, "the outline hairline is stroked once");
1916        let (_, width, color) = rec.strokes[0];
1917        assert_eq!(width, OUTLINE_WIDTH);
1918        assert_eq!(color, OUTLINE);
1919    }
1920
1921    #[test]
1922    fn a_filled_icon_button_paints_no_outline_stroke() {
1923        let view =
1924            icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant(IconButtonVariant::Filled);
1925        let mut widget = build(&view);
1926        let size = layout_with(&mut widget, 200.0);
1927        let rec = paint_at(&mut widget, size, None);
1928        assert!(rec.strokes.is_empty());
1929    }
1930
1931    #[test]
1932    fn selected_icon_becomes_active_only_while_selected() {
1933        let base = leaf_any(20.0, 20.0);
1934        let selected_icon = leaf_any(24.0, 24.0);
1935        let view = icon_button::<(), _>(base, |_| {}).selected_icon(selected_icon);
1936
1937        assert!(view.is_toggle());
1938        assert!(
1939            std::ptr::eq(view.active_icon(), &view.icon),
1940            "unselected: base icon is active"
1941        );
1942
1943        let selected_view = view.selected(true);
1944        assert!(
1945            std::ptr::eq(
1946                selected_view.active_icon(),
1947                selected_view.selected_icon.as_ref().unwrap()
1948            ),
1949            "selected: selected_icon is active"
1950        );
1951    }
1952
1953    #[test]
1954    fn selected_without_a_selected_icon_still_counts_as_toggle() {
1955        let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).selected(false);
1956        assert!(view.is_toggle());
1957    }
1958
1959    // ---- semantics --------------------------------------------------------
1960
1961    #[test]
1962    fn semantics_reports_role_button_selected_and_label() {
1963        fn logic(_s: &mut ()) -> IconButtonView<()> {
1964            icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {})
1965                .selected(true)
1966                .semantic_label("Favorite")
1967        }
1968        let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
1969            frust_core::RenderRoot::new();
1970        let mut state = ();
1971        root.rebuild(&mut logic, &mut state);
1972        let mut tcx = frust::authoring::text::TextContext::new();
1973        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1974        let update = root.semantics();
1975        let (_, node) = update
1976            .nodes
1977            .iter()
1978            .find(|(_, n)| n.role() == Role::Button)
1979            .expect("icon button contributes a Role::Button node");
1980        assert_eq!(node.label(), Some("Favorite"));
1981        assert_eq!(node.is_selected(), Some(true));
1982        assert!(node.supports_action(Action::Click));
1983    }
1984
1985    #[test]
1986    fn semantics_omits_click_action_when_disabled() {
1987        fn logic(_s: &mut ()) -> IconButtonView<()> {
1988            icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).enabled(false)
1989        }
1990        let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
1991            frust_core::RenderRoot::new();
1992        let mut state = ();
1993        root.rebuild(&mut logic, &mut state);
1994        let mut tcx = frust::authoring::text::TextContext::new();
1995        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1996        let update = root.semantics();
1997        let (_, node) = update
1998            .nodes
1999            .iter()
2000            .find(|(_, n)| n.role() == Role::Button)
2001            .expect("icon button contributes a Role::Button node");
2002        assert!(!node.supports_action(Action::Click));
2003    }
2004
2005    // ---- disabled colors never dim ----------------------------------------
2006
2007    #[test]
2008    fn disabled_colors_are_identical_to_enabled_colors() {
2009        // Verified-from-source: `_variantColors` has no disabled branch —
2010        // see the module docs' Variants section.
2011        for variant in [
2012            IconButtonVariant::Standard,
2013            IconButtonVariant::Filled,
2014            IconButtonVariant::Tonal,
2015            IconButtonVariant::Outlined,
2016        ] {
2017            let enabled = resolve_colors(None, variant, false);
2018            let disabled = resolve_colors(None, variant, false); // enabled has no color input at all
2019            assert_eq!(enabled, disabled);
2020        }
2021    }
2022
2023    // ---- event lifecycle ---------------------------------------------------
2024
2025    #[test]
2026    fn a_disabled_button_ignores_every_pointer_event() {
2027        let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).enabled(false);
2028        let mut widget = build(&view);
2029        let size = layout_with(&mut widget, 200.0);
2030        let mut state = ();
2031        dispatch(
2032            &mut widget,
2033            &mut state,
2034            size,
2035            &ev(PointerPhase::Down, 5.0, 5.0),
2036        );
2037        assert!(!widget.captured);
2038        assert!(!widget.state.pressed);
2039    }
2040
2041    #[test]
2042    fn a_release_outside_bounds_does_not_fire() {
2043        let presses = Rc::new(RefCell::new(0u32));
2044        let counted = presses.clone();
2045        let view = icon_button::<(), _>(leaf_any(20.0, 20.0), move |_| {
2046            *counted.borrow_mut() += 1;
2047        });
2048        let mut widget = build(&view);
2049        let size = layout_with(&mut widget, 200.0);
2050        let mut state = ();
2051        dispatch(
2052            &mut widget,
2053            &mut state,
2054            size,
2055            &ev(PointerPhase::Down, 5.0, 5.0),
2056        );
2057        dispatch(
2058            &mut widget,
2059            &mut state,
2060            size,
2061            &ev(PointerPhase::Up, 9999.0, 9999.0),
2062        );
2063        assert_eq!(*presses.borrow(), 0);
2064        assert!(!widget.captured);
2065    }
2066}