Skip to main content

frust_material/
toggle_button.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/toggle_button/` tree is itself vendored from
3// m3e_buttons (MIT, © 2026 Mudit Purohit) — `m3e_toggle_button.dart` +
4// `components/m3e_toggle_button_{state,shape,style,content}.dart` +
5// `styles/m3e_toggle_button_theme.dart`, plus the connected-group corner
6// constants from `../toggle_button_group/styles/m3e_toggle_button_group_theme.dart`.
7// Porting decision: `button::motion::RadiusPaddingMotion` is private to the
8// `button` module (a wave-peer-owned tree this task must not touch — see this
9// module's own docs), so the checked-shape spring below is a sibling copy of
10// its exact contract, generalized from one radius to four independent
11// corners for the connected-group case.
12
13//! The Material 3 Expressive **toggle button**: a controlled on/off control
14//! that morphs its container between a round (unchecked) and a square
15//! (checked) shape, in any of the five button style variants.
16//!
17//! [`toggle_button`] builds a filled toggle button; [`outlined_toggle_button`],
18//! [`tonal_toggle_button`], [`elevated_toggle_button`] and
19//! [`text_toggle_button`] are the reference's other four named constructors.
20//! Every one is a **controlled component** (see `docs/CODE_STANDARDS.md`'s
21//! Interaction Semantics): a tap reports the *requested* value through
22//! `on_checked_change` and leaves `checked` untouched — the app feeds the
23//! confirmed value back on the next rebuild, exactly like
24//! [`mod@crate::switch`]/[`frust::Checkbox`].
25//!
26//! # Variants × checked (`_resolvedForegroundColor`/`_resolvedBackgroundColor`,
27//! `m3e_toggle_button_style.dart:118`/`:134`)
28//!
29//! | Variant | Content unchecked | Content checked | Container unchecked | Container checked |
30//! |---|---|---|---|---|
31//! | [`ButtonVariant::Filled`] (default) | `on_surface_variant` | `on_primary` | `surface_container_highest` | `primary` |
32//! | [`ButtonVariant::Elevated`] | `primary` | `on_primary` | `surface_container_low` | `primary` |
33//! | [`ButtonVariant::Tonal`] | `on_surface_variant` | `on_secondary_container` | `surface_container_highest` | `secondary_container` |
34//! | [`ButtonVariant::Outlined`] | `on_surface` | `on_secondary_container` | transparent | `secondary_container` |
35//! | [`ButtonVariant::Text`] | `on_surface` | `primary` | transparent | transparent |
36//!
37//! Unlike the plain [`mod@crate::button`], an unchecked Filled/Tonal toggle
38//! still paints an opaque container (`surface_container_highest`) — the "off"
39//! pill look every M3E toggle-button row uses. The outline hairline paints
40//! only for [`ButtonVariant::Outlined`], both checked and unchecked
41//! (`_sideProperty`), reusing `colors.outline` exactly as the plain button
42//! does. Disabled colors are checked-independent, reusing the plain button's
43//! 38%/12% `on_surface` roles ([`crate::interaction::DISABLED_CONTENT_OPACITY`]/
44//! [`crate::interaction::DISABLED_CONTAINER_OPACITY`] — `M3EButtonConstants`'s
45//! disabled alphas are the same three constants the plain button cites).
46//! Elevation is **not** checked-aware (`_buttonTheme.elevation` reads plain
47//! `WidgetState`s, never adding `.selected` the way the color/side resolvers
48//! do) — reusing the plain button's own (variant, enabled, pressed, hovered)
49//! table verbatim (duplicated in [`elevation_dp`] since `button::core` is
50//! private; see this module's Porting decision above).
51//!
52//! # Sizes: three of the five (`m3e_button_theme.dart:76`'s rows)
53//!
54//! | Size | Height | Padding (labeled) | Icon | Gap | Label role |
55//! |---|---|---|---|---|---|
56//! | [`ToggleButtonSize::Sm`] (default) | 40 | 16 | 20 | 8 | `label_medium` |
57//! | [`ToggleButtonSize::Md`] | 56 | 24 | 24 | 8 | `label_large` |
58//! | [`ToggleButtonSize::Lg`] | 96 | 48 | 32 | 12 | `title_medium` |
59//!
60//! Scoped to `sm`/`md`/`lg` (the reference's `xs`/`xl` rows are not ported).
61//! An icon-only toggle (no effective label) halves its horizontal padding
62//! (`_buildWidget`'s `hPad = _hasLabel ? m.hPadding : m.hPadding / 2`) — and,
63//! also unlike the plain button, a toggle button has **no** minimum-width
64//! floor: the reference's `_buildButtonStyle` sets `minimumSize:
65//! Size(0, height)`, never touching `M3EButtonTheme.minWidthFloor` (that
66//! constant is read only by the plain button's own `ButtonStyle`,
67//! `m3e_button_style.dart:11`) — transcribed faithfully, not "fixed".
68//!
69//! # Content wider than the box: covered, never re-fitted
70//!
71//! A toggle button shapes its label **once, at its natural width**, and a box
72//! too small for the resulting content row centers and *clips* it rather than
73//! ellipsizing it to fit. That is the reference's own answer, not a
74//! simplification: `_fitContentToConstraints`
75//! (`m3e_toggle_button_content.dart:95`-`:111`) hands a bounded constraint to
76//! `FittedBox(fit: BoxFit.none, clipBehavior: Clip.hardEdge)`, which lays the
77//! natural row out unbounded, applies no scale, centers it
78//! (`Alignment.center`) and hard-clips the overflow. The one ellipsis the
79//! reference's label style carries (`m3e_base_button_state.dart:177`) can
80//! therefore never fire on this path — nothing ever hands the label a bounded
81//! width — and the label merges only `maxLines: 1, softWrap: false`
82//! (`_buildLabelText`, `:194`). The plain [`mod@crate::button`] is the genuine
83//! contrast, and keeps its own ellipsize-to-fit: its label sits in a
84//! `Flexible` inside the content row (`m3e_button_state.dart:28`-`:35`), so it
85//! really is handed the width left over.
86//!
87//! This is what a [`mod@crate::button_group`] member's press squish looks like
88//! in the reference and now here: the padding compresses first (the row is
89//! centered in a shrinking box), then the box edge slides over stationary
90//! glyphs — never a label re-flowing to a new ellipsis every frame, and never
91//! a truncation left behind once the box comes back. Two consequences worth
92//! naming: [`LabelRun`] caches on style alone (no width key, so a squish
93//! re-shapes nothing), and `content_x` is allowed to go negative so the
94//! overflow is split evenly off both ends.
95//!
96//! # Checked shape morph: round ↔ square, one spring
97//!
98//! Unchecked rests at the round family radius (`height / 2`, a pill);
99//! checked rests at the square family radius —
100//! [`ToggleButtonSize::square_radius`], the exact per-size token the plain
101//! button's own square shape resolves
102//! (`theme.shape.medium`/`large`/`extra_large`, 12/16/28 unthemed). Pressed
103//! and hovered each override *both* of those with their own per-size token
104//! ([`ToggleButtonSize::pressed_radius`]/[`ToggleButtonSize::hovered_radius`],
105//! same tables as the plain button), in `pressed > hovered > checked > rest`
106//! precedence — `m3e_toggle_button_shape.dart`'s
107//! `_resolveStandaloneTargetRadius`. All four corners move together for a
108//! standalone toggle button; see *Group-connection corner asymmetry* below
109//! for why the driver still carries four independent corners rather than one
110//! scalar.
111//!
112//! The spring is [`SHAPE_SPRING`] — stiffness 380, damping ratio 0.55, the
113//! same `expressiveSpatialPress` preset
114//! ([`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`]) the plain
115//! button's press morph flings along
116//! (`_M3EToggleButtonState.effectiveMotion`'s
117//! `M3EButtonMotion.expressiveSpatialPress` default,
118//! `m3e_toggle_button_state.dart:61`-`:62`). [`ShapeMotion`] carries the exact
119//! retarget/continuity contract [`mod@crate::button`]'s
120//! `RadiusPaddingMotion` documents (mount-frame snap, a
121//! [`RETARGET_TOLERANCE`]-px dead band, mid-flight retarget continuity from
122//! the value being painted *right now* rather than a discontinuous
123//! progress-flip, and a 1.5× [`OVERSHOOT_LIMIT`]) — this module's own tests
124//! re-pin all of it directly, since that type is private to a sibling module
125//! this task must not touch.
126//!
127//! # Group-connection corner asymmetry
128//!
129//! [`ToggleButtonView::group_connected`]/[`ToggleButtonView::first_in_group`]/
130//! [`ToggleButtonView::last_in_group`] are plumbing for the upcoming
131//! button-group rework, exercised directly here. When connected, the two
132//! *outer* corners (the group-facing side of a first/last member) stay pinned
133//! at the pill radius (`height / 2`) in every state; the two *inner*
134//! (adjacent-member) corners instead resolve per state —
135//! [`CONNECTED_INNER_RADIUS`] (6dp, unthemed) resting,
136//! [`CONNECTED_PRESSED_INNER_RADIUS`] (2dp, unthemed) pressed, the per-size
137//! [`ToggleButtonSize::hovered_radius`] token hovered — mirroring
138//! `m3e_toggle_button_shape.dart`'s `_resolveConnectedTargetRadius`. A
139//! **checked, connected, non-pressed/non-hovered** member is a special case:
140//! all four corners go fully round (`checkedConnectedShape`), not the
141//! per-size square token a standalone checked member uses — the reference's
142//! own asymmetry between the standalone and connected checked shapes,
143//! transcribed rather than "corrected". `is_first_in_group`/`is_last_in_group`
144//! resolve independently, so a single-member group (`true`/`true`) is all
145//! outer corners, matching [`mod@crate::button_group`]'s own single-member
146//! convention. Painted with [`frust::authoring::CornerRadii`] via
147//! [`PaintScene::fill_rounded_rect_radii`] rather than
148//! [`mod@crate::button_group`]'s own `kurbo`-`RoundedRect`-plus-`fill_path`
149//! route — both reach the same pixels; this module takes the newer
150//! per-corner-native paint call directly. This framework has no
151//! `Directionality`/RTL layout yet, so — like `button_group`'s own
152//! `member_radii` — "first"/"last" map onto "left"/"right" unconditionally,
153//! with no mirrored-in-RTL case to port.
154//!
155//! # Icon/label swap on checked
156//!
157//! [`ToggleButtonView::icon`]/[`ToggleButtonView::checked_icon`] and
158//! [`ToggleButtonView::label`]/[`ToggleButtonView::checked_label`] each fall
159//! back to their unchecked sibling when the checked-state one is unset
160//! (`_effectiveIcon`/`_effectiveLabel`, `m3e_toggle_button_state.dart:28`-
161//! `:48`) — all four slots are optional, so a toggle button may be icon-only,
162//! label-only, both, or (checked-state-only) neither. **Porting decision:**
163//! the reference cross-fades and horizontally slides between the unchecked
164//! and checked label (`_buildAnimatedLabelSlot`, a second, independent
165//! spring) when exactly one of `icon`/`checkedIcon` is paired with exactly
166//! one of `label`/`checkedLabel` set on the opposite side
167//! (`_hasDistinctLabelStates`). That second animation channel is out of scope
168//! here: the effective icon/label swap immediately on the checked flip the
169//! same frame it is confirmed, with no cross-fade — a reasonable v1 cut this
170//! module's tests pin directly rather than leave undocumented.
171//!
172//! # Long-press: fires, but on the next pointer event, not immediately
173//!
174//! [`ToggleButtonView::on_long_press`] fires once a capture has been held past
175//! [`LONG_PRESS_MS`] (500ms — same value and citation
176//! `frust_widgets::gesture`'s `GestureDetector` uses: **community-approximate**,
177//! Android's `ViewConfiguration` defaults to ~400-500ms, iOS's
178//! `UILongPressGestureRecognizer.minimumPressDuration` to 0.5s). `paint` is
179//! the only pass carrying a clock in this framework, so it latches an
180//! `elapsed` flag the same way `GestureDetector` does — but that widget then
181//! fires *immediately*, with no further pointer event, via
182//! `frust_core::mark_pending_result_flush` forcing an extra rebuild pass. That
183//! function is not part of `frust::authoring`'s re-exported surface (a
184//! design-system plugin depends on the `frust` facade alone, never
185//! `frust-core` directly in production — `docs/PLUGINS_CODE_STANDARDS.md`'s
186//! Plugin Conventions), so it is unreachable here. This widget instead fires
187//! on the next pointer event that arrives after the threshold — the same
188//! *move-arrival* delivery `GestureDetector` itself falls back to when no
189//! long-press handler is wired for its own `on_hold_progress`-only case — with
190//! an `Up` as the final fallback if no `Move` ever arrives. A long-press and
191//! the checked toggle resolve as mutually exclusive outcomes of the same
192//! press, exactly like `GestureDetector`'s own tap/long-press arbitration: an
193//! `Up` that already crossed the threshold fires the long-press, never the
194//! toggle. Movement past [`frust::input::TOUCH_SLOP`] from the initiating
195//! `Down` cancels the long-press candidacy outright (a drag, not a hold).
196//!
197//! # Haptics, focus ring, decoration/gradient seam
198//!
199//! [`ToggleButtonView::haptic`] defaults to [`HapticSignal::None`], fired
200//! (when set) on the same up-inside edge that reports the checked toggle,
201//! through [`MaterialHaptics::fire`] — the plain button's own convention
202//! (`m3e_toggle_button_state.dart:120`'s `M3EHaptics.trigger` call site).
203//! Neither a focus ring (`m3e_focus_ring.dart`, which the plain button ports)
204//! nor the `M3EToggleButtonDecoration` gradient/override seam (the plain
205//! button's still-unfilled `ButtonDecoration` seam) is ported in this v1 —
206//! out of this task's acceptance scope; `PaintCtx::has_focus`/`is_hovered`
207//! still drive the state-layer overlay correctly, only the extra ring stroke
208//! is deferred.
209//!
210//! # Attribution
211//!
212//! See `plugins/material/NOTICE`'s "MIT License — Additional Copyright Holders
213//! (Vendored Components)" section (Mudit Purohit / m3e_buttons) and its Module
214//! Attribution Header Convention.
215
216use std::rc::Rc;
217use std::time::Duration;
218
219use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
220use frust::authoring::{
221    Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, CursorIcon,
222    EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role,
223    SemanticsCtx, Toggled, View, Widget,
224};
225use frust::input::TOUCH_SLOP;
226use frust::{AnimationController, FrameTime, SpringDesc, Theme};
227use kurbo::{Point, Rect, RoundedRect, RoundedRectRadii, Shape, Size};
228use peniko::{Brush, Color};
229
230use crate::ButtonVariant;
231use crate::interaction::{
232    DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
233    MaterialHaptics,
234};
235use crate::press::presses;
236
237// ---- Metric tables (m3e_button_theme.dart, sm/md/lg rows only) ------------
238
239/// Stroke width of an [`ButtonVariant::Outlined`] hairline, in logical px —
240/// same value the plain button uses (Flutter's `BorderSide` default).
241const OUTLINE_WIDTH: f64 = 1.0;
242
243/// Flattening tolerance for the `kurbo` rounded-rect paths this module
244/// strokes (matches [`mod@crate::button_group`]'s `PATH_TOLERANCE`).
245const PATH_TOLERANCE: f64 = 0.1;
246
247/// The ink every label run is *shaped* with; never painted — see
248/// [`mod@crate::button`]'s `SHAPING_INK` for why (recolor-without-reshape).
249const SHAPING_INK: Color = Color::BLACK;
250
251/// One row of the per-size measurement table.
252#[derive(Clone, Copy, Debug, PartialEq)]
253struct SizeMetrics {
254    height: f64,
255    h_padding: f64,
256    icon_size: f64,
257    icon_gap: f64,
258}
259
260/// One row of the M3 type scale, as `(size, line_height, letter_spacing,
261/// weight)` — the unthemed fallback for a label role.
262type TypeToken = (f32, f32, f32, FontWeight);
263
264const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
265const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
266const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
267
268/// The toggle button's size tier — a three-row subset of the reference's
269/// `M3EButtonSize` (`sm`/`md`/`lg` only). See the [module docs](self)' Sizes
270/// table.
271#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
272pub enum ToggleButtonSize {
273    /// 40dp tall (the default).
274    #[default]
275    Sm,
276    /// 56dp tall.
277    Md,
278    /// 96dp tall.
279    Lg,
280}
281
282impl ToggleButtonSize {
283    /// The size's row of `m3e_button_theme.dart:76`'s `_measurementsTable`.
284    fn metrics(self) -> SizeMetrics {
285        match self {
286            // m3e_button_theme.dart:83
287            ToggleButtonSize::Sm => SizeMetrics {
288                height: 40.0,
289                h_padding: 16.0,
290                icon_size: 20.0,
291                icon_gap: 8.0,
292            },
293            // m3e_button_theme.dart:89
294            ToggleButtonSize::Md => SizeMetrics {
295                height: 56.0,
296                h_padding: 24.0,
297                icon_size: 24.0,
298                icon_gap: 8.0,
299            },
300            // m3e_button_theme.dart:95
301            ToggleButtonSize::Lg => SizeMetrics {
302                height: 96.0,
303                h_padding: 48.0,
304                icon_size: 32.0,
305                icon_gap: 12.0,
306            },
307        }
308    }
309
310    /// The checked-state (square-family) resting radius
311    /// (`m3e_button_theme.dart:44`'s `_squareRadiusTable`: 12/16/28 for
312    /// sm/md/lg). Resolved from the theme's shape scale, whose
313    /// `medium`/`large`/`extra_large` tokens coincide with that table
314    /// exactly — the same resolution [`mod@crate::button`]'s own
315    /// `ButtonSize::square_radius` performs; duplicated here since that
316    /// method is private to the `button` module.
317    fn square_radius(self, theme: Option<&Theme>) -> f64 {
318        match theme {
319            Some(theme) => match self {
320                ToggleButtonSize::Sm => theme.shape.medium,
321                ToggleButtonSize::Md => theme.shape.large,
322                ToggleButtonSize::Lg => theme.shape.extra_large,
323            },
324            None => match self {
325                ToggleButtonSize::Sm => 12.0,
326                ToggleButtonSize::Md => 16.0,
327                ToggleButtonSize::Lg => 28.0,
328            },
329        }
330    }
331
332    /// The pressed-state resting radius (`m3e_button_theme.dart:52`'s
333    /// `_pressedRadiusTable`: 8/12/16 for sm/md/lg) — one shape-scale rung
334    /// squarer than [`Self::square_radius`], and likewise theme-resolved.
335    fn pressed_radius(self, theme: Option<&Theme>) -> f64 {
336        match theme {
337            Some(theme) => match self {
338                ToggleButtonSize::Sm => theme.shape.small,
339                ToggleButtonSize::Md => theme.shape.medium,
340                ToggleButtonSize::Lg => theme.shape.large,
341            },
342            None => match self {
343                ToggleButtonSize::Sm => 8.0,
344                ToggleButtonSize::Md => 12.0,
345                ToggleButtonSize::Lg => 16.0,
346            },
347        }
348    }
349
350    /// The hovered-state resting radius (`m3e_button_theme.dart:60`'s
351    /// `_hoveredRadiusTable`: 10/14/22 for sm/md/lg) — the midpoint of its
352    /// size's square and pressed radii, so (like the plain button's own
353    /// table) it has no shape-scale token to resolve from and stays literal.
354    fn hovered_radius(self) -> f64 {
355        match self {
356            ToggleButtonSize::Sm => 10.0,
357            ToggleButtonSize::Md => 14.0,
358            ToggleButtonSize::Lg => 22.0,
359        }
360    }
361
362    /// The label's type role (`m3e_base_button_state.dart:169`, the mixin
363    /// both the plain and toggle button share): `labelMedium`/`labelLarge`/
364    /// `titleMedium` for sm/md/lg. Themed from [`frust::Theme`]'s type scale;
365    /// unthemed from the M3 token literals above. The returned style always
366    /// carries [`SHAPING_INK`].
367    fn label_style(self, theme: Option<&Theme>) -> TextStyle {
368        let mut style = match theme {
369            Some(theme) => match self {
370                ToggleButtonSize::Sm => theme.type_scale.label_medium.clone(),
371                ToggleButtonSize::Md => theme.type_scale.label_large.clone(),
372                ToggleButtonSize::Lg => theme.type_scale.title_medium.clone(),
373            },
374            None => {
375                let (size, line_height, letter_spacing, weight) = match self {
376                    ToggleButtonSize::Sm => LABEL_MEDIUM,
377                    ToggleButtonSize::Md => LABEL_LARGE,
378                    ToggleButtonSize::Lg => TITLE_MEDIUM,
379                };
380                let mut style = TextStyle::new(size, SHAPING_INK);
381                style.line_height = LineHeight::Absolute(line_height);
382                style.letter_spacing = letter_spacing;
383                style.weight = weight;
384                style
385            }
386        };
387        style.color = SHAPING_INK;
388        style
389    }
390}
391
392// ---- Group-connection constants (m3e_toggle_button_group_theme.dart) ------
393
394/// The connected-group inner-corner radius while resting, in logical px —
395/// `M3EToggleButtonGroupTheme.connectedInnerRadius`'s default
396/// (`m3e_toggle_button_group_theme.dart:39`). Unthemed: `frust::Theme` has no
397/// matching shape-scale slot for this M3E-specific concept, so this stays a
398/// plain constant until the button-group rework lands a themeable extension.
399const CONNECTED_INNER_RADIUS: f64 = 6.0;
400/// The connected-group inner-corner radius while pressed, in logical px —
401/// `M3EToggleButtonGroupTheme.connectedPressedInnerRadius`'s default
402/// (`m3e_toggle_button_group_theme.dart:40`). Same unthemed status as
403/// [`CONNECTED_INNER_RADIUS`].
404const CONNECTED_PRESSED_INNER_RADIUS: f64 = 2.0;
405
406// ---- Colors (m3e_toggle_button_style.dart) ---------------------------------
407
408/// Unthemed-fallback `primary` (a theme resolves `colors.primary`).
409const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
410/// Unthemed-fallback `on_primary`.
411const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
412/// Unthemed-fallback `on_surface_variant`.
413const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
414/// Unthemed-fallback `surface_container_highest`.
415const SURFACE_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
416/// Unthemed-fallback `surface_container_low`.
417const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
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 `outline`.
423const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
424/// Unthemed-fallback `on_surface` — the disabled roles' base color.
425const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
426/// Unthemed-fallback shadow color (opaque black at
427/// `crate::tokens::elevation()`'s `0.3` alpha).
428const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
429
430/// The nine color roles a toggle button reads, resolved once per pass from
431/// the live theme or from this module's unthemed fallbacks.
432#[derive(Clone, Copy, Debug, PartialEq)]
433struct Roles {
434    primary: Color,
435    on_primary: Color,
436    on_surface_variant: Color,
437    surface_container_highest: Color,
438    surface_container_low: Color,
439    secondary_container: Color,
440    on_secondary_container: Color,
441    outline: Color,
442    on_surface: Color,
443}
444
445impl Roles {
446    fn resolve(theme: Option<&Theme>) -> Self {
447        match theme {
448            Some(theme) => {
449                let s = theme.scheme();
450                Self {
451                    primary: s.primary,
452                    on_primary: s.on_primary,
453                    on_surface_variant: s.on_surface_variant,
454                    surface_container_highest: s.surface_container_highest,
455                    surface_container_low: s.surface_container_low,
456                    secondary_container: s.secondary_container,
457                    on_secondary_container: s.on_secondary_container,
458                    outline: s.outline,
459                    on_surface: s.on_surface,
460                }
461            }
462            None => Self {
463                primary: PRIMARY,
464                on_primary: ON_PRIMARY,
465                on_surface_variant: ON_SURFACE_VARIANT,
466                surface_container_highest: SURFACE_CONTAINER_HIGHEST,
467                surface_container_low: SURFACE_CONTAINER_LOW,
468                secondary_container: SECONDARY_CONTAINER,
469                on_secondary_container: ON_SECONDARY_CONTAINER,
470                outline: OUTLINE,
471                on_surface: ON_SURFACE,
472            },
473        }
474    }
475}
476
477/// The resolved inks for one paint pass.
478#[derive(Clone, Copy, Debug, PartialEq)]
479struct ToggleColors {
480    /// Container fill (transparent for an unchecked Outlined/Text button).
481    container: Color,
482    /// Label + icon ink, and the state layer's tint.
483    content: Color,
484    /// The hairline, when the variant draws one.
485    outline: Option<Color>,
486}
487
488/// `color` with its alpha channel replaced by `alpha`.
489fn with_alpha(color: Color, alpha: f32) -> Color {
490    let c = color.components;
491    Color::new([c[0], c[1], c[2], alpha])
492}
493
494/// Whether `variant` never paints a container even when checked, per the
495/// reference's `_isTransparentStyle` (`m3e_toggle_button_style.dart:150`) —
496/// note this is style-only, unlike the checked/unchecked container switch
497/// itself: an Outlined toggle *does* paint `secondary_container` while
498/// checked (see [`resolve_colors`]), so this flag only gates the *disabled*
499/// container branch, matching the reference's own scope for it.
500fn is_transparent(variant: ButtonVariant) -> bool {
501    matches!(variant, ButtonVariant::Outlined | ButtonVariant::Text)
502}
503
504/// Resolve the container/content/outline inks for `variant` at `checked`.
505///
506/// Enabled follows `m3e_toggle_button_style.dart:118`
507/// (`_resolvedForegroundColor`) and `:134` (`_resolvedBackgroundColor`) — see
508/// the [module docs](self)' Variants × checked table. Disabled follows the
509/// same `M3EButtonConstants` alphas the plain button uses: content at
510/// [`DISABLED_CONTENT_OPACITY`] (38%), an opaque container at
511/// [`DISABLED_CONTAINER_OPACITY`] (12%), both over `on_surface`, and a
512/// transparent-style container stays transparent — checked-independent.
513fn resolve_colors(
514    theme: Option<&Theme>,
515    variant: ButtonVariant,
516    checked: bool,
517    enabled: bool,
518) -> ToggleColors {
519    let roles = Roles::resolve(theme);
520
521    if !enabled {
522        return ToggleColors {
523            container: if is_transparent(variant) {
524                Color::TRANSPARENT
525            } else {
526                with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)
527            },
528            content: with_alpha(roles.on_surface, DISABLED_CONTENT_OPACITY),
529            outline: (variant == ButtonVariant::Outlined)
530                .then(|| with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)),
531        };
532    }
533
534    let (content, container) = match variant {
535        ButtonVariant::Filled => (
536            if checked {
537                roles.on_primary
538            } else {
539                roles.on_surface_variant
540            },
541            if checked {
542                roles.primary
543            } else {
544                roles.surface_container_highest
545            },
546        ),
547        ButtonVariant::Elevated => (
548            if checked {
549                roles.on_primary
550            } else {
551                roles.primary
552            },
553            if checked {
554                roles.primary
555            } else {
556                roles.surface_container_low
557            },
558        ),
559        ButtonVariant::Tonal => (
560            if checked {
561                roles.on_secondary_container
562            } else {
563                roles.on_surface_variant
564            },
565            if checked {
566                roles.secondary_container
567            } else {
568                roles.surface_container_highest
569            },
570        ),
571        ButtonVariant::Outlined => (
572            if checked {
573                roles.on_secondary_container
574            } else {
575                roles.on_surface
576            },
577            if checked {
578                roles.secondary_container
579            } else {
580                Color::TRANSPARENT
581            },
582        ),
583        ButtonVariant::Text => (
584            if checked {
585                roles.primary
586            } else {
587                roles.on_surface
588            },
589            Color::TRANSPARENT,
590        ),
591    };
592
593    ToggleColors {
594        container,
595        content,
596        outline: (variant == ButtonVariant::Outlined).then_some(roles.outline),
597    }
598}
599
600/// The container elevation in dp for `variant` in the current state —
601/// **not** checked-aware (`_buttonTheme.elevation(widget.style, states)` is
602/// called with plain `WidgetState`s, never `.selected`,
603/// `m3e_toggle_button_style.dart:188`-`:190`), so this is a byte-for-byte
604/// duplicate of [`mod@crate::button`]'s own `elevation_dp` — private to that
605/// module, hence the copy (see this module's Porting decision header).
606fn elevation_dp(variant: ButtonVariant, enabled: bool, pressed: bool, hovered: bool) -> f64 {
607    if !enabled {
608        return 0.0;
609    }
610    match variant {
611        ButtonVariant::Elevated => {
612            if pressed {
613                0.0
614            } else if hovered {
615                3.0
616            } else {
617                1.0
618            }
619        }
620        ButtonVariant::Filled | ButtonVariant::Tonal => {
621            if !pressed && hovered {
622                1.0
623            } else {
624                0.0
625            }
626        }
627        ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
628    }
629}
630
631/// The `(blur_std_dev, y_offset, color)` shadow for an elevation of `dp`, or
632/// `None` for a flat button — the same M3 elevation-level resolution
633/// [`mod@crate::button`]'s own `resolve_shadow` performs (duplicated here for
634/// the same private-module reason as [`elevation_dp`]).
635fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
636    if dp <= 0.0 {
637        return None;
638    }
639    match theme {
640        Some(theme) => {
641            let level = if dp >= theme.elevation.level2.dp {
642                theme.elevation.level2
643            } else {
644                theme.elevation.level1
645            };
646            let shadow = level.shadow(theme.brightness);
647            let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
648            Some((shadow.blur_std_dev, shadow.y_offset, color))
649        }
650        None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
651    }
652}
653
654// ---- The checked-shape + padding spring (sibling of button::motion) -------
655
656/// The morph's spring: stiffness 380, damping ratio 0.55, mass 1 — the exact
657/// [`crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`] preset. See
658/// the [module docs](self)' Checked shape morph section.
659const SHAPE_SPRING: SpringDesc = SpringDesc {
660    mass: 1.0,
661    stiffness: 380.0,
662    damping_ratio: 0.55,
663};
664
665/// Nominal period seeding the morph [`AnimationController`]'s clock — spring-
666/// driven via `fling`, so this duration only backs construction (mirrors
667/// `button::motion::PRESS_ANIM_PERIOD`).
668const ANIM_PERIOD: Duration = Duration::from_millis(300);
669
670/// Launch velocity (progress-units/sec) handed to each leg's
671/// [`AnimationController::fling`] — the same modest kick every spring in this
672/// crate uses so a retarget reads snappy rather than creeping off zero.
673const FLING_VELOCITY: f64 = 4.0;
674
675/// The dead band (logical px) a new target must exceed (per corner, per
676/// padding edge) before it starts a new spring leg — mirrors
677/// `button::motion::RETARGET_TOLERANCE` (the reference's
678/// `kSpringRetargetTolerance`).
679const RETARGET_TOLERANCE: f64 = 0.1;
680
681/// How far past its target the spring's progress is allowed to read —
682/// mirrors `button::motion::OVERSHOOT_LIMIT` (the reference's
683/// `rawFactor.clamp(0.0, 1.5)`).
684const OVERSHOOT_LIMIT: f64 = 1.5;
685
686/// Linear interpolation between `from` and `to` at `t` (unclamped — the
687/// spring's overshoot rides past `t = 1`).
688fn lerp(from: f64, to: f64, t: f64) -> f64 {
689    from + (to - from) * t
690}
691
692/// The button's internal content padding — symmetric horizontal only (a
693/// toggle button's height is fixed by its size token), mirroring
694/// `button::motion::ContentPadding`.
695#[derive(Clone, Copy, Debug, PartialEq)]
696struct ContentPadding {
697    left: f64,
698    right: f64,
699    top: f64,
700    bottom: f64,
701}
702
703impl ContentPadding {
704    const fn symmetric(horizontal: f64) -> Self {
705        Self {
706            left: horizontal,
707            right: horizontal,
708            top: 0.0,
709            bottom: 0.0,
710        }
711    }
712
713    fn lerp(self, to: Self, t: f64) -> Self {
714        Self {
715            left: lerp(self.left, to.left, t),
716            right: lerp(self.right, to.right, t),
717            top: lerp(self.top, to.top, t),
718            bottom: lerp(self.bottom, to.bottom, t),
719        }
720    }
721
722    fn matches(self, other: Self) -> bool {
723        (self.left - other.left).abs() <= RETARGET_TOLERANCE
724            && (self.right - other.right).abs() <= RETARGET_TOLERANCE
725            && (self.top - other.top).abs() <= RETARGET_TOLERANCE
726            && (self.bottom - other.bottom).abs() <= RETARGET_TOLERANCE
727    }
728}
729
730/// Whether every corner of `a` is within [`RETARGET_TOLERANCE`] of `b`'s.
731fn corners_match(a: CornerRadii, b: CornerRadii) -> bool {
732    (a.top_left - b.top_left).abs() <= RETARGET_TOLERANCE
733        && (a.top_right - b.top_right).abs() <= RETARGET_TOLERANCE
734        && (a.bottom_right - b.bottom_right).abs() <= RETARGET_TOLERANCE
735        && (a.bottom_left - b.bottom_left).abs() <= RETARGET_TOLERANCE
736}
737
738/// Per-corner interpolation of `from` toward `to` at `t`, each corner floored
739/// at `0` (the reference's `v < 0 ? 0.0 : v` corner guard).
740fn lerp_corners(from: CornerRadii, to: CornerRadii, t: f64) -> CornerRadii {
741    CornerRadii::new(
742        lerp(from.top_left, to.top_left, t).max(0.0),
743        lerp(from.top_right, to.top_right, t).max(0.0),
744        lerp(from.bottom_right, to.bottom_right, t).max(0.0),
745        lerp(from.bottom_left, to.bottom_left, t).max(0.0),
746    )
747}
748
749/// The checked-shape + padding spring [`ToggleButtonWidget`] owns — a sibling
750/// of `button::motion::RadiusPaddingMotion`, generalized from one radius to
751/// four independent corners. See the [module docs](self)' Checked shape
752/// morph section for the full contract and why this is a copy rather than a
753/// reuse.
754#[derive(Clone, Copy, Debug)]
755struct ShapeMotion {
756    from_radii: CornerRadii,
757    to_radii: CornerRadii,
758    from_padding: ContentPadding,
759    to_padding: ContentPadding,
760    anim: AnimationController,
761    /// Whether a first target has been seeded — the mount frame snaps rather
762    /// than springs.
763    seeded: bool,
764}
765
766impl ShapeMotion {
767    /// An unseeded morph resting at every corner `0` and zero padding — the
768    /// first [`Self::retarget`] snaps to its argument instead of springing.
769    fn new() -> Self {
770        let zero_pad = ContentPadding {
771            left: 0.0,
772            right: 0.0,
773            top: 0.0,
774            bottom: 0.0,
775        };
776        Self {
777            from_radii: CornerRadii::default(),
778            to_radii: CornerRadii::default(),
779            from_padding: zero_pad,
780            to_padding: zero_pad,
781            anim: AnimationController::new(ANIM_PERIOD),
782            seeded: false,
783        }
784    }
785
786    /// Aim the morph at `radii`/`padding`, returning whether this actually
787    /// started a new spring leg. Same three-outcome contract as
788    /// `button::motion::RadiusPaddingMotion::retarget`: unseeded snaps, a
789    /// within-[`RETARGET_TOLERANCE`] target is ignored, anything else springs
790    /// from the value being painted *right now* (mid-flight continuity).
791    fn retarget(&mut self, radii: CornerRadii, padding: ContentPadding) -> bool {
792        if !self.seeded {
793            self.snap_to(radii, padding);
794            return false;
795        }
796        if corners_match(self.to_radii, radii) && self.to_padding.matches(padding) {
797            return false;
798        }
799        self.from_radii = self.radii();
800        self.from_padding = self.padding();
801        self.to_radii = radii;
802        self.to_padding = padding;
803        self.anim = AnimationController::new(ANIM_PERIOD);
804        self.anim.fling(FLING_VELOCITY, SHAPE_SPRING);
805        true
806    }
807
808    /// Pin both channels to `radii`/`padding` with no motion at all,
809    /// marking the morph seeded.
810    fn snap_to(&mut self, radii: CornerRadii, padding: ContentPadding) {
811        self.from_radii = radii;
812        self.to_radii = radii;
813        self.from_padding = padding;
814        self.to_padding = padding;
815        self.anim = AnimationController::new(ANIM_PERIOD);
816        self.seeded = true;
817    }
818
819    /// Advance the spring to frame time `now`, returning whether it is still
820    /// animating.
821    fn advance(&mut self, now: FrameTime) -> bool {
822        self.anim.advance(now)
823    }
824
825    /// Whether a first target has been seeded.
826    fn is_seeded(&self) -> bool {
827        self.seeded
828    }
829
830    /// The progress factor this frame paints at.
831    fn factor(&self) -> f64 {
832        let raw = self.anim.value();
833        if raw.is_finite() {
834            raw.clamp(0.0, OVERSHOOT_LIMIT)
835        } else {
836            0.0
837        }
838    }
839
840    /// The per-corner radii to paint this frame.
841    fn radii(&self) -> CornerRadii {
842        lerp_corners(self.from_radii, self.to_radii, self.factor())
843    }
844
845    /// The content padding to lay out with this frame.
846    fn padding(&self) -> ContentPadding {
847        self.from_padding.lerp(self.to_padding, self.factor())
848    }
849
850    /// The radii the morph is currently springing *toward*.
851    #[cfg(test)]
852    fn target_radii(&self) -> CornerRadii {
853        self.to_radii
854    }
855}
856
857// ---- The label run (sibling of button::core::LabelRun) --------------------
858
859/// The toggle button's own lazily-shaped label run, re-brushed at paint
860/// time — a sibling of `button::core::LabelRun` (private to that module) for
861/// the same reason as [`elevation_dp`].
862///
863/// **Shaped at its natural width and nothing else**, which is where it parts
864/// company with that sibling: the plain button fits its label to the room
865/// left over (`Flexible` + `overflow: ellipsis`,
866/// `m3e_button_state.dart:28`-`:35`), while a toggle button's content row is
867/// laid out unbounded and *clipped* by a box too small for it
868/// (`FittedBox(fit: BoxFit.none, clipBehavior: Clip.hardEdge)`,
869/// `m3e_toggle_button_content.dart:103`-`:110`). So this run carries no
870/// width-keyed cache and no ellipsis path at all — see the [module docs](self)'
871/// clipping section.
872struct LabelRun {
873    content: String,
874    layout: Option<TextLayout>,
875    shaped_for: Option<TextStyle>,
876}
877
878impl LabelRun {
879    fn new(content: String) -> Self {
880        Self {
881            content,
882            layout: None,
883            shaped_for: None,
884        }
885    }
886
887    fn set_content(&mut self, content: &str) {
888        if self.content != content {
889            self.content = content.to_string();
890            self.layout = None;
891            self.shaped_for = None;
892        }
893    }
894
895    fn content(&self) -> &str {
896        &self.content
897    }
898
899    /// Shape (or reuse) the run in `style` at its natural width, returning its
900    /// measured size. Nothing but the style can invalidate it.
901    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
902        if let Some(cached) = &self.layout
903            && self.shaped_for.as_ref() == Some(style)
904        {
905            return cached.size();
906        }
907        let text_ctx = ctx.text_context::<TextContext>();
908        let laid = text_ctx.layout(&self.content, style, None);
909        let size = laid.size();
910        self.layout = Some(laid);
911        self.shaped_for = Some(style.clone());
912        size
913    }
914
915    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
916        let Some(layout) = &self.layout else {
917            return;
918        };
919        for mut run in layout.to_scene_runs(origin) {
920            run.brush = Brush::Solid(color);
921            scene.draw_glyph_run(run);
922        }
923    }
924}
925
926// ---- Geometry helpers -------------------------------------------------------
927
928/// Whether local point `pos` is inside a widget of `size`.
929fn inside(pos: Point, size: Size) -> bool {
930    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
931}
932
933/// Per-corner outer/inner split for a connected-group member — outer on the
934/// group-facing side (first gets outer on the left, last on the right),
935/// inner on the adjacent side. Mirrors [`crate::button_group::member_radii`]'s
936/// left/right split, generalized to independently-set first/last flags.
937fn horizontal_radii(is_first: bool, is_last: bool, outer: f64, inner: f64) -> CornerRadii {
938    let left = if is_first { outer } else { inner };
939    let right = if is_last { outer } else { inner };
940    CornerRadii::new(left, right, right, left)
941}
942
943/// The rounded rect to *stroke* for a `width`-wide hairline lying fully
944/// inside a container of `size` with per-corner `radii` — a stroke is
945/// centered on its path, so both the rect and every radius pull in by half
946/// the width (the per-corner generalization of [`mod@crate::button`]'s
947/// `inset_stroke_rect`).
948fn inset_stroke_rrect(size: Size, radii: CornerRadii, width: f64) -> RoundedRect {
949    let half = width / 2.0;
950    let r = RoundedRectRadii::new(
951        (radii.top_left - half).max(0.0),
952        (radii.top_right - half).max(0.0),
953        (radii.bottom_right - half).max(0.0),
954        (radii.bottom_left - half).max(0.0),
955    );
956    RoundedRect::from_rect(
957        Rect::new(half, half, size.width - half, size.height - half),
958        r,
959    )
960}
961
962// ---- The declarative view ---------------------------------------------------
963
964/// A view-held, typed checked-change callback (erased on build).
965type OnCheckedChange<State> = Rc<dyn Fn(&mut State, bool)>;
966/// A view-held, typed long-press callback (erased on build).
967type OnLongPress<State> = Rc<dyn Fn(&mut State)>;
968
969/// A declarative M3 Expressive toggle button. See the [module docs](self).
970pub struct ToggleButtonView<State: 'static> {
971    checked: bool,
972    on_checked_change: OnCheckedChange<State>,
973    variant: ButtonVariant,
974    size: ToggleButtonSize,
975    enabled: bool,
976    icon: Option<AnyView<State>>,
977    checked_icon: Option<AnyView<State>>,
978    label: Option<String>,
979    checked_label: Option<String>,
980    is_group_connected: bool,
981    is_first_in_group: bool,
982    is_last_in_group: bool,
983    haptic: HapticSignal,
984    on_long_press: Option<OnLongPress<State>>,
985}
986
987impl<State: 'static> ToggleButtonView<State> {
988    /// The icon this frame should show, given `checked`
989    /// (`_effectiveIcon`).
990    fn effective_icon_view(&self) -> Option<&AnyView<State>> {
991        if self.checked {
992            self.checked_icon.as_ref().or(self.icon.as_ref())
993        } else {
994            self.icon.as_ref()
995        }
996    }
997
998    /// The label text this frame should show, given `checked`
999    /// (`_effectiveLabel`).
1000    fn effective_label_text(&self) -> Option<&str> {
1001        if self.checked {
1002            self.checked_label.as_deref().or(self.label.as_deref())
1003        } else {
1004            self.label.as_deref()
1005        }
1006    }
1007}
1008
1009/// Create a filled (highest-emphasis) toggle button reporting the requested
1010/// checked value through `on_checked_change` on release inside its bounds —
1011/// the reference's default `M3EToggleButton`/`M3EToggleButton.filled`.
1012///
1013/// A controlled component (see the [module docs](self)): `checked` never
1014/// self-mutates. Chain [`ToggleButtonView::icon`]/[`ToggleButtonView::label`]
1015/// (and their `checked_*` siblings) to add content, and
1016/// [`ToggleButtonView::size`]/[`ToggleButtonView::variant`] for the other
1017/// tiers.
1018pub fn toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1019    checked: bool,
1020    on_checked_change: F,
1021) -> ToggleButtonView<State> {
1022    ToggleButtonView {
1023        checked,
1024        on_checked_change: Rc::new(on_checked_change),
1025        variant: ButtonVariant::default(),
1026        size: ToggleButtonSize::default(),
1027        enabled: true,
1028        icon: None,
1029        checked_icon: None,
1030        label: None,
1031        checked_label: None,
1032        is_group_connected: false,
1033        is_first_in_group: true,
1034        is_last_in_group: true,
1035        haptic: HapticSignal::None,
1036        on_long_press: None,
1037    }
1038}
1039
1040/// PascalCase alias for [`toggle_button`], matching this catalog's view-fn
1041/// vocabulary (`Button`, `ButtonGroup`, …).
1042#[allow(non_snake_case)]
1043pub fn ToggleButton<State: 'static, F: Fn(&mut State, bool) + 'static>(
1044    checked: bool,
1045    on_checked_change: F,
1046) -> ToggleButtonView<State> {
1047    toggle_button(checked, on_checked_change)
1048}
1049
1050/// Create a filled toggle button — [`toggle_button`] under the name the
1051/// reference's `M3EToggleButton.filled` constructor uses.
1052pub fn filled_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1053    checked: bool,
1054    on_checked_change: F,
1055) -> ToggleButtonView<State> {
1056    toggle_button(checked, on_checked_change)
1057}
1058
1059/// Create a tonal toggle button — `M3EToggleButton.tonal`.
1060pub fn tonal_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1061    checked: bool,
1062    on_checked_change: F,
1063) -> ToggleButtonView<State> {
1064    toggle_button(checked, on_checked_change).variant(ButtonVariant::Tonal)
1065}
1066
1067/// Create an elevated toggle button — `M3EToggleButton.elevated`.
1068pub fn elevated_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1069    checked: bool,
1070    on_checked_change: F,
1071) -> ToggleButtonView<State> {
1072    toggle_button(checked, on_checked_change).variant(ButtonVariant::Elevated)
1073}
1074
1075/// Create an outlined toggle button — `M3EToggleButton.outlined`.
1076pub fn outlined_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1077    checked: bool,
1078    on_checked_change: F,
1079) -> ToggleButtonView<State> {
1080    toggle_button(checked, on_checked_change).variant(ButtonVariant::Outlined)
1081}
1082
1083/// Create a text toggle button — `M3EToggleButton.text`.
1084pub fn text_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1085    checked: bool,
1086    on_checked_change: F,
1087) -> ToggleButtonView<State> {
1088    toggle_button(checked, on_checked_change).variant(ButtonVariant::Text)
1089}
1090
1091impl<State: 'static> ToggleButtonView<State> {
1092    /// Set the container treatment (the [module docs](self)' Variants
1093    /// table).
1094    pub fn variant(mut self, variant: ButtonVariant) -> Self {
1095        self.variant = variant;
1096        self
1097    }
1098
1099    /// Set the size tier (the [module docs](self)' Sizes table).
1100    pub fn size(mut self, size: ToggleButtonSize) -> Self {
1101        self.size = size;
1102        self
1103    }
1104
1105    /// Whether the toggle button accepts a press. A disabled button paints
1106    /// the 38%/12% disabled roles and reports disabled semantics.
1107    pub fn enabled(mut self, enabled: bool) -> Self {
1108        self.enabled = enabled;
1109        self
1110    }
1111
1112    /// Icon shown while unchecked (and while checked too, if
1113    /// [`Self::checked_icon`] is unset).
1114    pub fn icon(mut self, icon: AnyView<State>) -> Self {
1115        self.icon = Some(icon);
1116        self
1117    }
1118
1119    /// Icon shown while checked. Falls back to [`Self::icon`] when unset.
1120    pub fn checked_icon(mut self, icon: AnyView<State>) -> Self {
1121        self.checked_icon = Some(icon);
1122        self
1123    }
1124
1125    /// Label shown while unchecked (and while checked too, if
1126    /// [`Self::checked_label`] is unset).
1127    pub fn label(mut self, label: impl Into<String>) -> Self {
1128        self.label = Some(label.into());
1129        self
1130    }
1131
1132    /// Label shown while checked. Falls back to [`Self::label`] when unset.
1133    pub fn checked_label(mut self, label: impl Into<String>) -> Self {
1134        self.checked_label = Some(label.into());
1135        self
1136    }
1137
1138    /// Whether this button is part of a connected group — the [module
1139    /// docs](self)' Group-connection corner asymmetry section. Plumbing for
1140    /// the upcoming button-group rework.
1141    pub fn group_connected(mut self, connected: bool) -> Self {
1142        self.is_group_connected = connected;
1143        self
1144    }
1145
1146    /// Whether this is the first (leading) member of a connected group —
1147    /// only consulted while [`Self::group_connected`] is `true`. Defaults to
1148    /// `true` (a standalone button behaves as if it were both ends).
1149    pub fn first_in_group(mut self, first: bool) -> Self {
1150        self.is_first_in_group = first;
1151        self
1152    }
1153
1154    /// Whether this is the last (trailing) member of a connected group —
1155    /// only consulted while [`Self::group_connected`] is `true`. Defaults to
1156    /// `true`.
1157    pub fn last_in_group(mut self, last: bool) -> Self {
1158        self.is_last_in_group = last;
1159        self
1160    }
1161
1162    /// The haptic signal to fire on a confirmed checked-toggle. Defaults to
1163    /// [`HapticSignal::None`] (the [module docs](self)' Haptics section).
1164    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
1165        self.haptic = haptic;
1166        self
1167    }
1168
1169    /// Fire `on_long_press` when a press is held past the threshold — the
1170    /// [module docs](self)' Long-press section documents the exact firing
1171    /// contract (deferred to the next pointer event, not immediate).
1172    pub fn on_long_press<F: Fn(&mut State) + 'static>(mut self, on_long_press: F) -> Self {
1173        self.on_long_press = Some(Rc::new(on_long_press));
1174        self
1175    }
1176}
1177
1178// ---- The retained widget -----------------------------------------------------
1179
1180/// Latched long-press timing state — see the [module docs](self)' Long-press
1181/// section.
1182#[derive(Debug, Default, Clone, Copy)]
1183struct LongPressState {
1184    /// The frame time the capture started, seeded on the first `paint` after
1185    /// a `Down`.
1186    press_start: Option<FrameTime>,
1187    /// Whether the hold has crossed [`LONG_PRESS_MS`] — latched in `paint`,
1188    /// the only pass with a clock.
1189    elapsed: bool,
1190    /// Whether the callback has already fired for this press (fire-exactly-
1191    /// once).
1192    fired: bool,
1193}
1194
1195impl LongPressState {
1196    fn clear(&mut self) {
1197        *self = Self::default();
1198    }
1199}
1200
1201/// The press duration after which a held toggle-button press becomes a
1202/// long-press. **Community-approximate** — see the [module docs](self)'
1203/// Long-press section for the same citation `frust_widgets::gesture`'s
1204/// `GestureDetector` uses.
1205const LONG_PRESS_MS: f64 = 500.0;
1206
1207/// The retained widget for a [`ToggleButtonView`]. See the [module
1208/// docs](self).
1209pub struct ToggleButtonWidget {
1210    variant: ButtonVariant,
1211    size: ToggleButtonSize,
1212    enabled: bool,
1213    /// The app-confirmed checked value (controlled — never self-mutated).
1214    checked: bool,
1215    /// The currently-effective label run (already resolved for `checked`).
1216    label: LabelRun,
1217    label_origin: Point,
1218    /// Whether the last layout's content row is wider than the box it was
1219    /// constrained into, so `paint` must clip it (`Clip.hardEdge`).
1220    content_clipped: bool,
1221    icon: Option<ChildPod>,
1222    is_group_connected: bool,
1223    is_first_in_group: bool,
1224    is_last_in_group: bool,
1225    haptic: HapticSignal,
1226    /// Hover/focus/pressed tracking feeding the state-layer overlay and the
1227    /// morph's target radii.
1228    state: InteractionState,
1229    /// Whether this widget holds the pointer capture a `Down` took.
1230    captured: bool,
1231    /// Where the capturing `Down` landed, in local coordinates — the
1232    /// long-press slop reference point.
1233    down_pos: Point,
1234    /// The checked-shape + padding press morph.
1235    motion: ShapeMotion,
1236    long_press: LongPressState,
1237    on_checked_change: frust::authoring::ErasedArgCallback<bool>,
1238    on_long_press: Option<frust::authoring::ErasedCallback>,
1239}
1240
1241impl ToggleButtonWidget {
1242    /// Whether the widget currently has an effective label (drives the
1243    /// halved-padding rule, `_hasLabel`).
1244    fn has_label(&self) -> bool {
1245        !self.label.content().is_empty()
1246    }
1247
1248    /// The horizontal content padding for the current label presence —
1249    /// `_buildWidget`'s `hPad = _hasLabel ? m.hPadding : m.hPadding / 2`.
1250    fn h_padding(&self, metrics: &SizeMetrics) -> f64 {
1251        if self.has_label() {
1252            metrics.h_padding
1253        } else {
1254            metrics.h_padding / 2.0
1255        }
1256    }
1257
1258    /// This frame's target corner radii, dispatching to the standalone or
1259    /// connected resolver. See the [module docs](self)' Checked shape morph
1260    /// and Group-connection sections.
1261    fn target_radii(&self, theme: Option<&Theme>, height: f64) -> CornerRadii {
1262        let pill = height / 2.0;
1263        if self.is_group_connected {
1264            self.connected_target_radii(theme, pill)
1265        } else {
1266            self.standalone_target_radii(theme, pill)
1267        }
1268    }
1269
1270    /// `m3e_toggle_button_shape.dart`'s `_resolveStandaloneTargetRadius`:
1271    /// `pressed > hovered > checked > rest` precedence, uniform across all
1272    /// four corners.
1273    fn standalone_target_radii(&self, theme: Option<&Theme>, pill: f64) -> CornerRadii {
1274        if self.enabled && self.state.pressed {
1275            return CornerRadii::uniform(self.size.pressed_radius(theme));
1276        }
1277        if self.enabled && self.state.hovered {
1278            return CornerRadii::uniform(self.size.hovered_radius());
1279        }
1280        if self.checked {
1281            return CornerRadii::uniform(self.size.square_radius(theme));
1282        }
1283        CornerRadii::uniform(pill)
1284    }
1285
1286    /// `m3e_toggle_button_shape.dart`'s `_resolveConnectedTargetRadius`:
1287    /// outer corners (group-facing) stay pinned at `pill` in every state;
1288    /// inner corners resolve per state, except a checked-and-otherwise-idle
1289    /// member which goes fully round on *every* corner
1290    /// (`checkedConnectedShape`).
1291    /// `theme` is unused today: every branch here reads either a plain
1292    /// constant or [`ToggleButtonSize::hovered_radius`] (itself literal-only,
1293    /// like the plain button's own table). Kept in the signature — matching
1294    /// [`Self::standalone_target_radii`]'s — as the seam a themeable
1295    /// `M3EToggleButtonGroupTheme` extension will read from once the
1296    /// button-group rework lands (see [`CONNECTED_INNER_RADIUS`]'s doc).
1297    fn connected_target_radii(&self, _theme: Option<&Theme>, outer: f64) -> CornerRadii {
1298        if self.enabled && self.state.pressed {
1299            return horizontal_radii(
1300                self.is_first_in_group,
1301                self.is_last_in_group,
1302                outer,
1303                CONNECTED_PRESSED_INNER_RADIUS,
1304            );
1305        }
1306        if self.enabled && self.state.hovered {
1307            return horizontal_radii(
1308                self.is_first_in_group,
1309                self.is_last_in_group,
1310                outer,
1311                self.size.hovered_radius(),
1312            );
1313        }
1314        if self.checked {
1315            return CornerRadii::uniform(outer);
1316        }
1317        horizontal_radii(
1318            self.is_first_in_group,
1319            self.is_last_in_group,
1320            outer,
1321            CONNECTED_INNER_RADIUS,
1322        )
1323    }
1324}
1325
1326impl<State: 'static> View<State> for ToggleButtonView<State> {
1327    type Element = ToggleButtonWidget;
1328
1329    fn build(&self, ctx: &mut BuildCtx<'_>) -> ToggleButtonWidget {
1330        let icon = self
1331            .effective_icon_view()
1332            .map(|icon| frust::authoring::build_child(icon, ctx));
1333        let mut label = LabelRun::new(String::new());
1334        if let Some(text) = self.effective_label_text() {
1335            label.set_content(text);
1336        }
1337        ToggleButtonWidget {
1338            variant: self.variant,
1339            size: self.size,
1340            enabled: self.enabled,
1341            checked: self.checked,
1342            label,
1343            label_origin: Point::ZERO,
1344            content_clipped: false,
1345            icon,
1346            is_group_connected: self.is_group_connected,
1347            is_first_in_group: self.is_first_in_group,
1348            is_last_in_group: self.is_last_in_group,
1349            haptic: self.haptic,
1350            state: InteractionState::new(),
1351            captured: false,
1352            down_pos: Point::ZERO,
1353            motion: ShapeMotion::new(),
1354            long_press: LongPressState::default(),
1355            on_checked_change: frust::authoring::erase_callback_arg(&self.on_checked_change),
1356            on_long_press: self
1357                .on_long_press
1358                .as_ref()
1359                .map(frust::authoring::erase_callback),
1360        }
1361    }
1362
1363    fn rebuild(
1364        &self,
1365        prev: &Self,
1366        element: &mut ToggleButtonWidget,
1367        ctx: &mut BuildCtx<'_>,
1368    ) -> ChangeFlags {
1369        element.on_checked_change = frust::authoring::erase_callback_arg(&self.on_checked_change);
1370        element.on_long_press = self
1371            .on_long_press
1372            .as_ref()
1373            .map(frust::authoring::erase_callback);
1374        element.haptic = self.haptic;
1375        let mut flags = ChangeFlags::NONE;
1376
1377        if prev.checked != self.checked {
1378            element.checked = self.checked;
1379            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1380        }
1381        if prev.variant != self.variant {
1382            element.variant = self.variant;
1383            flags |= ChangeFlags::PAINT;
1384        }
1385        if prev.size != self.size {
1386            element.size = self.size;
1387            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1388        }
1389        if prev.enabled != self.enabled {
1390            element.enabled = self.enabled;
1391            if !self.enabled {
1392                // A button disabled mid-press keeps neither the press, the
1393                // capture, nor a pending long-press candidacy.
1394                element.state.set_pressed(false);
1395                element.captured = false;
1396                element.long_press.clear();
1397            }
1398            flags |= ChangeFlags::PAINT;
1399        }
1400        if prev.is_group_connected != self.is_group_connected {
1401            element.is_group_connected = self.is_group_connected;
1402            flags |= ChangeFlags::PAINT;
1403        }
1404        if prev.is_first_in_group != self.is_first_in_group {
1405            element.is_first_in_group = self.is_first_in_group;
1406            flags |= ChangeFlags::PAINT;
1407        }
1408        if prev.is_last_in_group != self.is_last_in_group {
1409            element.is_last_in_group = self.is_last_in_group;
1410            flags |= ChangeFlags::PAINT;
1411        }
1412
1413        let prev_label = prev.effective_label_text();
1414        let next_label = self.effective_label_text();
1415        if prev_label != next_label {
1416            element.label.set_content(next_label.unwrap_or(""));
1417            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1418        }
1419
1420        match (prev.effective_icon_view(), self.effective_icon_view()) {
1421            (None, None) => {}
1422            (Some(p), Some(n)) => {
1423                let pod = element.icon.as_mut().expect("icon pod present");
1424                flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
1425            }
1426            (None, Some(n)) => {
1427                element.icon = Some(frust::authoring::build_child(n, ctx));
1428                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1429            }
1430            (Some(p), None) => {
1431                let mut pod = element.icon.take().expect("icon pod present");
1432                frust::authoring::teardown_child(p, &mut pod, ctx);
1433                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1434            }
1435        }
1436
1437        flags
1438    }
1439
1440    fn teardown(&self, element: &mut ToggleButtonWidget, ctx: &mut BuildCtx<'_>) {
1441        if let (Some(icon_view), Some(pod)) = (self.effective_icon_view(), element.icon.as_mut()) {
1442            frust::authoring::teardown_child(icon_view, pod, ctx);
1443        }
1444    }
1445}
1446
1447impl Widget for ToggleButtonWidget {
1448    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1449        let metrics = self.size.metrics();
1450        let (label_style, h_padding) = {
1451            let theme = Theme::from_layout_ctx(ctx);
1452            (self.size.label_style(theme), self.h_padding(&metrics))
1453        };
1454        if !self.motion.is_seeded() {
1455            // The mount frame snaps; only a later target change springs.
1456            let theme = Theme::from_layout_ctx(ctx);
1457            let resting = self.target_radii(theme, metrics.height);
1458            self.motion
1459                .snap_to(resting, ContentPadding::symmetric(h_padding));
1460        }
1461        // The padding target is a pure function of `(self.size, has_label)`
1462        // — unlike the radius channel, which also depends on interaction
1463        // state only `paint` tracks — so `layout` resolves it directly
1464        // (`h_padding`, just above) rather than reading it back off the
1465        // morph. Reading `self.motion.padding()` here would lag a runtime
1466        // size or label-presence change by a whole pass: `layout` always
1467        // runs before `paint` in a frame, and `paint` below is the only
1468        // place that ever calls `retarget` with the new padding — its bare
1469        // `request_frame` (never `request_layout`) leaves the stale width
1470        // standing until an unrelated layout dirty happens along (the exact
1471        // hazard `crate::button::core`'s identical fix names). Both props
1472        // that move `h_padding` already raise `ChangeFlags::LAYOUT` on
1473        // `rebuild` (`size`, and the label text whose emptiness
1474        // `has_label` reads), so the value computed here is always
1475        // immediately correct, with no in-flight frame that would need its
1476        // own `request_layout`.
1477        let padding = ContentPadding::symmetric(h_padding);
1478
1479        let icon_size = self.icon.as_mut().map(|pod| {
1480            pod.layout_child(
1481                ctx,
1482                &BoxConstraints::tight(Size::new(metrics.icon_size, metrics.icon_size)),
1483            )
1484        });
1485        let icon_width = icon_size.map_or(0.0, |s| s.width);
1486        let has_label = self.has_label();
1487        // The gap exists only between an icon and a label.
1488        let gap = if icon_size.is_some() && has_label {
1489            metrics.icon_gap
1490        } else {
1491            0.0
1492        };
1493
1494        // The label is shaped once, at its natural width, whatever the
1495        // incoming constraint: a box too small for the content covers it
1496        // rather than re-fitting it (see the [module docs](self)' clipping
1497        // section, and `paint`, which applies that clip).
1498        let label_size = self.label.shape(ctx, &label_style);
1499
1500        let content_width = icon_width + gap + label_size.width;
1501        // No minimum-width floor — see the [module docs](self)' Sizes
1502        // section for why this differs from the plain button.
1503        let width = content_width + padding.left + padding.right;
1504        let size = bc.constrain(Size::new(width, metrics.height));
1505
1506        // The content row is centered as a whole, icon always leading (a
1507        // toggle button has no `IconAlignment` concept). Content wider than
1508        // the box centers too — `FittedBox`'s default `Alignment.center`, so
1509        // the overflow is split evenly and clipped off both ends rather than
1510        // pinned to the leading edge.
1511        let content_x = (size.width - content_width) / 2.0;
1512        self.content_clipped = content_width > size.width;
1513        let icon_x = content_x;
1514        let label_x = content_x + icon_width + gap;
1515        if let Some(pod) = self.icon.as_mut() {
1516            let icon_height = icon_size.map_or(0.0, |s| s.height);
1517            pod.set_origin(Point::new(icon_x, (size.height - icon_height) / 2.0));
1518        }
1519        self.label_origin = Point::new(label_x, (size.height - label_size.height) / 2.0);
1520
1521        size
1522    }
1523
1524    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1525        let origin = ctx.origin();
1526        let size = ctx.size();
1527
1528        // Authoritative hover/focus reads, self-correcting the latched flags
1529        // (`docs/CODE_STANDARDS.md`'s Interaction Semantics); both inert
1530        // while disabled, mirroring the reference's `effectivelyEnabled`
1531        // guards.
1532        self.state.set_hovered(self.enabled && ctx.is_hovered());
1533        self.state.set_focused(self.enabled && ctx.has_focus());
1534
1535        let (colors, shadow, radii_target, h_padding) = {
1536            let theme = Theme::from_paint_ctx(ctx);
1537            let dp = elevation_dp(
1538                self.variant,
1539                self.enabled,
1540                self.state.pressed,
1541                self.state.hovered,
1542            );
1543            (
1544                resolve_colors(theme, self.variant, self.checked, self.enabled),
1545                resolve_shadow(theme, dp),
1546                self.target_radii(theme, size.height),
1547                self.h_padding(&self.size.metrics()),
1548            )
1549        };
1550
1551        let padding = ContentPadding::symmetric(h_padding);
1552        if self.motion.retarget(radii_target, padding) {
1553            ctx.request_frame();
1554        }
1555        if self.motion.advance(ctx.frame_time()) {
1556            ctx.request_frame();
1557        }
1558        let radii = self.motion.radii();
1559
1560        if let Some((blur, y_offset, shadow_color)) = shadow {
1561            // `draw_shadow` takes one scalar radius — the per-corner lowering
1562            // documented on `frust_scene::CornerRadii::largest`.
1563            scene.draw_shadow(
1564                Point::new(origin.x, origin.y + y_offset),
1565                size,
1566                radii.largest(),
1567                blur,
1568                shadow_color,
1569            );
1570        }
1571
1572        if colors.container.components[3] > 0.0 {
1573            scene.fill_rounded_rect_radii(origin, size, radii, colors.container);
1574        }
1575
1576        // The M3E state layer: `dragged > pressed > focused > hovered`
1577        // precedence. Mirrors the plain button's own choice to keep the
1578        // pressed step despite the reference suppressing it for its own
1579        // `InkSparkle` ripple — this framework has no ripple engine, so the
1580        // state layer *is* the press feedback here too.
1581        if self.enabled {
1582            let opacity = self.state.resolve_opacity();
1583            if opacity > 0.0 {
1584                scene.fill_rounded_rect_radii(
1585                    origin,
1586                    size,
1587                    radii,
1588                    with_alpha(colors.content, opacity),
1589                );
1590            }
1591        }
1592
1593        if let Some(outline) = colors.outline {
1594            let path = inset_stroke_rrect(size, radii, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1595            scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1596        }
1597
1598        // A content row wider than the box is clipped to it, never re-fitted
1599        // — `FittedBox`'s `Clip.hardEdge`. Pushed only when it actually
1600        // overflows: a clip layer is not free, and the common case does not
1601        // need one.
1602        if self.content_clipped {
1603            scene.push_clip(origin, size);
1604        }
1605        if let Some(pod) = self.icon.as_mut() {
1606            pod.paint_child(ctx, scene);
1607        }
1608        self.label.paint(
1609            Point::new(
1610                origin.x + self.label_origin.x,
1611                origin.y + self.label_origin.y,
1612            ),
1613            colors.content,
1614            scene,
1615        );
1616        if self.content_clipped {
1617            scene.pop_clip();
1618        }
1619
1620        // Long-press threshold latch — `paint` is the only pass with a
1621        // clock. See the [module docs](self)' Long-press section.
1622        if self.enabled && self.captured && self.on_long_press.is_some() && !self.long_press.elapsed
1623        {
1624            let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
1625            let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
1626            if elapsed_ms >= LONG_PRESS_MS {
1627                self.long_press.elapsed = true;
1628            } else {
1629                ctx.request_frame();
1630            }
1631        }
1632    }
1633
1634    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1635        if !self.enabled {
1636            return EventResult::Ignored;
1637        }
1638        let InputEvent::Pointer(p) = event else {
1639            return EventResult::Ignored;
1640        };
1641        match p.phase {
1642            PointerPhase::Down => {
1643                if !presses(p) {
1644                    return EventResult::Ignored;
1645                }
1646                self.state.set_pressed(true);
1647                self.captured = true;
1648                self.down_pos = p.position;
1649                self.long_press.clear();
1650                ctx.capture_pointer();
1651                ctx.request_redraw();
1652                EventResult::Handled
1653            }
1654            PointerPhase::Move => {
1655                let over = inside(p.position, ctx.size());
1656                if !self.captured {
1657                    // The hover pass: claim, latch, and let paint
1658                    // self-correct.
1659                    if over {
1660                        ctx.claim_hover();
1661                        ctx.set_cursor(CursorIcon::Pointer);
1662                    }
1663                    if self.state.set_hovered(over) {
1664                        ctx.request_redraw();
1665                    }
1666                    return EventResult::Ignored;
1667                }
1668                ctx.set_cursor(CursorIcon::Pointer);
1669                if self.state.set_pressed(over) {
1670                    ctx.request_redraw();
1671                }
1672                // Long-press slop check + fire-on-move-arrival (the [module
1673                // docs](self)' Long-press section).
1674                if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
1675                    self.long_press.clear();
1676                } else if self.long_press.elapsed && !self.long_press.fired {
1677                    self.long_press.fired = true;
1678                    if let Some(cb) = self.on_long_press.as_mut() {
1679                        cb(ctx);
1680                    }
1681                    ctx.request_redraw();
1682                }
1683                EventResult::Handled
1684            }
1685            PointerPhase::Up => {
1686                if !self.captured {
1687                    return EventResult::Ignored;
1688                }
1689                // A held-past-threshold press resolves as a long-press,
1690                // never also as a checked toggle — mutually exclusive
1691                // outcomes of the same gesture (the [module docs](self)'
1692                // Long-press section). `elapsed` alone gates the toggle
1693                // (not `elapsed && !fired`): a press already resolved as a
1694                // long-press via a Move-arrival fire above must not *also*
1695                // toggle here just because it already fired once.
1696                if self.long_press.elapsed {
1697                    if !self.long_press.fired {
1698                        self.long_press.fired = true;
1699                        if let Some(cb) = self.on_long_press.as_mut() {
1700                            cb(ctx);
1701                        }
1702                    }
1703                } else if inside(p.position, ctx.size()) {
1704                    if self.haptic != HapticSignal::None {
1705                        MaterialHaptics::fire(self.haptic);
1706                    }
1707                    // Report the *requested* value; never self-toggle.
1708                    let requested = !self.checked;
1709                    (self.on_checked_change)(ctx, requested);
1710                }
1711                self.state.set_pressed(false);
1712                self.captured = false;
1713                self.long_press.clear();
1714                ctx.request_redraw();
1715                EventResult::Handled
1716            }
1717            PointerPhase::Cancel => {
1718                if !self.captured {
1719                    return EventResult::Ignored;
1720                }
1721                self.state.set_pressed(false);
1722                self.captured = false;
1723                self.long_press.clear();
1724                ctx.request_redraw();
1725                EventResult::Handled
1726            }
1727        }
1728    }
1729
1730    fn semantics(&self, ctx: &mut SemanticsCtx) {
1731        // A toggle button reports as a plain `Button` carrying a `Toggled`
1732        // state (the ARIA `role="button" aria-pressed`/accesskit shape) —
1733        // accesskit has no dedicated "toggle button" role. The icon child is
1734        // not forwarded (decoration only), mirroring the plain button.
1735        ctx.push_node(Role::Button, |node| {
1736            if !self.label.content().is_empty() {
1737                node.set_label(self.label.content());
1738            }
1739            node.set_toggled(Toggled::from(self.checked));
1740            if self.enabled {
1741                node.add_action(Action::Click);
1742            } else {
1743                node.set_disabled();
1744            }
1745        });
1746    }
1747
1748    frust::authoring::visit_children!(icon);
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753    use super::*;
1754    use frust::authoring::scene::GlyphRun;
1755    use frust::authoring::{PointerButton, PointerEvent};
1756    use frust_widgets::test_support::leaf_any;
1757    use std::any::Any;
1758
1759    // ---- fixtures ---------------------------------------------------------
1760
1761    fn ft_secs(s: f64) -> FrameTime {
1762        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1763    }
1764
1765    /// Build a widget from a view over any state type.
1766    fn build<S: 'static>(view: &ToggleButtonView<S>) -> ToggleButtonWidget {
1767        let mut counter = 0u64;
1768        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1769    }
1770
1771    /// Advance the widget's shape morph past its settling point.
1772    fn settle_morph(widget: &mut ToggleButtonWidget) {
1773        let mut t = 0.0;
1774        for _ in 0..600 {
1775            t += 1.0 / 60.0;
1776            if !widget.motion.advance(ft_secs(t)) {
1777                return;
1778            }
1779        }
1780        panic!("the shape morph never settled");
1781    }
1782
1783    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1784        InputEvent::Pointer(PointerEvent {
1785            phase,
1786            position: Point::new(x, y),
1787            button: PointerButton::Primary,
1788        })
1789    }
1790
1791    fn dispatch<S: 'static>(
1792        widget: &mut ToggleButtonWidget,
1793        state: &mut S,
1794        size: Size,
1795        event: &InputEvent,
1796    ) {
1797        let state_any: &mut dyn Any = state;
1798        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1799        widget.event(&mut ctx, event);
1800    }
1801
1802    #[derive(Default)]
1803    struct Recorder {
1804        rrects: Vec<(Point, Size, CornerRadii, Color)>,
1805        strokes: Vec<(Point, f64, Color)>,
1806        runs: Vec<(Point, Color)>,
1807        /// Glyphs per painted run, in paint order — the only paint-side
1808        /// window onto how much of a label was shaped (`GlyphRun` carries
1809        /// resolved glyph ids, never the text).
1810        run_glyphs: Vec<usize>,
1811        /// `(origin, size)` of every clip pushed.
1812        clips: Vec<(Point, Size)>,
1813    }
1814
1815    impl PaintScene for Recorder {
1816        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1817        fn draw_text(&mut self, _o: Point, _t: &str) {}
1818        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1819            self.rrects.push((o, s, radii, color));
1820        }
1821        fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
1822            self.strokes.push((o, width, solid(brush)));
1823        }
1824        fn push_clip(&mut self, o: Point, s: Size) {
1825            self.clips.push((o, s));
1826        }
1827        fn draw_glyph_run(&mut self, run: GlyphRun) {
1828            let t = run.transform.translation();
1829            self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
1830            self.run_glyphs.push(run.glyphs.len());
1831        }
1832    }
1833
1834    fn solid(brush: &Brush) -> Color {
1835        match brush {
1836            Brush::Solid(c) => *c,
1837            _ => Color::TRANSPARENT,
1838        }
1839    }
1840
1841    fn layout_with(widget: &mut ToggleButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
1842        let mut tcx = TextContext::new();
1843        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1844        if let Some(theme) = theme {
1845            lctx = lctx.with_theme(theme as &dyn Any);
1846        }
1847        widget.layout(
1848            &mut lctx,
1849            &BoxConstraints::loose(Size::new(max_width, 500.0)),
1850        )
1851    }
1852
1853    /// Lay the widget out under the tight-main/loose-cross constraint a
1854    /// button group hands a member (`BoxConstraints.tightFor(width:)`,
1855    /// `m3e_toggle_button_group_render.dart:319`).
1856    fn layout_tight_main(widget: &mut ToggleButtonWidget, main: f64) -> Size {
1857        let mut tcx = TextContext::new();
1858        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1859        widget.layout(
1860            &mut lctx,
1861            &BoxConstraints::new(Size::new(main, 0.0), Size::new(main, 500.0)),
1862        )
1863    }
1864
1865    fn paint_at(widget: &mut ToggleButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
1866        let mut rec = Recorder::default();
1867        let mut pctx = PaintCtx::new(Point::ZERO, size);
1868        if let Some(theme) = theme {
1869            pctx = pctx.with_theme(theme as &dyn Any);
1870        }
1871        widget.paint(&mut pctx, &mut rec);
1872        rec
1873    }
1874
1875    const ALL_SIZES: [ToggleButtonSize; 3] = [
1876        ToggleButtonSize::Sm,
1877        ToggleButtonSize::Md,
1878        ToggleButtonSize::Lg,
1879    ];
1880
1881    const ALL_VARIANTS: [ButtonVariant; 5] = [
1882        ButtonVariant::Filled,
1883        ButtonVariant::Outlined,
1884        ButtonVariant::Tonal,
1885        ButtonVariant::Elevated,
1886        ButtonVariant::Text,
1887    ];
1888
1889    // ---- Acceptance 1: metric + color tables, 5 variants x 3 sizes --------
1890
1891    #[test]
1892    fn measurements_match_the_plain_buttons_sm_md_lg_rows() {
1893        let expected: [(ToggleButtonSize, f64, f64, f64, f64); 3] = [
1894            (ToggleButtonSize::Sm, 40.0, 16.0, 20.0, 8.0),
1895            (ToggleButtonSize::Md, 56.0, 24.0, 24.0, 8.0),
1896            (ToggleButtonSize::Lg, 96.0, 48.0, 32.0, 12.0),
1897        ];
1898        for (size, height, h_padding, icon_size, icon_gap) in expected {
1899            assert_eq!(
1900                size.metrics(),
1901                SizeMetrics {
1902                    height,
1903                    h_padding,
1904                    icon_size,
1905                    icon_gap
1906                },
1907                "{size:?}"
1908            );
1909        }
1910    }
1911
1912    #[test]
1913    fn radius_tables_match_the_reference_themed_and_unthemed() {
1914        let theme = crate::baseline();
1915        let expected: [(ToggleButtonSize, f64, f64, f64); 3] = [
1916            (ToggleButtonSize::Sm, 12.0, 8.0, 10.0),
1917            (ToggleButtonSize::Md, 16.0, 12.0, 14.0),
1918            (ToggleButtonSize::Lg, 28.0, 16.0, 22.0),
1919        ];
1920        for (size, square, pressed, hovered) in expected {
1921            assert_eq!(size.square_radius(None), square, "{size:?} square unthemed");
1922            assert_eq!(
1923                size.square_radius(Some(&theme)),
1924                square,
1925                "{size:?} square themed"
1926            );
1927            assert_eq!(
1928                size.pressed_radius(None),
1929                pressed,
1930                "{size:?} pressed unthemed"
1931            );
1932            assert_eq!(
1933                size.pressed_radius(Some(&theme)),
1934                pressed,
1935                "{size:?} pressed themed"
1936            );
1937            assert_eq!(size.hovered_radius(), hovered, "{size:?} hovered");
1938        }
1939    }
1940
1941    #[test]
1942    fn label_roles_match_the_shared_base_button_state_switch() {
1943        let theme = crate::baseline();
1944        let expected = [
1945            (ToggleButtonSize::Sm, theme.type_scale.label_medium.clone()),
1946            (ToggleButtonSize::Md, theme.type_scale.label_large.clone()),
1947            (ToggleButtonSize::Lg, theme.type_scale.title_medium.clone()),
1948        ];
1949        for (size, role) in expected {
1950            let style = size.label_style(Some(&theme));
1951            assert_eq!(style.size, role.size, "{size:?} size");
1952            assert_eq!(style.weight, role.weight, "{size:?} weight");
1953        }
1954    }
1955
1956    #[test]
1957    fn enabled_colors_match_the_reference_variant_x_checked_table() {
1958        let theme = crate::baseline();
1959        let s = theme.scheme();
1960        // (variant, fg_unchecked, fg_checked, bg_unchecked, bg_checked, outline?)
1961        let expected: [(ButtonVariant, Color, Color, Color, Color, bool); 5] = [
1962            (
1963                ButtonVariant::Filled,
1964                s.on_surface_variant,
1965                s.on_primary,
1966                s.surface_container_highest,
1967                s.primary,
1968                false,
1969            ),
1970            (
1971                ButtonVariant::Outlined,
1972                s.on_surface,
1973                s.on_secondary_container,
1974                Color::TRANSPARENT,
1975                s.secondary_container,
1976                true,
1977            ),
1978            (
1979                ButtonVariant::Tonal,
1980                s.on_surface_variant,
1981                s.on_secondary_container,
1982                s.surface_container_highest,
1983                s.secondary_container,
1984                false,
1985            ),
1986            (
1987                ButtonVariant::Elevated,
1988                s.primary,
1989                s.on_primary,
1990                s.surface_container_low,
1991                s.primary,
1992                false,
1993            ),
1994            (
1995                ButtonVariant::Text,
1996                s.on_surface,
1997                s.primary,
1998                Color::TRANSPARENT,
1999                Color::TRANSPARENT,
2000                false,
2001            ),
2002        ];
2003        for (variant, fg_u, fg_c, bg_u, bg_c, has_outline) in expected {
2004            let unchecked = resolve_colors(Some(&theme), variant, false, true);
2005            let checked = resolve_colors(Some(&theme), variant, true, true);
2006            assert_eq!(unchecked.content, fg_u, "{variant:?} unchecked content");
2007            assert_eq!(unchecked.container, bg_u, "{variant:?} unchecked container");
2008            assert_eq!(checked.content, fg_c, "{variant:?} checked content");
2009            assert_eq!(checked.container, bg_c, "{variant:?} checked container");
2010            assert_eq!(
2011                unchecked.outline.is_some(),
2012                has_outline,
2013                "{variant:?} unchecked outline presence"
2014            );
2015            assert_eq!(
2016                checked.outline.is_some(),
2017                has_outline,
2018                "{variant:?} checked outline presence"
2019            );
2020            if has_outline {
2021                assert_eq!(unchecked.outline, Some(s.outline));
2022                assert_eq!(checked.outline, Some(s.outline));
2023            }
2024        }
2025    }
2026
2027    #[test]
2028    fn unthemed_colors_fall_back_to_the_baseline_light_roles() {
2029        let unchecked = resolve_colors(None, ButtonVariant::Filled, false, true);
2030        assert_eq!(unchecked.content, ON_SURFACE_VARIANT);
2031        assert_eq!(unchecked.container, SURFACE_CONTAINER_HIGHEST);
2032        let checked = resolve_colors(None, ButtonVariant::Filled, true, true);
2033        assert_eq!(checked.content, ON_PRIMARY);
2034        assert_eq!(checked.container, PRIMARY);
2035    }
2036
2037    /// The full 5 variants × 3 sizes × checked/unchecked matrix: every
2038    /// combination builds, lays out, and paints without panicking, with a
2039    /// finite, non-negative resting corner radius resolved for it — the
2040    /// acceptance criterion's "tables pinned" cross-product, exercised end to
2041    /// end rather than table-by-table.
2042    #[test]
2043    fn every_variant_x_size_x_checked_combination_builds_lays_out_and_paints() {
2044        let theme = crate::baseline();
2045        for variant in ALL_VARIANTS {
2046            for size in ALL_SIZES {
2047                for checked in [false, true] {
2048                    let view = toggle_button::<u32, _>(checked, |_, _| {})
2049                        .variant(variant)
2050                        .size(size)
2051                        .label("Toggle");
2052                    let mut widget = build(&view);
2053                    let laid = layout_with(&mut widget, 200.0, Some(&theme));
2054                    assert!(
2055                        laid.width.is_finite() && laid.width >= 0.0,
2056                        "{variant:?} {size:?} {checked}"
2057                    );
2058                    assert_eq!(laid.height, size.metrics().height);
2059                    let target = widget.target_radii(Some(&theme), laid.height);
2060                    for r in [
2061                        target.top_left,
2062                        target.top_right,
2063                        target.bottom_left,
2064                        target.bottom_right,
2065                    ] {
2066                        assert!(r.is_finite() && r >= 0.0, "{variant:?} {size:?} {checked}");
2067                    }
2068                    let _ = paint_at(&mut widget, laid, Some(&theme));
2069                }
2070            }
2071        }
2072    }
2073
2074    #[test]
2075    fn disabled_colors_are_the_38_and_12_percent_on_surface_roles_checked_independent() {
2076        let theme = crate::baseline();
2077        let on_surface = theme.scheme().on_surface;
2078        for variant in ALL_VARIANTS {
2079            for checked in [false, true] {
2080                let colors = resolve_colors(Some(&theme), variant, checked, false);
2081                assert_eq!(
2082                    colors.content,
2083                    with_alpha(on_surface, DISABLED_CONTENT_OPACITY),
2084                    "{variant:?} checked={checked} content"
2085                );
2086                if is_transparent(variant) {
2087                    assert_eq!(colors.container, Color::TRANSPARENT, "{variant:?}");
2088                } else {
2089                    assert_eq!(
2090                        colors.container,
2091                        with_alpha(on_surface, DISABLED_CONTAINER_OPACITY),
2092                        "{variant:?} checked={checked} container"
2093                    );
2094                }
2095            }
2096        }
2097    }
2098
2099    #[test]
2100    fn elevation_is_not_checked_aware_and_matches_the_plain_buttons_table() {
2101        for variant in ALL_VARIANTS {
2102            for pressed in [false, true] {
2103                for hovered in [false, true] {
2104                    let expected = match variant {
2105                        ButtonVariant::Elevated => {
2106                            if pressed {
2107                                0.0
2108                            } else if hovered {
2109                                3.0
2110                            } else {
2111                                1.0
2112                            }
2113                        }
2114                        ButtonVariant::Filled | ButtonVariant::Tonal => {
2115                            if !pressed && hovered {
2116                                1.0
2117                            } else {
2118                                0.0
2119                            }
2120                        }
2121                        ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
2122                    };
2123                    assert_eq!(elevation_dp(variant, true, pressed, hovered), expected);
2124                }
2125            }
2126        }
2127    }
2128
2129    // ---- Acceptance 2: checked shape morph ---------------------------------
2130
2131    #[test]
2132    fn shape_spring_matches_expressive_spatial_press() {
2133        let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
2134        assert_eq!(SHAPE_SPRING.stiffness, token.stiffness);
2135        assert_eq!(SHAPE_SPRING.damping_ratio, token.damping_ratio);
2136        assert_eq!(SHAPE_SPRING.mass, 1.0);
2137        assert_eq!(SHAPE_SPRING.stiffness, 380.0);
2138        assert_eq!(SHAPE_SPRING.damping_ratio, 0.55);
2139    }
2140
2141    #[test]
2142    fn the_first_target_snaps_rather_than_springing() {
2143        let mut motion = ShapeMotion::new();
2144        let pad = ContentPadding::symmetric(16.0);
2145        assert!(!motion.retarget(CornerRadii::uniform(20.0), pad));
2146        assert_eq!(motion.radii(), CornerRadii::uniform(20.0));
2147        assert!(!motion.advance(ft_secs(0.0)));
2148    }
2149
2150    #[test]
2151    fn a_target_inside_the_dead_band_is_ignored() {
2152        let mut motion = ShapeMotion::new();
2153        let pad = ContentPadding::symmetric(16.0);
2154        motion.retarget(CornerRadii::uniform(20.0), pad);
2155        assert!(!motion.retarget(CornerRadii::uniform(20.0 + RETARGET_TOLERANCE / 2.0), pad));
2156        assert_eq!(motion.target_radii(), CornerRadii::uniform(20.0));
2157        assert!(motion.retarget(CornerRadii::uniform(20.0 + RETARGET_TOLERANCE * 2.0), pad));
2158    }
2159
2160    #[test]
2161    fn checked_toggle_springs_from_round_rest_to_the_square_radius_and_settles_exactly() {
2162        let mut motion = ShapeMotion::new();
2163        let pad = ContentPadding::symmetric(16.0);
2164        motion.retarget(CornerRadii::uniform(20.0), pad); // unchecked pill rest
2165        assert!(motion.retarget(CornerRadii::uniform(12.0), pad)); // checked square
2166        assert_eq!(
2167            motion.radii(),
2168            CornerRadii::uniform(20.0),
2169            "starts where it was"
2170        );
2171        settle_at(&mut motion);
2172        assert_eq!(motion.radii(), CornerRadii::uniform(12.0));
2173    }
2174
2175    fn settle_at(motion: &mut ShapeMotion) {
2176        let mut t = 0.0;
2177        for _ in 0..600 {
2178            t += 1.0 / 60.0;
2179            if !motion.advance(ft_secs(t)) {
2180                return;
2181            }
2182        }
2183        panic!("never settled");
2184    }
2185
2186    #[test]
2187    fn rapid_toggle_retarget_mid_flight_is_continuous() {
2188        let mut motion = ShapeMotion::new();
2189        let pad = ContentPadding::symmetric(16.0);
2190        motion.retarget(CornerRadii::uniform(20.0), pad);
2191        motion.retarget(CornerRadii::uniform(12.0), pad);
2192        motion.advance(ft_secs(0.0));
2193        motion.advance(ft_secs(0.05));
2194        let mid = motion.radii();
2195        assert!(
2196            mid.top_left < 20.0 && mid.top_left > 12.0,
2197            "sampled mid-flight (got {mid:?})"
2198        );
2199
2200        // Rapid re-toggle back while still in flight: no jump.
2201        assert!(motion.retarget(CornerRadii::uniform(20.0), pad));
2202        assert_eq!(motion.radii(), mid);
2203
2204        settle_at(&mut motion);
2205        assert_eq!(motion.radii(), CornerRadii::uniform(20.0));
2206    }
2207
2208    #[test]
2209    fn overshoot_never_drives_a_corner_negative() {
2210        let mut motion = ShapeMotion::new();
2211        let pad = ContentPadding::symmetric(16.0);
2212        motion.retarget(CornerRadii::uniform(28.0), pad);
2213        motion.retarget(CornerRadii::uniform(0.0), pad);
2214        let mut t = 0.0;
2215        for _ in 0..600 {
2216            t += 1.0 / 60.0;
2217            let animating = motion.advance(ft_secs(t));
2218            let r = motion.radii();
2219            assert!(
2220                r.top_left >= 0.0
2221                    && r.top_right >= 0.0
2222                    && r.bottom_left >= 0.0
2223                    && r.bottom_right >= 0.0,
2224                "a corner went negative at t={t}"
2225            );
2226            if !animating {
2227                break;
2228            }
2229        }
2230        assert_eq!(motion.radii(), CornerRadii::uniform(0.0));
2231    }
2232
2233    #[test]
2234    fn widget_paint_retargets_the_morph_on_a_checked_flip() {
2235        let theme = crate::baseline();
2236        let mut widget = build(&toggle_button::<u32, _>(false, |_, _| {}));
2237        layout_with(&mut widget, 200.0, Some(&theme));
2238        paint_at(&mut widget, Size::new(40.0, 40.0), Some(&theme));
2239        settle_morph(&mut widget);
2240        let unchecked_radius = widget.motion.radii().top_left;
2241        assert_eq!(unchecked_radius, 20.0, "sm pill = height/2");
2242
2243        widget.checked = true;
2244        layout_with(&mut widget, 200.0, Some(&theme));
2245        paint_at(&mut widget, Size::new(40.0, 40.0), Some(&theme));
2246        assert_eq!(
2247            widget.motion.radii().top_left,
2248            unchecked_radius,
2249            "the leg starts where it was (continuity)"
2250        );
2251        settle_morph(&mut widget);
2252        assert_eq!(widget.motion.radii().top_left, 12.0, "sm square token");
2253    }
2254
2255    #[test]
2256    fn a_runtime_size_change_relayouts_at_the_new_sizes_padding_immediately() {
2257        // `ToggleButtonView::rebuild` already raises `ChangeFlags::LAYOUT` on
2258        // a `size` change, so the very next `layout` pass must already
2259        // reflect the NEW size's (halved, icon-only) padding — it must not
2260        // wait for an intervening `paint` to retarget the morph first, since
2261        // the crate's per-frame order always runs `layout` before `paint`.
2262        // Mutating `size` directly (the same field write `rebuild` performs)
2263        // with no `paint` call in between reproduces exactly that ordering —
2264        // the same revert-verified shape as `crate::button::core`'s
2265        // `a_runtime_size_change_relayouts_at_the_new_sizes_padding_immediately`.
2266        let mut widget = build(
2267            &toggle_button::<(), _>(false, |_, _| {})
2268                .icon(leaf_any(20.0, 20.0))
2269                .size(ToggleButtonSize::Sm),
2270        );
2271        let sm = layout_with(&mut widget, 500.0, None);
2272        let sm_metrics = ToggleButtonSize::Sm.metrics();
2273        assert_eq!(
2274            sm.width,
2275            sm_metrics.icon_size + sm_metrics.h_padding, // halved padding, both sides
2276            "sm icon-only width"
2277        );
2278
2279        widget.size = ToggleButtonSize::Lg;
2280        let lg = layout_with(&mut widget, 500.0, None);
2281        let lg_metrics = ToggleButtonSize::Lg.metrics();
2282        assert_eq!(
2283            lg.width,
2284            lg_metrics.icon_size + lg_metrics.h_padding,
2285            "layout must resolve the NEW size's padding on its very next pass, \
2286             not the stale one only a later paint would have retargeted"
2287        );
2288    }
2289
2290    // ---- Acceptance 3: group-connection corner asymmetry -------------------
2291
2292    #[test]
2293    fn standalone_single_member_shape_is_uniform() {
2294        let theme = crate::baseline();
2295        let mut widget = build(&toggle_button::<u32, _>(false, |_, _| {}));
2296        layout_with(&mut widget, 200.0, Some(&theme));
2297        let target = widget.target_radii(Some(&theme), 40.0);
2298        assert_eq!(target, CornerRadii::uniform(20.0));
2299    }
2300
2301    #[test]
2302    fn connected_first_member_is_outer_left_inner_right() {
2303        let theme = crate::baseline();
2304        let mut widget = build(
2305            &toggle_button::<u32, _>(false, |_, _| {})
2306                .group_connected(true)
2307                .first_in_group(true)
2308                .last_in_group(false),
2309        );
2310        layout_with(&mut widget, 200.0, Some(&theme));
2311        let target = widget.target_radii(Some(&theme), 40.0);
2312        assert_eq!(
2313            (target.top_left, target.bottom_left),
2314            (20.0, 20.0),
2315            "outer left"
2316        );
2317        assert_eq!(
2318            (target.top_right, target.bottom_right),
2319            (CONNECTED_INNER_RADIUS, CONNECTED_INNER_RADIUS),
2320            "inner right"
2321        );
2322    }
2323
2324    #[test]
2325    fn connected_last_member_is_inner_left_outer_right() {
2326        let theme = crate::baseline();
2327        let mut widget = build(
2328            &toggle_button::<u32, _>(false, |_, _| {})
2329                .group_connected(true)
2330                .first_in_group(false)
2331                .last_in_group(true),
2332        );
2333        layout_with(&mut widget, 200.0, Some(&theme));
2334        let target = widget.target_radii(Some(&theme), 40.0);
2335        assert_eq!(
2336            (target.top_left, target.bottom_left),
2337            (CONNECTED_INNER_RADIUS, CONNECTED_INNER_RADIUS),
2338            "inner left"
2339        );
2340        assert_eq!(
2341            (target.top_right, target.bottom_right),
2342            (20.0, 20.0),
2343            "outer right"
2344        );
2345    }
2346
2347    #[test]
2348    fn connected_middle_member_is_all_inner_corners() {
2349        let theme = crate::baseline();
2350        let mut widget = build(
2351            &toggle_button::<u32, _>(false, |_, _| {})
2352                .group_connected(true)
2353                .first_in_group(false)
2354                .last_in_group(false),
2355        );
2356        layout_with(&mut widget, 200.0, Some(&theme));
2357        let target = widget.target_radii(Some(&theme), 40.0);
2358        assert_eq!(
2359            target,
2360            CornerRadii::uniform(CONNECTED_INNER_RADIUS),
2361            "a middle member is all inner corners"
2362        );
2363    }
2364
2365    #[test]
2366    fn connected_pressed_member_uses_the_pressed_inner_radius() {
2367        let theme = crate::baseline();
2368        let mut widget = build(
2369            &toggle_button::<u32, _>(false, |_, _| {})
2370                .group_connected(true)
2371                .first_in_group(false)
2372                .last_in_group(false),
2373        );
2374        layout_with(&mut widget, 200.0, Some(&theme));
2375        widget.state.set_pressed(true);
2376        let target = widget.target_radii(Some(&theme), 40.0);
2377        assert_eq!(target, CornerRadii::uniform(CONNECTED_PRESSED_INNER_RADIUS));
2378    }
2379
2380    #[test]
2381    fn connected_checked_idle_member_is_fully_round_on_every_corner() {
2382        let theme = crate::baseline();
2383        let mut widget = build(
2384            &toggle_button::<u32, _>(true, |_, _| {})
2385                .group_connected(true)
2386                .first_in_group(false)
2387                .last_in_group(false),
2388        );
2389        layout_with(&mut widget, 200.0, Some(&theme));
2390        let target = widget.target_radii(Some(&theme), 40.0);
2391        assert_eq!(
2392            target,
2393            CornerRadii::uniform(20.0),
2394            "checked+connected+idle overrides the inner-corner asymmetry"
2395        );
2396    }
2397
2398    // ---- Acceptance 4: icon/label swap on checked ---------------------------
2399
2400    #[test]
2401    fn label_falls_back_to_the_unchecked_one_when_checked_label_is_unset() {
2402        let view = toggle_button::<u32, _>(true, |_, _| {}).label("Off");
2403        assert_eq!(view.effective_label_text(), Some("Off"));
2404    }
2405
2406    #[test]
2407    fn checked_label_wins_over_the_unchecked_one_when_both_are_set() {
2408        let view = toggle_button::<u32, _>(true, |_, _| {})
2409            .label("Off")
2410            .checked_label("On");
2411        assert_eq!(view.effective_label_text(), Some("On"));
2412        let unchecked = toggle_button::<u32, _>(false, |_, _| {})
2413            .label("Off")
2414            .checked_label("On");
2415        assert_eq!(unchecked.effective_label_text(), Some("Off"));
2416    }
2417
2418    #[test]
2419    fn build_resolves_the_effective_label_for_the_initial_checked_value() {
2420        let checked_view = toggle_button::<u32, _>(true, |_, _| {})
2421            .label("Off")
2422            .checked_label("On");
2423        let widget = build(&checked_view);
2424        assert_eq!(widget.label.content(), "On");
2425
2426        let unchecked_view = toggle_button::<u32, _>(false, |_, _| {})
2427            .label("Off")
2428            .checked_label("On");
2429        let widget = build(&unchecked_view);
2430        assert_eq!(widget.label.content(), "Off");
2431    }
2432
2433    #[test]
2434    fn rebuild_swaps_the_label_content_on_a_checked_flip() {
2435        let prev = toggle_button::<u32, _>(false, |_, _| {})
2436            .label("Off")
2437            .checked_label("On");
2438        let next = toggle_button::<u32, _>(true, |_, _| {})
2439            .label("Off")
2440            .checked_label("On");
2441        let mut widget = build(&prev);
2442        assert_eq!(widget.label.content(), "Off");
2443        let mut counter = 0u64;
2444        let flags =
2445            View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2446        assert_eq!(widget.label.content(), "On");
2447        assert!(flags.contains(ChangeFlags::LAYOUT));
2448    }
2449
2450    #[test]
2451    fn icon_falls_back_and_swaps_the_same_way_as_the_label() {
2452        let unchecked = toggle_button::<(), _>(false, |_, _| {}).icon(leaf_any(20.0, 20.0));
2453        assert!(unchecked.effective_icon_view().is_some());
2454
2455        let checked_no_override =
2456            toggle_button::<(), _>(true, |_, _| {}).icon(leaf_any(20.0, 20.0));
2457        assert!(
2458            checked_no_override.effective_icon_view().is_some(),
2459            "falls back to `icon` while checked"
2460        );
2461
2462        let checked_with_override = toggle_button::<(), _>(true, |_, _| {})
2463            .icon(leaf_any(20.0, 20.0))
2464            .checked_icon(leaf_any(24.0, 24.0));
2465        let widget = build(&checked_with_override);
2466        assert!(widget.icon.is_some());
2467    }
2468
2469    #[test]
2470    fn an_icon_only_toggle_button_halves_its_horizontal_padding() {
2471        let mut icon_only =
2472            build(&toggle_button::<(), _>(false, |_, _| {}).icon(leaf_any(20.0, 20.0)));
2473        layout_with(&mut icon_only, 300.0, None);
2474        assert!(!icon_only.has_label());
2475        assert_eq!(icon_only.h_padding(&ToggleButtonSize::Sm.metrics()), 8.0);
2476
2477        let mut labeled = build(&toggle_button::<(), _>(false, |_, _| {}).label("Bold"));
2478        layout_with(&mut labeled, 300.0, None);
2479        assert!(labeled.has_label());
2480        assert_eq!(labeled.h_padding(&ToggleButtonSize::Sm.metrics()), 16.0);
2481    }
2482
2483    // ---- Content wider than the box: clipped, never re-fitted -------------
2484
2485    #[test]
2486    fn a_box_narrower_than_the_content_clips_it_instead_of_ellipsizing() {
2487        let mut widget = build(&toggle_button::<(), _>(false, |_, _| {}).label("Days per week"));
2488        let natural = layout_with(&mut widget, f64::INFINITY, None);
2489        let full = paint_at(&mut widget, natural, None).run_glyphs;
2490        assert_eq!(full.len(), 1, "one label run");
2491        assert!(full[0] > 1, "the label shaped some glyphs");
2492        assert!(!widget.content_clipped, "nothing to clip at natural width");
2493
2494        // A group hands its members a tight main mid-squish. A shallow
2495        // squeeze eats into the horizontal padding first — the label is whole
2496        // and still inside the box, which is what makes the reference's
2497        // squish read as padding compressing rather than text truncating.
2498        let padding = ToggleButtonSize::Sm.metrics().h_padding;
2499        let shallow = layout_tight_main(&mut widget, natural.width - padding);
2500        let rec = paint_at(&mut widget, shallow, None);
2501        assert_eq!(rec.run_glyphs, full, "the label is untouched");
2502        assert!(
2503            rec.clips.is_empty(),
2504            "and still fits, so nothing is clipped"
2505        );
2506
2507        // Past the padding the box starts covering the label itself.
2508        let squeezed = layout_tight_main(&mut widget, natural.width - padding * 2.0 - 4.0);
2509        let rec = paint_at(&mut widget, squeezed, None);
2510        assert_eq!(
2511            rec.run_glyphs, full,
2512            "the shrinking box covers the label; it is never re-shaped to fit"
2513        );
2514        assert_eq!(
2515            rec.clips,
2516            vec![(Point::ZERO, squeezed)],
2517            "the overflowing content row is clipped to the box (`Clip.hardEdge`)"
2518        );
2519        // Centered, so the overflow is split evenly off both ends.
2520        assert!(
2521            widget.label_origin.x < 0.0,
2522            "the centered row hangs off the leading edge too: {}",
2523            widget.label_origin.x
2524        );
2525    }
2526
2527    #[test]
2528    fn a_squeezed_then_restored_label_keeps_its_full_shape() {
2529        let mut widget = build(&toggle_button::<(), _>(false, |_, _| {}).label("Days per week"));
2530        let natural = layout_with(&mut widget, f64::INFINITY, None);
2531        let full = paint_at(&mut widget, natural, None).run_glyphs;
2532
2533        layout_tight_main(&mut widget, natural.width - 12.0);
2534        // Back to natural — including the sub-pixel residue a spring landing
2535        // on its target leaves behind, which an exact fit test would answer
2536        // by dropping a whole word.
2537        for main in [natural.width - 0.0002, natural.width] {
2538            let size = layout_tight_main(&mut widget, main);
2539            assert_eq!(
2540                paint_at(&mut widget, size, None).run_glyphs,
2541                full,
2542                "the label is whole again at main {main}"
2543            );
2544        }
2545        assert!(!widget.content_clipped, "and needs no clip once it fits");
2546    }
2547
2548    // ---- Controlled contract -------------------------------------------------
2549
2550    #[test]
2551    fn defaults_match_the_reference_constructor_defaults() {
2552        let view = toggle_button::<u32, _>(false, |_, _| {});
2553        assert_eq!(view.variant, ButtonVariant::Filled);
2554        assert_eq!(view.size, ToggleButtonSize::Sm);
2555        assert!(view.enabled);
2556        assert_eq!(view.haptic, HapticSignal::None);
2557        assert!(!view.is_group_connected);
2558        assert!(view.is_first_in_group);
2559        assert!(view.is_last_in_group);
2560    }
2561
2562    #[test]
2563    fn the_named_constructors_map_onto_the_reference_styles() {
2564        assert_eq!(
2565            filled_toggle_button::<u32, _>(false, |_, _| {}).variant,
2566            ButtonVariant::Filled
2567        );
2568        assert_eq!(
2569            tonal_toggle_button::<u32, _>(false, |_, _| {}).variant,
2570            ButtonVariant::Tonal
2571        );
2572        assert_eq!(
2573            elevated_toggle_button::<u32, _>(false, |_, _| {}).variant,
2574            ButtonVariant::Elevated
2575        );
2576        assert_eq!(
2577            outlined_toggle_button::<u32, _>(false, |_, _| {}).variant,
2578            ButtonVariant::Outlined
2579        );
2580        assert_eq!(
2581            text_toggle_button::<u32, _>(false, |_, _| {}).variant,
2582            ButtonVariant::Text
2583        );
2584    }
2585
2586    #[test]
2587    fn a_tap_reports_the_requested_value_and_never_self_mutates() {
2588        let mut widget = build(&toggle_button::<bool, _>(false, |s, v| *s = v));
2589        layout_with(&mut widget, 200.0, None);
2590        let size = Size::new(60.0, 40.0);
2591        let mut state = false;
2592        let state_any: &mut dyn Any = &mut state;
2593        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2594        widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2595        assert!(widget.captured);
2596        assert!(!widget.checked, "the widget itself never flips `checked`");
2597
2598        widget.event(&mut ctx, &ev(PointerPhase::Up, 30.0, 20.0));
2599        assert!(state, "on_checked_change fired with the requested value");
2600        assert!(!widget.checked, "still not self-mutated");
2601    }
2602
2603    #[test]
2604    fn disabling_mid_press_clears_the_press_capture_and_long_press_candidacy() {
2605        let prev = toggle_button::<u32, _>(false, |_, _| {});
2606        let next = toggle_button::<u32, _>(false, |_, _| {}).enabled(false);
2607        let mut widget = build(&prev);
2608        widget.state.set_pressed(true);
2609        widget.captured = true;
2610        widget.long_press.elapsed = true;
2611        let mut counter = 0u64;
2612        View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2613        assert!(!widget.state.pressed);
2614        assert!(!widget.captured);
2615        assert!(!widget.long_press.elapsed);
2616    }
2617
2618    // ---- Long-press ------------------------------------------------------
2619
2620    #[test]
2621    fn a_long_press_fires_on_move_arrival_after_the_threshold_and_suppresses_the_tap() {
2622        // `PaintCtx::for_test`, the chosen-`FrameTime` seam, is behind a
2623        // `frust-core` feature this crate's dev-dependency does not enable
2624        // (see `switch.rs`'s own note) — so the threshold-crossing latch this
2625        // test exercises is set directly rather than paced through real
2626        // paint passes; the paint-side latch logic itself is exercised by
2627        // `paint`'s own request_frame plumbing, not re-derived here.
2628        let view =
2629            toggle_button::<(u32, u32), _>(false, |s, _| s.0 += 1).on_long_press(|s| s.1 += 1);
2630        let mut widget = build(&view);
2631        layout_with(&mut widget, 200.0, None);
2632        let size = Size::new(60.0, 40.0);
2633
2634        let mut state = (0u32, 0u32);
2635        dispatch(
2636            &mut widget,
2637            &mut state,
2638            size,
2639            &ev(PointerPhase::Down, 30.0, 20.0),
2640        );
2641        assert!(widget.captured);
2642
2643        // Simulate paint having latched the threshold crossing.
2644        widget.long_press.elapsed = true;
2645
2646        // A Move within slop fires the long-press now (fire-on-arrival), not
2647        // the tap.
2648        dispatch(
2649            &mut widget,
2650            &mut state,
2651            size,
2652            &ev(PointerPhase::Move, 31.0, 20.0),
2653        );
2654        assert_eq!(state.1, 1, "on_long_press fired");
2655        assert_eq!(state.0, 0, "on_checked_change must not also fire");
2656
2657        dispatch(
2658            &mut widget,
2659            &mut state,
2660            size,
2661            &ev(PointerPhase::Up, 31.0, 20.0),
2662        );
2663        assert_eq!(
2664            state.0, 0,
2665            "the up-inside toggle is suppressed after a long-press"
2666        );
2667    }
2668
2669    #[test]
2670    fn a_long_press_still_fires_at_up_if_no_move_arrives_first() {
2671        let view =
2672            toggle_button::<(u32, u32), _>(false, |s, _| s.0 += 1).on_long_press(|s| s.1 += 1);
2673        let mut widget = build(&view);
2674        layout_with(&mut widget, 200.0, None);
2675        let size = Size::new(60.0, 40.0);
2676        let mut state = (0u32, 0u32);
2677        let state_any: &mut dyn Any = &mut state;
2678        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2679        widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2680        widget.long_press.elapsed = true;
2681        widget.event(&mut ctx, &ev(PointerPhase::Up, 30.0, 20.0));
2682        assert_eq!(state.1, 1, "the Up fallback fires the long-press");
2683        assert_eq!(state.0, 0, "the checked toggle is suppressed");
2684    }
2685
2686    #[test]
2687    fn movement_past_touch_slop_cancels_long_press_candidacy() {
2688        let view = toggle_button::<u32, _>(false, |s, _| *s += 1).on_long_press(|s| *s += 100);
2689        let mut widget = build(&view);
2690        layout_with(&mut widget, 200.0, None);
2691        let size = Size::new(200.0, 40.0);
2692        let mut state = 0u32;
2693        let state_any: &mut dyn Any = &mut state;
2694        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2695        widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2696        widget.long_press.elapsed = true; // simulate the threshold already crossed
2697        widget.event(
2698            &mut ctx,
2699            &ev(PointerPhase::Move, 30.0 + TOUCH_SLOP + 5.0, 20.0),
2700        );
2701        assert!(!widget.long_press.elapsed, "a drag cancels the candidacy");
2702    }
2703
2704    #[test]
2705    fn cancel_clears_long_press_state_without_touching_app_state() {
2706        let mut widget = build(&toggle_button::<u32, _>(false, |s, _| *s = 1));
2707        layout_with(&mut widget, 200.0, None);
2708        let size = Size::new(60.0, 40.0);
2709        let mut state = 0u32;
2710        let state_any: &mut dyn Any = &mut state;
2711        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2712        widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2713        widget.long_press.elapsed = true;
2714        widget.event(&mut ctx, &ev(PointerPhase::Cancel, 30.0, 20.0));
2715        assert!(!widget.long_press.elapsed);
2716        assert!(!widget.captured);
2717        assert_eq!(state, 0);
2718    }
2719
2720    // ---- Semantics ---------------------------------------------------------
2721
2722    /// A `SemanticsCtx` is only ever handed out by `RenderRoot::semantics` (no
2723    /// public constructor) — the same fixture route `button_group.rs`'s own
2724    /// semantics test uses.
2725    fn semantics_button_node(
2726        mut view_fn: impl FnMut(&mut ()) -> ToggleButtonView<()> + 'static,
2727    ) -> frust_core::accesskit::Node {
2728        let mut root: frust_core::RenderRoot<(), ToggleButtonView<()>> =
2729            frust_core::RenderRoot::new();
2730        let mut state = ();
2731        root.rebuild(&mut view_fn, &mut state);
2732        let mut tcx = TextContext::new();
2733        root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
2734        let update = root.semantics();
2735        update
2736            .nodes
2737            .iter()
2738            .find(|(_, n)| n.role() == Role::Button)
2739            .expect("a Button node is contributed")
2740            .1
2741            .clone()
2742    }
2743
2744    #[test]
2745    fn semantics_reports_toggled_state_and_label() {
2746        let node = semantics_button_node(|_| toggle_button::<(), _>(true, |_, _| {}).label("Bold"));
2747        assert_eq!(node.label(), Some("Bold"));
2748        assert_eq!(node.toggled(), Some(Toggled::True));
2749    }
2750
2751    #[test]
2752    fn disabled_semantics_report_no_click_action() {
2753        let node =
2754            semantics_button_node(|_| toggle_button::<(), _>(false, |_, _| {}).enabled(false));
2755        assert!(node.is_disabled());
2756    }
2757}