Skip to main content

frust_glyph/
toggle.rs

1//! [`toggle`]/[`ToggleView`]: the Glyph switch — a 38×22 pill track whose 16px
2//! knob springs 16px across on a state change (the Glyph design system's
3//! `.toggle` rules and the motion reference's own "toggle spring" section,
4//! retrieved 2026-07-21).
5//!
6//! # Why the catalog carries a switch at all
7//!
8//! The baseline widget set ships no `Switch` (`frust::Checkbox` is its only
9//! boolean control), and Glyph authors this one as its own design rather than a
10//! re-tint of the M3 switch: a hairline-bordered pill, a small
11//! constant-diameter knob, no state-layer overlay, an accent *wash* instead of
12//! a filled track. The public shape still mirrors `frust_material::switch`
13//! (`toggle(checked, on_toggle)`), so an app migrates between catalogs
14//! mechanically.
15//!
16//! # Controlled component (never self-mutating)
17//!
18//! Like [`crate::segmented_control`]/[`crate::tabs`], it reports the
19//! *requested* value through `on_toggle(state, !checked)` on a release inside
20//! its bounds and never flips its own `checked`; the app's next `rebuild` feeds
21//! the confirmed value back in, which is what starts the animation.
22//!
23//! # A touch slab around a fixed pill
24//!
25//! `layout` requests a [`TOUCH_TARGET`]-square slab and `paint` centers the
26//! fixed pill inside whatever box it was given, so the hit-tested area (the
27//! whole slab) and the painted control never diverge — and the a11y target
28//! clears WCAG 2.2 and iOS's minimums a bare 38×22 misses (4dp under
29//! Android's; see [`TOUCH_TARGET`]'s doc for why that shortfall is accepted).
30//!
31//! # Two motion tracks: the knob springs, the colors fade
32//!
33//! The motion reference states the split outright — "the knob is a spatial
34//! change (position) — it gets the spring. The track fill is a color change —
35//! it fades, no bounce" (`glyph-motion.html`, section 02) — so the widget runs
36//! two independent progress lanes ([`Progress`]):
37//!
38//! - **knob position**: 220ms on the overshooting spatial bezier
39//!   ([`KNOB_DURATION`]/[`KNOB_CURVE`]), read **unclamped** — the knob sliding
40//!   briefly past its rest spot is the source's intended travel.
41//! - **track/border/knob color**: 150ms on the non-overshooting effects bezier
42//!   ([`FADE_DURATION`]/[`FADE_CURVE`]), read clamped — a color never
43//!   extrapolates past its endpoint.
44//!
45//! Both pairs stay hardcoded on the
46//! [`crate::tabs`]/[`crate::segmented_control`] convention: a Glyph
47//! transition's timing is part of the design's identity,
48//! not theme-tunable decoration. Nothing technical forces that — a controller's
49//! duration and curve resolve fine at paint time, as [`crate::accordion`] and
50//! `frust_material::switch`'s spring both do — it is a fidelity choice. The
51//! values are the exact `MotionScheme::glyph()` `durations.base`/
52//! `easing.spatial` and `durations.fast`/`easing.effects` ([`crate::tokens`]),
53//! so a themed app animates identically.
54//!
55//! Both lanes advance during `paint` and re-request a frame while in flight
56//! (`docs/WIDGETS_CODE_STANDARDS.md`'s Theming & Animation Conventions); under
57//! `theme.motion.reduce_motion` they snap to their targets and request nothing,
58//! so a state change paints its final frame at once. The pill never resizes, so
59//! this is a paint-only animation: bare `request_frame`, never
60//! `request_layout`.
61//!
62//! # Per-brightness paint (the source's dark/light split)
63//!
64//! Only the *off* knob and the lift shadow differ between brightnesses, so the
65//! resolve branches on `theme.brightness` (the [`crate::segmented_control`]
66//! precedent): dark draws the off knob from `--fg-dim` with no shadow; light
67//! draws the white `--bg-surface` slot over a `0 1px 2px` ink lift, because a
68//! white knob on a warm-paper track needs the lift to read as an object at all
69//! (painted inline rather than as an `Elevation` level — again
70//! [`crate::segmented_control`]'s call for its micro-shadow).
71//!
72//! Everything else is one role per value: the `bg-overlay` track slot off and an
73//! accent wash on, the `fg`-derived hairline border off and an accent wash on,
74//! and the bright-fill accent role (`primary_container`, `#ffb627` in both
75//! brightnesses) for the *on* knob — never the accent *text* role
76//! (`docs/WIDGETS_CODE_STANDARDS.md`'s Glyph accent-role split).
77//!
78//! ## The `--fg-dim` role gap, and what it costs
79//!
80//! `--fg-dim` has no `ColorScheme` role at all ([`crate::tokens::color`] maps
81//! `fg-muted` to `on_surface_variant` and leaves `fg-dim` unmapped), so the
82//! themed dark off-knob follows the catalog convention
83//! `docs/WIDGETS_CODE_STANDARDS.md` records — `--fg-dim` as a *fill* resolves to
84//! `on_surface_variant` — and paints `#a39c88` where the source authors
85//! `#6b6556`: ~2.5× the authored relative luminance, reading 4.9:1 against the
86//! `#272d3d` track instead of 2.4:1. That is the deliberate price of one
87//! catalog-wide convention over a per-widget token; both hexes are pinned by
88//! test (themed and unthemed) so it cannot drift unnoticed. The unthemed
89//! fallback keeps the exact source hex ([`TOGGLE_KNOB_OFF`]).
90//!
91//! # No disabled, focus, or pressed states in v1
92//!
93//! The sources author none — no `:disabled`, `:focus-visible`, or `:active` rule
94//! in either brightness, and no state-layer overlay — so a press is armed state
95//! only, painting nothing. A future disabled state should follow the baseline
96//! `Button`'s 0.38 disabled-opacity convention rather than invent one.
97
98use std::rc::Rc;
99use std::time::Duration;
100
101use frust::authoring::{Action, Role, Toggled};
102use frust::authoring::{
103    BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
104    PaintScene, PointerPhase, SemanticsCtx, View, Widget,
105};
106use frust::{AnimationController, Brightness, Curve, FrameTime, ShapeScale, Theme, Tween};
107use kurbo::{Point, Rect, RoundedRect, Shape, Size, Vec2};
108use peniko::{Brush, Color};
109
110use crate::press::presses;
111
112/// Track width, logical px (`.toggle{width:38px}` —
113/// `glyph-design-system.html:255`, `glyph-design-system-light.html:152`,
114/// `glyph-motion.html:59`, identical in all three). No `Theme` token carries a
115/// fixed control dimension: `ShapeScale` publishes corner radii, not
116/// track/knob sizes.
117const TRACK_W: f64 = 38.0;
118/// Track height, logical px (`.toggle{height:22px}`, same three sources). See
119/// [`TRACK_W`] for why this is a constant rather than a token.
120const TRACK_H: f64 = 22.0;
121/// Track border thickness, logical px (`.toggle{border:1px solid …}`,
122/// `glyph-design-system.html:255`). Stroked on a path inset by half this width
123/// so the *painted* box measures exactly [`TRACK_W`]×[`TRACK_H`] — all three
124/// sources set `box-sizing:border-box`, which is also the premise
125/// [`KNOB_OFFSET`]'s math already assumes.
126const BORDER_W: f64 = 1.0;
127/// Interactive slab edge, logical px: `layout` requests
128/// `max(TRACK_W, TOUCH_TARGET) × max(TRACK_H, TOUCH_TARGET)` and centers the
129/// pill in it, because a 38×22 control clears no platform minimum (WCAG 2.2
130/// target-size 24px, iOS 44pt, Android 48dp). 44 clears WCAG 2.2 and iOS
131/// outright but sits 4dp under Android's 48dp — accepted for consistency with
132/// [`crate::sheet`]'s `HANDLE_TOUCH_TARGET`, already the same 44 value its 3px
133/// drag handle wraps in — one slab size for the catalog's undersized chrome.
134const TOUCH_TARGET: f64 = 44.0;
135/// Knob diameter, logical px (`.toggle::after{width:16px;height:16px}`,
136/// `glyph-design-system.html:256`). Constant in both states — unlike the M3
137/// switch, Glyph's knob never resizes.
138const KNOB_SIZE: f64 = 16.0;
139/// Knob inset from the track's *inner* (border-box) edge, logical px
140/// (`.toggle::after{top:2px;left:2px}`, `glyph-design-system.html:256`). The
141/// source's `::after` is positioned against the padding box, i.e. inside the
142/// 1px border — [`KNOB_OFFSET`] is that offset measured from the pill's origin.
143const KNOB_INSET: f64 = 2.0;
144/// The off-state knob offset from the *pill's* origin on both axes, logical px:
145/// the border plus the source's 2px inset. Vertically this also centers the
146/// knob exactly (`(22 − 16) / 2 == 3`).
147const KNOB_OFFSET: f64 = BORDER_W + KNOB_INSET;
148/// Knob travel, logical px (`.toggle:checked::after{transform:translateX(16px)}`
149/// — `glyph-design-system.html:258`, `glyph-design-system-light.html:155`,
150/// `glyph-motion.html:62`). Equivalently `TRACK_W − TRACK_H`: the knob's two
151/// rest centers sit exactly on the pill's two corner centers (x = 11 and 27).
152const KNOB_TRAVEL: f64 = 16.0;
153/// Corner-rounding tolerance for the hairline border path (the crate's shared
154/// `RoundedRect::to_path` value — see [`crate::badge`]'s `PATH_TOLERANCE`).
155const PATH_TOLERANCE: f64 = 0.1;
156
157// ---- Motion (hardcoded named constants; see the module docs) ---------------
158
159/// Knob travel duration — `--dur-base` (220ms, `glyph-motion.html:20`), the
160/// duration the "toggle spring" section pins for the knob
161/// (`glyph-motion.html:60`, `:166`–`:173`); the exact `MotionScheme::glyph()`
162/// `durations.base`.
163///
164/// **The sources disagree, and 220ms is the deliberate resolution.** Both
165/// design-system sheets author the knob as
166/// `transition: transform .18s var(--ease-spring)`
167/// (`glyph-design-system.html:256`, `glyph-design-system-light.html:153`) —
168/// 180ms — while the motion reference, which is *the* motion authority for this
169/// catalog, pins `--dur-base` 220ms on `--ease-spatial`
170/// (`glyph-motion.html:20`–`:21`, `:59`–`:62`) and repeats it in its toggle
171/// section. The authority wins: this is not a transcription slip to "fix" back
172/// to 180ms.
173const KNOB_DURATION: Duration = Duration::from_millis(220);
174/// Knob travel easing — `--ease-spatial`/`--ease-spring`,
175/// `cubic-bezier(0.34,1.35,0.64,1)` (`glyph-motion.html:21`,
176/// `glyph-design-system.html:53`); the exact `MotionScheme::glyph()`
177/// `easing.spatial`. Overshoots past `1.0` on purpose — that is the spring.
178const KNOB_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
179/// Color-fade duration — `--dur-fast` (150ms, `glyph-motion.html:20`), the
180/// `background` transition on `.toggle`/`.toggle::after`
181/// (`glyph-motion.html:59`–`:60`); the exact `MotionScheme::glyph()`
182/// `durations.fast`.
183const FADE_DURATION: Duration = Duration::from_millis(150);
184/// Color-fade easing — `--ease-effects`/`--ease-out`,
185/// `cubic-bezier(0.16,1,0.3,1)` (`glyph-motion.html:22`,
186/// `glyph-design-system.html:52`); the exact `MotionScheme::glyph()`
187/// `easing.effects`. Never overshoots, so no color extrapolates.
188const FADE_CURVE: Curve = Curve::Cubic(0.16, 1.0, 0.3, 1.0);
189
190// ---- Source alpha tokens ---------------------------------------------------
191
192/// Off-state border alpha — `--border-bright` is the foreground ink at 18%
193/// (`rgba(242,234,217,0.18)` dark `glyph-design-system.html:41`,
194/// `rgba(34,29,18,0.18)` light `glyph-design-system-light.html:45`): one alpha,
195/// both brightnesses, over the `on_surface` role each carries.
196///
197/// Painted as a true alpha stroke rather than the pre-flattened `outline` role
198/// ([`crate::tokens::color`]'s "Alpha pre-flattening"): that flattening assumes
199/// a `bg-surface` backdrop, and a toggle sits on whatever surface its row does.
200const BORDER_OFF_ALPHA: f32 = 0.18;
201/// Checked track alpha, dark — `--amber-faint` = `rgba(255,182,39,0.12)`
202/// (`glyph-design-system.html:21`), i.e. the accent at 12%.
203const TRACK_ON_ALPHA_DARK: f32 = 0.12;
204/// Checked track alpha, light — `--amber-faint` = `rgba(163,101,10,0.10)`
205/// (`glyph-design-system-light.html:24`): the light accent-text tone at 10%,
206/// re-tuned rather than flipped.
207const TRACK_ON_ALPHA_LIGHT: f32 = 0.10;
208/// Checked border alpha, dark — `--amber-border` = `rgba(255,182,39,0.35)`
209/// (`glyph-design-system.html:23`), applied by
210/// `.toggle:checked{border-color:var(--amber-border)}` (`:257`).
211const BORDER_ON_ALPHA_DARK: f32 = 0.35;
212/// Checked border alpha, light — `--amber-border` = `rgba(163,101,10,0.38)`
213/// (`glyph-design-system-light.html:26`, applied at `:154`).
214const BORDER_ON_ALPHA_LIGHT: f32 = 0.38;
215/// Light-only knob lift shadow: `box-shadow:0 1px 2px rgba(0,0,0,0.2)`
216/// (`glyph-design-system-light.html:153`) — alpha 0.2 over the theme's
217/// `shadow` role, which on the Glyph light baseline is warm ink rather than
218/// black ("light elevation reads as warm-ink lift", [`crate::tokens::color`]).
219const KNOB_SHADOW_ALPHA: f32 = 0.2;
220/// The lift shadow's blur, logical px: the CSS blur radius stored directly as a
221/// `blur_std_dev`, matching `frust_theme::elevation`'s v1 treatment and
222/// [`crate::segmented_control`]'s identical micro-shadow.
223const KNOB_SHADOW_BLUR: f64 = 2.0;
224/// The lift shadow's vertical offset, logical px (the `1px` in `0 1px 2px`).
225const KNOB_SHADOW_DY: f64 = 1.0;
226
227// ---- Unthemed fallback constants (Glyph **dark** values) -------------------
228
229/// Off-state track fill — `--bg-overlay` (`glyph-design-system.html:16`),
230/// themed `surface_container_highest`.
231const TOGGLE_TRACK_OFF: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
232/// Off-state knob fill — `--fg-dim` (`glyph-design-system.html:37`). Exact
233/// here; the themed path resolves `on_surface_variant` instead and lands
234/// ~2.5× brighter (see the module docs' `--fg-dim` role gap).
235const TOGGLE_KNOB_OFF: Color = Color::from_rgb8(0x6b, 0x65, 0x56);
236/// The accent — `--amber` (`glyph-design-system.html:19`), themed
237/// `primary_container` for the knob fill and `primary` for the washes.
238const TOGGLE_ACCENT: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
239/// The ink the 18% `--border-bright` hairline is derived from — `--fg`
240/// (`glyph-design-system.html:35`), themed `on_surface`.
241const TOGGLE_BORDER_INK: Color = Color::from_rgb8(0xf2, 0xea, 0xd9);
242
243/// Return `color` with its alpha channel replaced (mirrors the same helper in
244/// the sibling Glyph widgets).
245fn with_alpha(color: Color, alpha: f32) -> Color {
246    let c = color.components;
247    Color::new([c[0], c[1], c[2], alpha])
248}
249
250/// Interpolate `begin`→`end` at `t`, snapping exactly to an endpoint at (or
251/// past) `0.0`/`1.0` instead of routing it through [`Tween::lerp`]'s `f32`
252/// arithmetic, which can land a few ULPs off `end`. Keeps a resting toggle
253/// pixel-identical to its source color, the same guarantee (and shape)
254/// `frust_material::switch`'s `lerp_color_exact` keeps.
255fn lerp_color(begin: Color, end: Color, t: f64) -> Color {
256    if t <= 0.0 {
257        begin
258    } else if t >= 1.0 {
259        end
260    } else {
261        Tween::new(begin, end).lerp(t)
262    }
263}
264
265/// One curve-driven `0 → 1` progress lane: a controller plus the endpoints it
266/// interpolates between.
267///
268/// The endpoint pair is what [`crate::tabs`]' indicator and
269/// [`crate::accordion`]'s height driver both use, for two reasons a bare
270/// controller value can't cover: a freshly-built widget rests at its confirmed
271/// state with no entry animation (`from == to`), and a mid-flight reversal
272/// starts from the value actually on screen rather than snapping.
273struct Progress {
274    ctrl: AnimationController,
275    duration: Duration,
276    curve: Curve,
277    from: f64,
278    to: f64,
279    /// The value last computed by [`Progress::advance`] — what paint reads.
280    displayed: f64,
281}
282
283impl Progress {
284    /// An idle lane resting at `value`.
285    fn at_rest(value: f64, duration: Duration, curve: Curve) -> Self {
286        Self {
287            ctrl: AnimationController::new(duration).with_curve(curve),
288            duration,
289            curve,
290            from: value,
291            to: value,
292            displayed: value,
293        }
294    }
295
296    /// Re-aim at `to`, starting from whatever is on screen now.
297    fn retarget(&mut self, to: f64) {
298        self.from = self.displayed;
299        self.to = to;
300        self.ctrl = AnimationController::new(self.duration).with_curve(self.curve);
301        self.ctrl.forward();
302    }
303
304    /// Land on the target immediately, cancelling any flight — the
305    /// `reduce_motion` path ([`crate::accordion`]'s `snap` idiom). A fresh
306    /// controller is idle, so a following [`Progress::advance`] stays at rest
307    /// and asks for nothing.
308    fn snap(&mut self) {
309        self.from = self.to;
310        self.displayed = self.to;
311        self.ctrl = AnimationController::new(self.duration).with_curve(self.curve);
312    }
313
314    /// Advance to frame time `now`, returning whether the lane is still in
315    /// flight (in which case the caller must request another frame).
316    fn advance(&mut self, now: FrameTime) -> bool {
317        if self.ctrl.is_animating() {
318            let animating = self.ctrl.advance(now);
319            self.displayed = self.from + (self.to - self.from) * self.ctrl.value();
320            animating
321        } else {
322            self.displayed = self.to;
323            false
324        }
325    }
326
327    /// The current value, unclamped — an overshooting curve reads past `1.0`.
328    fn value(&self) -> f64 {
329        self.displayed
330    }
331
332    /// [`Progress::value`] clamped to `0.0..=1.0`, for a consumer (a color
333    /// lerp) that must not extrapolate.
334    fn value_clamped(&self) -> f64 {
335        self.displayed.clamp(0.0, 1.0)
336    }
337}
338
339/// The resolved toggle palette. `knob_shadow` is `Some` only on the light
340/// baseline — see the module docs' per-brightness split.
341struct ToggleColors {
342    track_off: Color,
343    track_on: Color,
344    knob_off: Color,
345    knob_on: Color,
346    border_off: Color,
347    border_on: Color,
348    knob_shadow: Option<Color>,
349}
350
351/// Resolve the palette from the theme, falling back to the literal Glyph
352/// **dark** constants with none threaded.
353fn resolve_toggle_colors(theme: Option<&Theme>) -> ToggleColors {
354    match theme {
355        Some(theme) => {
356            let scheme = theme.scheme();
357            let (track_on_alpha, border_on_alpha, knob_off, knob_shadow) = match theme.brightness {
358                // `on_surface_variant` is `--fg-muted`, not the authored
359                // `--fg-dim`: the catalog convention, with the cost measured in
360                // the module docs and pinned by test.
361                Brightness::Dark => (
362                    TRACK_ON_ALPHA_DARK,
363                    BORDER_ON_ALPHA_DARK,
364                    scheme.on_surface_variant,
365                    None,
366                ),
367                Brightness::Light => (
368                    TRACK_ON_ALPHA_LIGHT,
369                    BORDER_ON_ALPHA_LIGHT,
370                    scheme.surface_container_lowest,
371                    Some(with_alpha(scheme.shadow, KNOB_SHADOW_ALPHA)),
372                ),
373            };
374            ToggleColors {
375                track_off: scheme.surface_container_highest,
376                track_on: with_alpha(scheme.primary, track_on_alpha),
377                knob_off,
378                knob_on: scheme.primary_container,
379                border_off: with_alpha(scheme.on_surface, BORDER_OFF_ALPHA),
380                border_on: with_alpha(scheme.primary, border_on_alpha),
381                knob_shadow,
382            }
383        }
384        None => ToggleColors {
385            track_off: TOGGLE_TRACK_OFF,
386            track_on: with_alpha(TOGGLE_ACCENT, TRACK_ON_ALPHA_DARK),
387            knob_off: TOGGLE_KNOB_OFF,
388            knob_on: TOGGLE_ACCENT,
389            border_off: with_alpha(TOGGLE_BORDER_INK, BORDER_OFF_ALPHA),
390            border_on: with_alpha(TOGGLE_ACCENT, BORDER_ON_ALPHA_DARK),
391            knob_shadow: None,
392        },
393    }
394}
395
396/// Track radius (`--radius-full`) — themed `shape.full` resolved against the
397/// box (always a pill), else the literal half-height.
398fn track_radius(theme: Option<&Theme>, size: Size) -> f64 {
399    match theme {
400        Some(theme) => ShapeScale::resolve(theme.shape.full, size.width, size.height),
401        None => size.height / 2.0,
402    }
403}
404
405/// A view-held, typed toggle callback (erased on build/rebuild).
406type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
407
408/// A declarative Glyph toggle. See the [module docs](self).
409pub struct ToggleView<State: 'static> {
410    checked: bool,
411    label: Option<String>,
412    on_toggle: OnToggle<State>,
413}
414
415/// Create a toggle reflecting `checked` that fires `on_toggle(state, !checked)`
416/// on a release inside its bounds — a **controlled** component, mirroring
417/// `frust_material::switch`'s shape.
418pub fn toggle<State: 'static, F: Fn(&mut State, bool) + 'static>(
419    checked: bool,
420    on_toggle: F,
421) -> ToggleView<State> {
422    ToggleView {
423        checked,
424        label: None,
425        on_toggle: Rc::new(on_toggle),
426    }
427}
428
429/// PascalCase alias for [`toggle`], matching the widget-fn vocabulary
430/// (`Button`/`Image`/…).
431#[allow(non_snake_case)]
432pub fn Toggle<State: 'static, F: Fn(&mut State, bool) + 'static>(
433    checked: bool,
434    on_toggle: F,
435) -> ToggleView<State> {
436    toggle(checked, on_toggle)
437}
438
439impl<State: 'static> ToggleView<State> {
440    /// Name the control for assistive tech (the semantics node's label — a
441    /// switch has no visible text of its own to derive one from).
442    pub fn label(mut self, label: impl Into<String>) -> Self {
443        self.label = Some(label.into());
444        self
445    }
446}
447
448/// The retained widget for a [`ToggleView`].
449pub struct ToggleWidget {
450    /// The app-confirmed value (source of truth; adopted on `rebuild`).
451    checked: bool,
452    label: Option<String>,
453    /// Knob position, `0.0` off .. `1.0` on (read unclamped — it overshoots).
454    knob: Progress,
455    /// Track/border/knob color blend, `0.0` off .. `1.0` on.
456    fade: Progress,
457    /// Armed by a `Down` (alongside `capture_pointer`), cleared on
458    /// `Up`/`Cancel`. Nothing paints differently while armed — Glyph authors no
459    /// pressed state (see the module docs).
460    captured: bool,
461    on_toggle: frust::authoring::ErasedArgCallback<bool>,
462}
463
464fn inside(pos: Point, size: Size) -> bool {
465    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
466}
467
468impl<State: 'static> View<State> for ToggleView<State> {
469    type Element = ToggleWidget;
470
471    fn build(&self, _ctx: &mut BuildCtx<'_>) -> ToggleWidget {
472        let rest = if self.checked { 1.0 } else { 0.0 };
473        ToggleWidget {
474            checked: self.checked,
475            label: self.label.clone(),
476            knob: Progress::at_rest(rest, KNOB_DURATION, KNOB_CURVE),
477            fade: Progress::at_rest(rest, FADE_DURATION, FADE_CURVE),
478            captured: false,
479            on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
480        }
481    }
482
483    fn rebuild(
484        &self,
485        prev: &Self,
486        element: &mut ToggleWidget,
487        _ctx: &mut BuildCtx<'_>,
488    ) -> ChangeFlags {
489        // Closures aren't comparable — always reinstall the adapter.
490        element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
491        let mut flags = ChangeFlags::NONE;
492        if prev.label != self.label {
493            element.label = self.label.clone();
494            // Semantics-only, but `PAINT` is what bumps the root's semantics
495            // dirty gate, and there is no narrower flag.
496            flags |= ChangeFlags::PAINT;
497        }
498        if prev.checked != self.checked {
499            // The app is the source of truth: adopt the value and animate to
500            // it. Both lanes' timing is a constant, so `rebuild` can retarget
501            // them without waiting for a paint-time theme.
502            element.checked = self.checked;
503            let target = if self.checked { 1.0 } else { 0.0 };
504            element.knob.retarget(target);
505            element.fade.retarget(target);
506            flags |= ChangeFlags::PAINT;
507        }
508        flags
509    }
510}
511
512impl Widget for ToggleWidget {
513    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
514        // The touch slab, not the pill — see `TOUCH_TARGET`.
515        bc.constrain(Size::new(
516            TRACK_W.max(TOUCH_TARGET),
517            TRACK_H.max(TOUCH_TARGET),
518        ))
519    }
520
521    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
522        // Everything theme-derived is resolved (and copied out) up front, so
523        // the borrow ends before the animation asks for the next frame.
524        let track_size = Size::new(TRACK_W, TRACK_H);
525        let theme = Theme::from_paint_ctx(ctx);
526        let colors = resolve_toggle_colors(theme);
527        let radius = track_radius(theme, track_size);
528        let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
529
530        if reduce_motion {
531            // Land both lanes on the confirmed state and owe no frame.
532            self.knob.snap();
533            self.fade.snap();
534        } else {
535            // Both lanes advance every paint; either one still in flight owes
536            // the next frame (paint-only — the slab's size never changes).
537            let now = ctx.frame_time();
538            let knob_running = self.knob.advance(now);
539            let fade_running = self.fade.advance(now);
540            if knob_running || fade_running {
541                ctx.request_frame();
542            }
543        }
544
545        // The pill keeps its authored size and centers in the slab, so paint
546        // and hit-testing agree however the box was constrained.
547        let box_size = ctx.size();
548        let origin = ctx.origin()
549            + Vec2::new(
550                ((box_size.width - TRACK_W) / 2.0).max(0.0),
551                ((box_size.height - TRACK_H) / 2.0).max(0.0),
552            );
553        let fade = self.fade.value_clamped();
554
555        // Track fill, then its hairline border — stroked on the border's own
556        // centerline (inset half a border) so the painted box is border-box
557        // exact.
558        scene.fill_rounded_rect(
559            origin,
560            track_size,
561            radius,
562            lerp_color(colors.track_off, colors.track_on, fade),
563        );
564        let inset = BORDER_W / 2.0;
565        let outline = RoundedRect::from_rect(
566            Rect::from_origin_size(Point::ORIGIN, track_size).inset(-inset),
567            (radius - inset).max(0.0),
568        );
569        scene.stroke_path(
570            origin,
571            &Shape::to_path(&outline, PATH_TOLERANCE),
572            BORDER_W,
573            &Brush::Solid(lerp_color(colors.border_off, colors.border_on, fade)),
574        );
575
576        // Knob: unclamped travel (the spring's overshoot is intended), with the
577        // light-mode lift shadow beneath it.
578        let knob_origin =
579            origin + Vec2::new(KNOB_OFFSET + KNOB_TRAVEL * self.knob.value(), KNOB_OFFSET);
580        let knob_size = Size::new(KNOB_SIZE, KNOB_SIZE);
581        let knob_radius = KNOB_SIZE / 2.0;
582        if let Some(shadow) = colors.knob_shadow {
583            scene.draw_shadow(
584                knob_origin + Vec2::new(0.0, KNOB_SHADOW_DY),
585                knob_size,
586                knob_radius,
587                KNOB_SHADOW_BLUR,
588                shadow,
589            );
590        }
591        scene.fill_rounded_rect(
592            knob_origin,
593            knob_size,
594            knob_radius,
595            lerp_color(colors.knob_off, colors.knob_on, fade),
596        );
597    }
598
599    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
600        let InputEvent::Pointer(p) = event else {
601            return EventResult::Ignored;
602        };
603        match p.phase {
604            PointerPhase::Down => {
605                if !presses(p) {
606                    return EventResult::Ignored;
607                }
608                self.captured = true;
609                ctx.capture_pointer();
610                EventResult::Handled
611            }
612            PointerPhase::Move => {
613                // Nothing paints differently while armed, so a captured move
614                // is consumed without a redraw; `Up` re-tests the position.
615                if self.captured {
616                    EventResult::Handled
617                } else {
618                    EventResult::Ignored
619                }
620            }
621            PointerPhase::Up => {
622                if !self.captured {
623                    return EventResult::Ignored;
624                }
625                self.captured = false;
626                // `ctx.size()` is the touch slab, so the margin around the pill
627                // is live too.
628                if inside(p.position, ctx.size()) {
629                    // Report the *requested* value; never self-toggle.
630                    (self.on_toggle)(ctx, !self.checked);
631                }
632                EventResult::Handled
633            }
634            PointerPhase::Cancel => {
635                if !self.captured {
636                    return EventResult::Ignored;
637                }
638                self.captured = false;
639                EventResult::Handled
640            }
641        }
642    }
643
644    fn semantics(&self, ctx: &mut SemanticsCtx) {
645        ctx.push_node(Role::Switch, |node| {
646            node.set_toggled(Toggled::from(self.checked));
647            node.add_action(Action::Click);
648            if let Some(label) = &self.label {
649                node.set_label(label.as_str());
650            }
651        });
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use frust::authoring::{PointerButton, PointerEvent};
659    use std::any::Any;
660
661    #[derive(Default)]
662    struct ToggleState {
663        last: Option<bool>,
664        toggles: u32,
665    }
666
667    /// Records the paint commands the token tables assert over, in paint order:
668    /// `rrects` = [track, knob], `strokes` = [border], `shadows` = the
669    /// light-only knob lift.
670    #[derive(Default)]
671    struct Recorder {
672        rrects: Vec<(Point, Size, f64, Color)>,
673        strokes: Vec<Color>,
674        shadows: Vec<(Point, Size, f64, f64, Color)>,
675    }
676
677    impl PaintScene for Recorder {
678        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
679        fn draw_text(&mut self, _o: Point, _t: &str) {}
680        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
681            self.rrects.push((o, s, radius, color));
682        }
683        fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
684            if let Brush::Solid(c) = brush {
685                self.strokes.push(*c);
686            }
687        }
688        fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
689            self.shadows.push((o, s, radius, std_dev, color));
690        }
691    }
692
693    fn ft_ms(ms: f64) -> FrameTime {
694        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
695    }
696
697    /// The laid-out box: the 44-square touch slab, not the pill.
698    const SLAB: Size = Size::new(TOUCH_TARGET, TOUCH_TARGET);
699
700    /// Where the centered pill starts inside [`SLAB`] — every geometry
701    /// assertion below is expressed relative to this, so the source-faithful
702    /// pill numbers stay readable.
703    fn pill_origin() -> Point {
704        Point::new(
705            (TOUCH_TARGET - TRACK_W) / 2.0,
706            (TOUCH_TARGET - TRACK_H) / 2.0,
707        )
708    }
709
710    fn widget(checked: bool) -> ToggleWidget {
711        build(&toggle::<ToggleState, _>(
712            checked,
713            |s: &mut ToggleState, v| {
714                s.last = Some(v);
715                s.toggles += 1;
716            },
717        ))
718    }
719
720    fn build(view: &ToggleView<ToggleState>) -> ToggleWidget {
721        let mut counter = 0u64;
722        View::<ToggleState>::build(view, &mut BuildCtx::new(&mut counter))
723    }
724
725    /// Paint at frame time `ms`, returning the recorder and whether the widget
726    /// asked for another frame.
727    fn paint_at(w: &mut ToggleWidget, theme: Option<&Theme>, ms: f64) -> (Recorder, bool) {
728        let mut rec = Recorder::default();
729        let base = PaintCtx::for_test(Point::ZERO, SLAB, ft_ms(ms));
730        let mut ctx = match theme {
731            Some(t) => base.with_theme(t),
732            None => base,
733        };
734        w.paint(&mut ctx, &mut rec);
735        let again = ctx.needs_frame();
736        (rec, again)
737    }
738
739    fn paint(w: &mut ToggleWidget, theme: Option<&Theme>) -> Recorder {
740        paint_at(w, theme, 0.0).0
741    }
742
743    /// The knob's x offset **inside the pill** — the source's own frame of
744    /// reference (`0` off, `KNOB_TRAVEL` across), independent of where the pill
745    /// sits in the slab.
746    fn knob_x(rec: &Recorder) -> f64 {
747        rec.rrects[1].0.x - pill_origin().x
748    }
749
750    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
751        InputEvent::Pointer(PointerEvent {
752            phase,
753            position: Point::new(x, y),
754            button: PointerButton::Primary,
755        })
756    }
757
758    /// The same event on the secondary (right) button.
759    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
760        InputEvent::Pointer(PointerEvent {
761            phase,
762            position: Point::new(x, y),
763            button: PointerButton::Secondary,
764        })
765    }
766
767    fn dispatch(w: &mut ToggleWidget, state: &mut ToggleState, event: &InputEvent) {
768        let state_any: &mut dyn Any = state;
769        let mut ctx = EventCtx::new(state_any, Point::ZERO, SLAB);
770        w.event(&mut ctx, event);
771    }
772
773    #[test]
774    fn a_secondary_press_never_captures_or_toggles() {
775        let mut w = widget(false);
776        let mut state = ToggleState::default();
777        dispatch(
778            &mut w,
779            &mut state,
780            &secondary_ev(PointerPhase::Down, 22.0, 22.0),
781        );
782        assert!(!w.captured, "no capture for the shell to wedge on");
783        dispatch(
784            &mut w,
785            &mut state,
786            &secondary_ev(PointerPhase::Up, 22.0, 22.0),
787        );
788        assert_eq!(state.toggles, 0);
789
790        // The primary gesture still toggles.
791        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 22.0, 22.0));
792        assert!(w.captured);
793        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 22.0, 22.0));
794        assert_eq!(state.toggles, 1);
795        assert_eq!(state.last, Some(true));
796    }
797
798    // ---- Token tables ------------------------------------------------------
799
800    #[test]
801    fn glyph_dark_theme_paints_the_source_token_table() {
802        let theme = crate::baseline();
803        let scheme = theme.scheme();
804
805        let mut off = widget(false);
806        let rec = paint(&mut off, Some(&theme));
807        assert_eq!(rec.rrects.len(), 2, "track + knob only");
808        // Track: `--bg-overlay` #272d3d, a pill centered in the slab.
809        assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
810        assert_eq!(rec.rrects[0].3, Color::from_rgb8(0x27, 0x2d, 0x3d));
811        assert_eq!(rec.rrects[0].1, Size::new(TRACK_W, TRACK_H));
812        assert_eq!(rec.rrects[0].0, pill_origin());
813        assert_eq!(rec.rrects[0].2, TRACK_H / 2.0);
814        // Border: `--border-bright` rgba(242,234,217,0.18).
815        assert_eq!(
816            rec.strokes[0],
817            with_alpha(Color::from_rgb8(0xf2, 0xea, 0xd9), 0.18)
818        );
819        // Knob: `--fg-dim`'s themed stand-in — `on_surface_variant` #a39c88,
820        // NOT the authored #6b6556 (the documented role gap; the divergence is
821        // pinned here so it can't drift silently).
822        assert_eq!(rec.rrects[1].3, scheme.on_surface_variant);
823        assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xa3, 0x9c, 0x88));
824        assert_ne!(rec.rrects[1].3, TOGGLE_KNOB_OFF, "and it is not `--fg-dim`");
825        assert_eq!(rec.rrects[1].1, Size::new(KNOB_SIZE, KNOB_SIZE));
826        assert_eq!(knob_x(&rec), KNOB_OFFSET, "off rests at the pill inset");
827        assert_eq!(rec.rrects[1].0.y, pill_origin().y + KNOB_OFFSET);
828        assert!(rec.shadows.is_empty(), "dark authors no knob shadow");
829
830        let mut on = widget(true);
831        let rec = paint(&mut on, Some(&theme));
832        // Track: `--amber-faint` rgba(255,182,39,0.12).
833        assert_eq!(
834            rec.rrects[0].3,
835            with_alpha(Color::from_rgb8(0xff, 0xb6, 0x27), 0.12)
836        );
837        // Border: `--amber-border` rgba(255,182,39,0.35).
838        assert_eq!(
839            rec.strokes[0],
840            with_alpha(Color::from_rgb8(0xff, 0xb6, 0x27), 0.35)
841        );
842        // Knob: `--amber` #ffb627 — the bright-fill role, not accent text.
843        assert_eq!(rec.rrects[1].3, scheme.primary_container);
844        assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xb6, 0x27));
845        assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
846        assert!(rec.shadows.is_empty());
847    }
848
849    #[test]
850    fn glyph_light_theme_paints_a_white_knob_with_a_lift_shadow() {
851        let theme = crate::baseline().with_brightness(Brightness::Light);
852        let scheme = theme.scheme();
853
854        let mut off = widget(false);
855        let rec = paint(&mut off, Some(&theme));
856        // Track: `--bg-overlay` #e8e1d0 (the light ramp's darkest slot).
857        assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
858        assert_eq!(rec.rrects[0].3, Color::from_rgb8(0xe8, 0xe1, 0xd0));
859        // Border: `--border-bright` rgba(34,29,18,0.18).
860        assert_eq!(
861            rec.strokes[0],
862            with_alpha(Color::from_rgb8(0x22, 0x1d, 0x12), 0.18)
863        );
864        // Knob: the white `--bg-surface` slot, over a 0 1px 2px ink lift.
865        assert_eq!(rec.rrects[1].3, scheme.surface_container_lowest);
866        assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xff, 0xff));
867        assert_eq!(knob_x(&rec), KNOB_OFFSET);
868        assert_eq!(rec.shadows.len(), 1, "light lifts the knob");
869        let (shadow_origin, shadow_size, _, blur, shadow_color) = rec.shadows[0];
870        assert_eq!(
871            shadow_origin,
872            pill_origin() + Vec2::new(KNOB_OFFSET, KNOB_OFFSET + KNOB_SHADOW_DY)
873        );
874        assert_eq!(shadow_size, Size::new(KNOB_SIZE, KNOB_SIZE));
875        assert_eq!(blur, KNOB_SHADOW_BLUR);
876        assert_eq!(shadow_color, with_alpha(scheme.shadow, KNOB_SHADOW_ALPHA));
877        // The `shadow` role is warm ink #221d12 on Glyph light, not black.
878        assert_eq!(
879            shadow_color,
880            with_alpha(Color::from_rgb8(0x22, 0x1d, 0x12), KNOB_SHADOW_ALPHA)
881        );
882
883        let mut on = widget(true);
884        let rec = paint(&mut on, Some(&theme));
885        // Track: `--amber-faint` rgba(163,101,10,0.10) — a re-tuned light alpha.
886        assert_eq!(
887            rec.rrects[0].3,
888            with_alpha(Color::from_rgb8(0xa3, 0x65, 0x0a), 0.10)
889        );
890        // Border: `--amber-border` rgba(163,101,10,0.38).
891        assert_eq!(
892            rec.strokes[0],
893            with_alpha(Color::from_rgb8(0xa3, 0x65, 0x0a), 0.38)
894        );
895        // Knob: `--amber-fill` #ffb627 — identical to dark.
896        assert_eq!(rec.rrects[1].3, scheme.primary_container);
897        assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xb6, 0x27));
898        assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
899        // The source never overrides `box-shadow` on `:checked`.
900        assert_eq!(rec.shadows.len(), 1, "the lift survives the checked state");
901    }
902
903    #[test]
904    fn unthemed_paint_uses_the_dark_fallback_constants() {
905        let mut off = widget(false);
906        let rec = paint(&mut off, None);
907        assert_eq!(rec.rrects[0].3, TOGGLE_TRACK_OFF);
908        assert_eq!(rec.rrects[0].2, TRACK_H / 2.0, "pill radius");
909        assert_eq!(rec.rrects[1].3, TOGGLE_KNOB_OFF, "the exact `fg-dim` hex");
910        assert_eq!(
911            rec.strokes[0],
912            with_alpha(TOGGLE_BORDER_INK, BORDER_OFF_ALPHA)
913        );
914        assert!(rec.shadows.is_empty());
915
916        let mut on = widget(true);
917        let rec = paint(&mut on, None);
918        assert_eq!(
919            rec.rrects[0].3,
920            with_alpha(TOGGLE_ACCENT, TRACK_ON_ALPHA_DARK)
921        );
922        assert_eq!(rec.rrects[1].3, TOGGLE_ACCENT);
923        assert_eq!(
924            rec.strokes[0],
925            with_alpha(TOGGLE_ACCENT, BORDER_ON_ALPHA_DARK)
926        );
927    }
928
929    // ---- Layout slab + centered pill --------------------------------------
930
931    #[test]
932    fn layout_requests_a_touch_slab_around_the_pill() {
933        let mut w = widget(false);
934        let mut ctx = LayoutCtx::new();
935        let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
936        assert_eq!(size, SLAB);
937        assert!(
938            size.width > TRACK_W && size.height > TRACK_H,
939            "the interactive box is larger than the 38×22 pill it paints"
940        );
941        assert!(
942            size.width >= TOUCH_TARGET && size.height >= TOUCH_TARGET,
943            "and clears WCAG 2.2 / iOS's minimum on both axes (4dp under \
944             Android's 48dp, accepted — see `TOUCH_TARGET`'s doc)"
945        );
946    }
947
948    #[test]
949    fn paint_centers_the_fixed_pill_in_whatever_box_it_is_given() {
950        // A stretched/tightened box must move the pill, never scale it — that
951        // is what keeps paint and hit-testing (the whole box) in agreement.
952        let mut w = widget(true);
953        let mut rec = Recorder::default();
954        let box_size = Size::new(80.0, 60.0);
955        let mut ctx = PaintCtx::for_test(Point::new(10.0, 20.0), box_size, ft_ms(0.0));
956        w.paint(&mut ctx, &mut rec);
957
958        let expected = Point::new(
959            10.0 + (box_size.width - TRACK_W) / 2.0,
960            20.0 + (box_size.height - TRACK_H) / 2.0,
961        );
962        assert_eq!(rec.rrects[0].0, expected);
963        assert_eq!(rec.rrects[0].1, Size::new(TRACK_W, TRACK_H), "never scaled");
964        // The knob's on-state rest spot rides with the pill.
965        assert_eq!(
966            rec.rrects[1].0,
967            expected + Vec2::new(KNOB_OFFSET + KNOB_TRAVEL, KNOB_OFFSET)
968        );
969    }
970
971    #[test]
972    fn the_overshooting_knob_stays_inside_the_slab() {
973        // The spring reads unclamped, so the peak is past the rest spot — it
974        // must still land inside the painted box.
975        let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
976        let mut w = build(&prev);
977        let next = toggle::<ToggleState, _>(true, |_s, _v| {});
978        let mut counter = 0u64;
979        View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
980        paint_at(&mut w, None, 0.0);
981
982        let mut peak_right = f64::MIN;
983        for ms in (10..=210).step_by(10) {
984            let (rec, _) = paint_at(&mut w, None, ms as f64);
985            peak_right = peak_right.max(rec.rrects[1].0.x + KNOB_SIZE);
986        }
987        let rest_right = pill_origin().x + KNOB_OFFSET + KNOB_TRAVEL + KNOB_SIZE;
988        assert!(
989            peak_right > rest_right,
990            "the overshoot is real ({peak_right} vs {rest_right})"
991        );
992        assert!(
993            peak_right <= pill_origin().x + TRACK_W - BORDER_W,
994            "yet the knob never escapes the track's inner edge ({peak_right})"
995        );
996        assert!(
997            peak_right <= SLAB.width,
998            "nor the slab ({peak_right} vs {})",
999            SLAB.width
1000        );
1001    }
1002
1003    #[test]
1004    fn a_tap_in_the_slab_margin_outside_the_pill_still_toggles() {
1005        let mut w = widget(false);
1006        let mut state = ToggleState::default();
1007        // Bottom-right corner of the slab: outside the pill, inside the target.
1008        let (x, y) = (TOUCH_TARGET - 1.0, TOUCH_TARGET - 1.0);
1009        assert!(
1010            x > pill_origin().x + TRACK_W && y > pill_origin().y + TRACK_H,
1011            "the probe really is outside the painted pill"
1012        );
1013        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, x, y));
1014        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, x, y));
1015        assert_eq!(state.last, Some(true));
1016        assert_eq!(state.toggles, 1);
1017    }
1018
1019    // ---- Knob travel + frame requests -------------------------------------
1020
1021    #[test]
1022    fn knob_travels_the_source_distance_and_settles() {
1023        let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
1024        let mut w = build(&prev);
1025        let (rec, again) = paint_at(&mut w, None, 0.0);
1026        assert_eq!(knob_x(&rec), KNOB_OFFSET, "off rests at the inset");
1027        assert!(!again, "a resting toggle requests no frame");
1028
1029        // The app confirms the flip; the animation starts in `rebuild`.
1030        let next = toggle::<ToggleState, _>(true, |_s, _v| {});
1031        let mut counter = 0u64;
1032        let flags =
1033            View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1034        assert!(flags.needs_paint());
1035        assert!(!flags.needs_layout(), "the track never resizes");
1036
1037        // The first paint only seeds the animation clock (see
1038        // `AnimationController::advance`), so travel starts on the next one.
1039        let (rec, again) = paint_at(&mut w, None, 0.0);
1040        assert_eq!(knob_x(&rec), KNOB_OFFSET);
1041        assert!(again, "an in-flight knob owes another frame");
1042
1043        let settled_x = KNOB_OFFSET + KNOB_TRAVEL;
1044        let mut peak_x = f64::MIN;
1045        for ms in (10..=210).step_by(10) {
1046            let (rec, again) = paint_at(&mut w, None, ms as f64);
1047            peak_x = peak_x.max(knob_x(&rec));
1048            assert!(again, "still animating at {ms}ms");
1049        }
1050        assert!(
1051            peak_x > settled_x,
1052            "the spatial curve overshoots its rest spot ({peak_x} vs {settled_x})"
1053        );
1054
1055        let (rec, again) = paint_at(&mut w, None, 400.0);
1056        assert_eq!(knob_x(&rec), settled_x, "lands exactly 16px across");
1057        assert!(!again, "a settled toggle stops requesting frames");
1058        assert_eq!(rec.rrects[1].3, TOGGLE_ACCENT, "and the fade completed");
1059    }
1060
1061    #[test]
1062    fn a_reversal_travels_back_to_the_off_inset() {
1063        let prev = toggle::<ToggleState, _>(true, |_s, _v| {});
1064        let mut w = build(&prev);
1065        let next = toggle::<ToggleState, _>(false, |_s, _v| {});
1066        let mut counter = 0u64;
1067        View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1068        let (_, again) = paint_at(&mut w, None, 0.0);
1069        assert!(again);
1070        let (rec, again) = paint_at(&mut w, None, 400.0);
1071        assert_eq!(knob_x(&rec), KNOB_OFFSET);
1072        assert!(!again);
1073        assert_eq!(rec.rrects[1].3, TOGGLE_KNOB_OFF);
1074    }
1075
1076    #[test]
1077    fn reduce_motion_paints_the_final_state_at_once_and_requests_no_frame() {
1078        let mut theme = crate::baseline();
1079        theme.motion.reduce_motion = true;
1080
1081        let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
1082        let mut w = build(&prev);
1083        let next = toggle::<ToggleState, _>(true, |_s, _v| {});
1084        let mut counter = 0u64;
1085        View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1086
1087        // The first paint after the flip is already the settled state — no
1088        // seeding frame, no travel, nothing owed.
1089        let (rec, again) = paint_at(&mut w, Some(&theme), 0.0);
1090        assert!(!again, "reduce_motion owes no frame");
1091        assert_eq!(
1092            knob_x(&rec),
1093            KNOB_OFFSET + KNOB_TRAVEL,
1094            "the knob lands immediately"
1095        );
1096        assert_eq!(
1097            rec.rrects[1].3,
1098            theme.scheme().primary_container,
1099            "and the color lane is done too"
1100        );
1101
1102        // And it stays there: a later frame neither moves nor re-requests.
1103        let (rec, again) = paint_at(&mut w, Some(&theme), 400.0);
1104        assert!(!again);
1105        assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
1106    }
1107
1108    #[test]
1109    fn reduce_motion_snaps_a_reversal_back_as_well() {
1110        let mut theme = crate::baseline();
1111        theme.motion.reduce_motion = true;
1112
1113        let prev = toggle::<ToggleState, _>(true, |_s, _v| {});
1114        let mut w = build(&prev);
1115        let next = toggle::<ToggleState, _>(false, |_s, _v| {});
1116        let mut counter = 0u64;
1117        View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1118
1119        let (rec, again) = paint_at(&mut w, Some(&theme), 0.0);
1120        assert!(!again);
1121        assert_eq!(knob_x(&rec), KNOB_OFFSET);
1122        assert_eq!(rec.rrects[1].3, theme.scheme().on_surface_variant);
1123    }
1124
1125    // ---- Interaction -------------------------------------------------------
1126
1127    #[test]
1128    fn release_inside_reports_the_requested_value_once() {
1129        let mut w = widget(false);
1130        let mut state = ToggleState::default();
1131        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1132        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
1133        assert_eq!(state.last, Some(true));
1134        assert_eq!(state.toggles, 1);
1135        assert!(!w.checked, "a controlled toggle never self-mutates");
1136
1137        let mut w = widget(true);
1138        let mut state = ToggleState::default();
1139        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1140        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
1141        assert_eq!(state.last, Some(false));
1142        assert!(w.checked, "still checked until the app rebuilds it");
1143    }
1144
1145    #[test]
1146    fn release_outside_does_not_fire() {
1147        let mut w = widget(false);
1148        let mut state = ToggleState::default();
1149        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1150        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 11.0));
1151        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 11.0));
1152        assert_eq!(state.toggles, 0);
1153        assert!(!w.captured, "the release disarms either way");
1154    }
1155
1156    #[test]
1157    fn cancel_disarms_the_press() {
1158        let mut w = widget(false);
1159        let mut state = ToggleState::default();
1160        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1161        assert!(w.captured);
1162        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 8.0, 11.0));
1163        assert!(!w.captured);
1164        // A post-cancel Up is not this widget's gesture any more.
1165        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
1166        assert_eq!(state.toggles, 0);
1167    }
1168
1169    #[test]
1170    fn an_unarmed_move_is_ignored() {
1171        let mut w = widget(false);
1172        let mut state = ToggleState::default();
1173        let state_any: &mut dyn Any = &mut state;
1174        let mut ctx = EventCtx::new(state_any, Point::ZERO, SLAB);
1175        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 8.0, 11.0));
1176        assert!(matches!(result, EventResult::Ignored));
1177        assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
1178    }
1179
1180    // ---- Semantics ---------------------------------------------------------
1181
1182    #[test]
1183    fn semantics_reports_a_labelled_switch_with_its_toggled_state() {
1184        // `SemanticsCtx`'s constructor is crate-private to `frust-core`, so
1185        // this drives a real `RenderRoot` pass rather than the context itself
1186        // (the `crate::segmented_control` precedent).
1187        fn logic(_s: &mut ToggleState) -> ToggleView<ToggleState> {
1188            toggle::<ToggleState, _>(true, |_s, _v| {}).label("auto-reconnect")
1189        }
1190        let mut root: frust_core::RenderRoot<ToggleState, ToggleView<ToggleState>> =
1191            frust_core::RenderRoot::new();
1192        let mut state = ToggleState::default();
1193        root.rebuild(&mut logic, &mut state);
1194        root.layout(Size::new(200.0, 200.0));
1195        let update = root.semantics();
1196
1197        let (_, node) = update
1198            .nodes
1199            .iter()
1200            .find(|(_, n)| n.role() == Role::Switch)
1201            .expect("the toggle contributes a Role::Switch node");
1202        assert_eq!(node.toggled(), Some(Toggled::True));
1203        assert!(node.supports_action(Action::Click));
1204        assert_eq!(node.label(), Some("auto-reconnect"));
1205        // The a11y bounds are the touch slab, not the painted pill — the
1206        // bigger target is the point.
1207        let bounds = node.bounds().expect("the switch node has bounds");
1208        assert_eq!(
1209            (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0),
1210            (TOUCH_TARGET, TOUCH_TARGET)
1211        );
1212    }
1213
1214    #[test]
1215    fn semantics_mirrors_an_unchecked_unlabelled_toggle() {
1216        fn logic(_s: &mut ToggleState) -> ToggleView<ToggleState> {
1217            toggle::<ToggleState, _>(false, |_s, _v| {})
1218        }
1219        let mut root: frust_core::RenderRoot<ToggleState, ToggleView<ToggleState>> =
1220            frust_core::RenderRoot::new();
1221        let mut state = ToggleState::default();
1222        root.rebuild(&mut logic, &mut state);
1223        root.layout(Size::new(200.0, 200.0));
1224        let update = root.semantics();
1225
1226        let (_, node) = update
1227            .nodes
1228            .iter()
1229            .find(|(_, n)| n.role() == Role::Switch)
1230            .expect("the toggle contributes a Role::Switch node");
1231        assert_eq!(node.toggled(), Some(Toggled::False));
1232        assert_eq!(node.label(), None);
1233    }
1234
1235    #[test]
1236    fn a_label_change_alone_is_a_republish() {
1237        let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
1238        let mut w = build(&prev);
1239        assert_eq!(w.label, None);
1240        let next = toggle::<ToggleState, _>(false, |_s, _v| {}).label("haptics");
1241        let mut counter = 0u64;
1242        let flags =
1243            View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1244        assert_eq!(w.label.as_deref(), Some("haptics"));
1245        assert!(
1246            flags.needs_paint(),
1247            "the semantics dirty gate rides `PAINT`"
1248        );
1249    }
1250}