Skip to main content

frust_material/
switch.rs

1//! The Material 3 Expressive `Switch` toggle: a controlled component
2//! reporting a requested on/off value, painted as a 52×32dp track with a
3//! spring-driven thumb (16dp unselected / 24dp selected, 32dp while pressed)
4//! and the shared [`super::state_layer`] interaction overlay.
5//!
6//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
7//! `lib/components/switch_control/m3e_switch_control.dart` +
8//! `styles/m3e_switch_theme.dart` (retrieved 2026-08-19).
9//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
10//!
11//! [`switch`] produces a [`SwitchView`] carrying the current `checked` value
12//! and an `on_toggle` closure — a controlled component mirroring
13//! [`frust::Checkbox`]: it fires `on_toggle(state, !checked)` on release
14//! inside its bounds and never flips its own `checked` field; the app
15//! mutates its state and the next `rebuild` feeds the confirmed value back
16//! in.
17//!
18//! # Two independent springs, not one
19//!
20//! The reference drives the thumb's slide and its size off two *separate*
21//! `SingleMotionController`s (`m3e_switch_control.dart:60-61`), both built on
22//! `MaterialSpringMotion.expressiveSpatialDefault` (stiffness `380`,
23//! `tmp/motor-1.1.0/lib/src/motion.dart:610-616`) but each `copyWith`-ing a
24//! different damping ratio: position at `0.55` (a visible overshoot "snap")
25//! and size at `0.7` (a more directly-settling follow) —
26//! `m3e_switch_control.dart:66-76`. [`POSITION_SPRING`]/[`SIZE_SPRING`]
27//! transcribe those two specs exactly. Both are driven in-widget during
28//! [`Widget::paint`] via [`AnimationController::fling`], re-requested with
29//! [`PaintCtx::request_frame`] while either is in flight (the crate's shared
30//! advance-during-paint contract — see `docs/CODE_STANDARDS.md`'s Theming &
31//! Animation Conventions). `rebuild` only ever updates the *confirmed*
32//! `checked` value; both flings start lazily the next time `paint` observes
33//! `checked` disagreeing with `anim_target`.
34//!
35//! Motor's `damping` parameter feeds `SpringDescription.withDurationAndBounce`-style
36//! construction as a damping *ratio* (`motion.dart:678-690`: `ratio: damping`),
37//! the same ζ semantics [`frust::SpringDesc::damping_ratio`] uses — so these two
38//! constants carry over as a direct `(mass, stiffness, damping_ratio)` triple
39//! with no unit conversion.
40//!
41//! # Thumb travel and press-grow
42//!
43//! The thumb's bounding box is computed in closed form
44//! (`m3e_switch_control.dart:132` padding + `:171-187`'s `_buildThumb`,
45//! collapsed from Flutter's nested `LayoutBuilder`-in-padding-with-bleed
46//! shape into one pair of expressions — the arithmetic is unchanged, only
47//! not re-derived from a separate constraints pass): [`thumb_origin`]. Both
48//! the position spring's `value()` (used for on-axis travel) and the size
49//! spring's `value()` (used for the unselected↔selected diameter blend) are
50//! read **unclamped** — an under-damped spring's overshoot past its target
51//! is real, intended motion (see [`AnimationController`]'s Overshoot docs),
52//! and the reference itself never clamps either read in this formula. A
53//! press instantly overrides the diameter to [`THUMB_PRESSED`] regardless of
54//! the size spring's own value (`m3e_switch_control.dart:171-173`) — this is
55//! not spring-driven; it snaps the instant `pressed` flips, same as the
56//! pre-rework behavior.
57//!
58//! # Icons: value-selected, size-faded
59//!
60//! [`SwitchView::selected_icon`]/[`SwitchView::unselected_icon`] each take an
61//! optional [`frust::IconSource`], painted inside the thumb
62//! (`m3e_switch_control.dart:233-258`). The reference does **not** cross-fade
63//! between the two icons — it picks *one* icon from the **confirmed**
64//! `checked` boolean (not the animated position), then fades/scales *that*
65//! icon by the **size** spring's clamped `[0, 1]` value
66//! (`:242`: `t = _sizeCtrl.value.clamp(0.0, 1.0)`; `:243-246`: `opacity: t`,
67//! `scale: 0.5 + 0.5 * t`). One consequence, transcribed faithfully rather
68//! than "fixed": toggling off shows the *unselected* icon (chosen the instant
69//! `checked` flips) fading out as the thumb *shrinks* (`t` falling from ~1 to
70//! 0), not fading in — the reference's own shape, not a symmetric two-icon
71//! cross-fade.
72//!
73//! # Track/thumb/outline/icon/state-layer colors
74//!
75//! [`SwitchColors`] plus [`track_color`]/[`thumb_color`]/[`outline_color`]/
76//! [`icon_color`]/[`state_layer_color`] transcribe
77//! `M3ESwitchTheme`'s five color methods exactly
78//! (`m3e_switch_theme.dart:76-123`), including that `icon_color`/
79//! `state_layer_color` take no `enabled` branch in the source — an
80//! asymmetry preserved here rather than "corrected". An off-track outline
81//! ring (`m3e_switch_theme.dart:103-113`'s `outlineColor`,
82//! `m3e_switch_control.dart:140-148`'s `Border.all`) paints only while
83//! `!checked`, at [`BORDER_WIDTH`].
84//!
85//! **Porting decision — continuous color, not the reference's separate
86//! crossfade.** The reference re-paints track/thumb/outline color through an
87//! outer `AnimatedContainer`'s own implicit ~150ms linear duration crossfade
88//! (`m3e_switch_control.dart:129`, `M3EMotion.short3`), a *third* animation
89//! channel independent of the two springs above. This port instead lerps
90//! each color pair by the position spring's `value_clamped()` — one fewer
91//! animation channel, and no harsher a transition than the reference's own
92//! short (150ms) one — the same continuous-color idiom the pre-rework
93//! version of this module used.
94//!
95//! # No drag-to-toggle
96//!
97//! The reference wires only a tap (`M3ETappable(onTap: ...)`,
98//! `m3e_switch_control.dart:121`) — no pan/drag gesture anywhere in the
99//! component. None is added here either.
100//!
101//! # Haptics: wired, firing `None`
102//!
103//! `M3ETappable` always fires a haptic on tap
104//! (`tmp/material_3_expressive/lib/foundations/m3e_tappable.dart:187-198`'s
105//! `_wrapTap`/`_fireHaptic`), but `M3ESwitch` never overrides `M3ETappable`'s
106//! `haptic` field, so it fires at that field's own default,
107//! `M3EHapticFeedback.none` — a documented no-op
108//! (`m3e_haptics.dart`'s `trigger` `none` case). This port matches that
109//! *exactly*: [`super::interaction::MaterialHaptics::fire`] is called with
110//! [`super::interaction::HapticSignal::None`] on every confirmed toggle,
111//! same call site the reference's `_fireHaptic` occupies (immediately before
112//! the tap's own callback), producing no audible/tactile feedback today —
113//! the hook is live for a future revision that wants to override it, exactly
114//! as `M3ETappable.haptic` is for the reference.
115//!
116//! # `enabled`, not a nullable callback
117//!
118//! The reference represents "disabled" as `onChanged == null`
119//! (`m3e_switch_control.dart:63`'s `_enabled` getter). This crate's
120//! controlled-component contract always takes a concrete `on_toggle`
121//! closure, so [`SwitchView::enabled`] is an explicit flag instead — same
122//! effect (gates interaction and switches the disabled color table), a
123//! different shape to fit this framework's callback contract.
124//!
125//! `focusNode`/`autofocus`/`semanticLabel` are Dart-only parameters
126//! (`m3e_switch_control.dart:16-26`) with no port here — none of the springs/
127//! icons/colors/haptics rework above touches focus routing or semantics
128//! labeling, so this stays out of scope for this pass.
129
130use std::rc::Rc;
131use std::time::Duration;
132
133use frust::authoring::{Action, Role, Toggled};
134use frust::authoring::{
135    BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
136    PaintScene, PointerPhase, SemanticsCtx, View, Widget,
137};
138use frust::{AnimationController, IconData, IconSource, SpringDesc, Theme, Tween};
139use kurbo::{Affine, BezPath, Point, Rect, RoundedRect, Shape, Size, Vec2};
140use peniko::{Brush, Color};
141
142use super::interaction::{HapticSignal, MaterialHaptics};
143use super::press::presses;
144use super::state_layer::StateLayer;
145
146/// Tessellation tolerance for the border/icon `BezPath`s (same value as
147/// [`super::split_button`]/[`super::button_group`]/[`super::sheet`]'s own
148/// `PATH_TOLERANCE`).
149const PATH_TOLERANCE: f64 = 0.1;
150
151/// Track width, in logical px (`m3e_switch_theme.dart:10`'s `trackWidth`
152/// default). No `Theme` size token exists for a fixed control dimension like
153/// this (`ShapeScale` publishes corner radii, not track/thumb sizes) —
154/// hoisted as a named constant rather than left as a bare literal.
155const TRACK_W: f64 = 52.0;
156/// Track height, in logical px (`m3e_switch_theme.dart:11`'s `trackHeight`).
157/// See [`TRACK_W`]'s doc comment — no suitable `Theme` token exists.
158const TRACK_H: f64 = 32.0;
159/// Uniform inset the thumb travels within, in logical px
160/// (`m3e_switch_theme.dart:12`'s `trackPadding`).
161const TRACK_PADDING: f64 = 4.0;
162/// Thumb diameter while pressed (either state), in logical px
163/// (`m3e_switch_theme.dart:13`'s `thumbSizePressed`) — overrides the
164/// unselected/selected interpolation while a press is in progress, instantly
165/// (not spring-driven; see the [module docs](self)).
166const THUMB_PRESSED: f64 = 32.0;
167/// Thumb diameter while selected, in logical px
168/// (`m3e_switch_theme.dart:14`'s `thumbSizeSelected`).
169const THUMB_SELECTED: f64 = 24.0;
170/// Thumb diameter while unselected, in logical px
171/// (`m3e_switch_theme.dart:15`'s `thumbSizeUnselected`).
172const THUMB_UNSELECTED: f64 = 16.0;
173/// Default diameter of the state-layer overlay painted behind the thumb, in
174/// logical px (`m3e_switch_theme.dart:16`'s `stateLayerSize`) —
175/// [`SwitchView::state_layer_size`] overrides it.
176const STATE_LAYER_SIZE_DEFAULT: f64 = 48.0;
177/// Side length of the optional per-state icon's own box, in logical px
178/// (`m3e_switch_theme.dart:17`'s `iconSize`), before the size spring's
179/// fade/scale is applied (see the [module docs](self)).
180const ICON_SIZE: f64 = 16.0;
181/// Width of the off-track outline ring, in logical px
182/// (`m3e_switch_theme.dart:18`'s `borderWidth`).
183const BORDER_WIDTH: f64 = 2.0;
184/// Track opacity while disabled (`m3e_switch_theme.dart:19`'s
185/// `disabledTrackOpacity`).
186const DISABLED_TRACK_OPACITY: f32 = 0.12;
187/// Thumb opacity while disabled (`m3e_switch_theme.dart:20`'s
188/// `disabledThumbOpacity`).
189const DISABLED_THUMB_OPACITY: f32 = 0.38;
190/// Outline opacity while disabled (`m3e_switch_theme.dart:21`'s
191/// `disabledOutlineOpacity`).
192const DISABLED_OUTLINE_OPACITY: f32 = 0.12;
193
194/// Unselected track fill (unthemed fallback; a theme resolves this from
195/// `colors.surface_container_highest`).
196const TRACK_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
197/// Selected track fill (unthemed fallback; a theme resolves this from
198/// `colors.primary`).
199const TRACK_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
200/// Unselected thumb fill (unthemed fallback; a theme resolves this from
201/// `colors.outline`).
202const THUMB_OFF: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
203/// Selected thumb fill (unthemed fallback; a theme resolves this from
204/// `colors.on_primary`).
205const THUMB_ON: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
206/// Unthemed fallback for `colors.on_surface` (disabled-state base color and
207/// the unselected state-layer color) — the same value
208/// `frust-widgets`' own `IconWidget` uses as its unthemed `on_surface`
209/// default, for the same "pre-theme app" reason.
210const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
211/// Unthemed fallback for `colors.on_primary_container` (the selected icon's
212/// color). No dedicated container-role fallback exists elsewhere in this
213/// module, so this reuses [`THUMB_ON`]'s value — light-on-saturated-primary,
214/// the same relationship `on_primary_container` has to `primary_container`
215/// in a real generated scheme.
216const FALLBACK_ON_PRIMARY_CONTAINER: Color = THUMB_ON;
217
218/// Position spring: `MaterialSpringMotion.expressiveSpatialDefault`
219/// (stiffness `380`, `tmp/motor-1.1.0/lib/src/motion.dart:610-616`) with
220/// damping overridden to `0.55` (`m3e_switch_control.dart:66-70`) — visibly
221/// overshoots before settling, the spec's "snap".
222const POSITION_SPRING: SpringDesc = SpringDesc {
223    mass: 1.0,
224    stiffness: 380.0,
225    damping_ratio: 0.55,
226};
227/// Size spring: the same `expressiveSpatialDefault` stiffness with damping
228/// overridden to `0.7` (`m3e_switch_control.dart:72-76`) — settles more
229/// directly than the position spring, so the thumb's size change trails its
230/// slide without as pronounced a bounce.
231const SIZE_SPRING: SpringDesc = SpringDesc {
232    mass: 1.0,
233    stiffness: 380.0,
234    damping_ratio: 0.7,
235};
236
237/// A sub-visible release velocity used only to select which target
238/// ([`AnimationController::fling`]'s `1.0`/`0.0`) each spring drives toward —
239/// the actual motion is governed by the displacement (`x0`), not this
240/// magnitude, so the "flick" is imperceptible; only its sign matters.
241const RELEASE_VELOCITY: f64 = 1e-3;
242
243/// Interpolate from `begin` to `end` at `t`, snapping exactly to an endpoint
244/// when `t` is at (or past) `0.0`/`1.0` rather than routing it through
245/// [`Tween::lerp`]'s `f32` arithmetic — which, unlike `f64`'s exact `x*1.0 ==
246/// x`/`x+0.0 == x` identities, can round `begin + (end - begin) * 1.0` to a
247/// value a few ULPs off `end` for colors with widely-separated channels.
248/// This keeps a fully-off/-on (at-rest, unanimated) switch pixel-identical to
249/// its resting color, matching every other widget's unthemed/themed-exact
250/// paint guarantee (see `docs/CODE_STANDARDS.md`'s Theming conventions).
251fn lerp_color_exact(begin: Color, end: Color, t: f64) -> Color {
252    if t <= 0.0 {
253        begin
254    } else if t >= 1.0 {
255        end
256    } else {
257        Tween::new(begin, end).lerp(t)
258    }
259}
260
261/// Return `color` with its alpha channel replaced by `alpha` (the same
262/// helper [`super::state_layer`]/[`super::button_group`] each carry their
263/// own copy of).
264fn with_alpha(color: Color, alpha: f32) -> Color {
265    let c = color.components;
266    Color::new([c[0], c[1], c[2], alpha])
267}
268
269/// The raw `ColorScheme` roles this module's color table reads, resolved
270/// once per paint from the threaded [`Theme`] (or the unthemed fallback
271/// constants) — see [`resolve_switch_colors`].
272#[derive(Clone, Copy)]
273struct SwitchColors {
274    on_surface: Color,
275    surface_container_highest: Color,
276    primary: Color,
277    outline: Color,
278    on_primary: Color,
279    on_primary_container: Color,
280}
281
282/// Resolve the six `ColorScheme` roles [`SwitchColors`] carries. Themed:
283/// straight from `theme.scheme()`. Unthemed: the module's own fallback
284/// constants — see the [module docs](self) for the "MaterialTokens-only
285/// resolution" contract this satisfies (no ad hoc `ThemeExtensions` type).
286fn resolve_switch_colors(theme: Option<&Theme>) -> SwitchColors {
287    match theme {
288        Some(theme) => {
289            let s = theme.scheme();
290            SwitchColors {
291                on_surface: s.on_surface,
292                surface_container_highest: s.surface_container_highest,
293                primary: s.primary,
294                outline: s.outline,
295                on_primary: s.on_primary,
296                on_primary_container: s.on_primary_container,
297            }
298        }
299        None => SwitchColors {
300            on_surface: FALLBACK_ON_SURFACE,
301            surface_container_highest: TRACK_OFF,
302            primary: TRACK_ON,
303            outline: THUMB_OFF,
304            on_primary: THUMB_ON,
305            on_primary_container: FALLBACK_ON_PRIMARY_CONTAINER,
306        },
307    }
308}
309
310/// `M3ESwitchTheme.trackColor` (`m3e_switch_theme.dart:76-88`).
311fn track_color(c: SwitchColors, enabled: bool, value: bool) -> Color {
312    if !enabled {
313        with_alpha(
314            if value {
315                c.on_surface
316            } else {
317                c.surface_container_highest
318            },
319            DISABLED_TRACK_OPACITY,
320        )
321    } else if value {
322        c.primary
323    } else {
324        c.surface_container_highest
325    }
326}
327
328/// `M3ESwitchTheme.thumbColor` (`m3e_switch_theme.dart:90-101`).
329fn thumb_color(c: SwitchColors, enabled: bool, value: bool) -> Color {
330    if !enabled {
331        with_alpha(c.on_surface, DISABLED_THUMB_OPACITY)
332    } else if value {
333        c.on_primary
334    } else {
335        c.outline
336    }
337}
338
339/// `M3ESwitchTheme.outlineColor` (`m3e_switch_theme.dart:103-113`).
340fn outline_color(c: SwitchColors, enabled: bool) -> Color {
341    if !enabled {
342        with_alpha(c.on_surface, DISABLED_OUTLINE_OPACITY)
343    } else {
344        c.outline
345    }
346}
347
348/// `M3ESwitchTheme.iconColor` (`m3e_switch_theme.dart:115-118`) — no
349/// `enabled` branch in the source; preserved as-is (see the
350/// [module docs](self)).
351fn icon_color(c: SwitchColors, value: bool) -> Color {
352    if value {
353        c.on_primary_container
354    } else {
355        c.surface_container_highest
356    }
357}
358
359/// `M3ESwitchTheme.stateLayerColor` (`m3e_switch_theme.dart:120-123`) — no
360/// `enabled` branch in the source; preserved as-is (see the
361/// [module docs](self)).
362fn state_layer_color(c: SwitchColors, value: bool) -> Color {
363    if value { c.primary } else { c.on_surface }
364}
365
366/// The thumb's top-left corner, in the widget's own local (0-based)
367/// coordinate space, for a given (unclamped) position fraction and diameter.
368/// See the [module docs](self)' Thumb travel section for provenance.
369fn thumb_origin(pos_frac: f64, diam: f64) -> (f64, f64) {
370    let max_w = TRACK_W - 2.0 * TRACK_PADDING;
371    let max_h = TRACK_H - 2.0 * TRACK_PADDING;
372    let bleed = if diam > max_h {
373        (diam - max_h) / 2.0
374    } else {
375        0.0
376    };
377    let left = TRACK_PADDING - bleed + (max_w - diam + 2.0 * bleed) * pos_frac;
378    let top = TRACK_PADDING + (max_h - diam) / 2.0;
379    (left, top)
380}
381
382/// Whether two optional icon sources name the same geometry (`d`/`design`
383/// equality) — lets `rebuild` skip re-resolving an unchanged icon, the same
384/// cheap-identity purpose `frust::IconData::same` serves for `IconWidget`.
385fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
386    match (a, b) {
387        (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
388        (None, None) => true,
389        _ => false,
390    }
391}
392
393/// Resolve an optional [`IconSource`] into its cached `(design-space path,
394/// design box)` pair.
395fn resolve_icon(source: Option<IconSource>) -> Option<(BezPath, f64)> {
396    source.map(|s| IconData::from(s).resolve())
397}
398
399/// A view-held, typed toggle callback (erased on build).
400type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
401
402/// A declarative M3E switch. See the [module docs](self).
403pub struct SwitchView<State: 'static> {
404    checked: bool,
405    on_toggle: OnToggle<State>,
406    enabled: bool,
407    selected_icon: Option<IconSource>,
408    unselected_icon: Option<IconSource>,
409    state_layer_size: Option<f64>,
410}
411
412/// Create a switch reflecting `checked` that fires `on_toggle(state,
413/// !checked)` on release inside its bounds.
414pub fn switch<State: 'static, F: Fn(&mut State, bool) + 'static>(
415    checked: bool,
416    on_toggle: F,
417) -> SwitchView<State> {
418    SwitchView {
419        checked,
420        on_toggle: Rc::new(on_toggle),
421        enabled: true,
422        selected_icon: None,
423        unselected_icon: None,
424        state_layer_size: None,
425    }
426}
427
428/// PascalCase alias for [`switch`].
429#[allow(non_snake_case)]
430pub fn Switch<State: 'static, F: Fn(&mut State, bool) + 'static>(
431    checked: bool,
432    on_toggle: F,
433) -> SwitchView<State> {
434    switch(checked, on_toggle)
435}
436
437impl<State: 'static> SwitchView<State> {
438    /// Gate interaction and switch to the disabled color table
439    /// (`m3e_switch_theme.dart:76-113`'s `enabled` branch). See the
440    /// [module docs](self) for why this is an explicit flag rather than the
441    /// reference's nullable `onChanged`.
442    pub fn enabled(mut self, enabled: bool) -> Self {
443        self.enabled = enabled;
444        self
445    }
446
447    /// Icon painted inside the thumb while `checked` is true
448    /// (`m3e_switch_control.dart:36`'s `selectedIcon`).
449    pub fn selected_icon(mut self, icon: IconSource) -> Self {
450        self.selected_icon = Some(icon);
451        self
452    }
453
454    /// Icon painted inside the thumb while `checked` is false
455    /// (`m3e_switch_control.dart:38`'s `unselectedIcon`).
456    pub fn unselected_icon(mut self, icon: IconSource) -> Self {
457        self.unselected_icon = Some(icon);
458        self
459    }
460
461    /// Diameter of the thumb-centered state-layer overlay, overriding
462    /// [`STATE_LAYER_SIZE_DEFAULT`] (`m3e_switch_control.dart:41-44`'s
463    /// `stateLayerSize`).
464    pub fn state_layer_size(mut self, size: f64) -> Self {
465        self.state_layer_size = Some(size);
466        self
467    }
468}
469
470/// The retained widget for a [`SwitchView`].
471pub struct SwitchWidget {
472    /// The app-confirmed value (source of truth; adopted on `rebuild`).
473    checked: bool,
474    enabled: bool,
475    /// Drives the thumb's on-axis `0.0` (off) .. `1.0` (on) travel fraction.
476    position_anim: AnimationController,
477    /// Drives the thumb's unselected↔selected diameter blend fraction.
478    size_anim: AnimationController,
479    /// The `checked` value both animations are currently driving toward (or
480    /// have already settled at) — compared against `checked` at paint time
481    /// to decide whether a fresh pair of flings needs to start (see the
482    /// [module docs](self)).
483    anim_target: bool,
484    state_layer: StateLayer,
485    /// The pressed *visual* state; follows the cursor in/out while captured,
486    /// and also instantly overrides the thumb diameter to
487    /// [`THUMB_PRESSED`].
488    pressed: bool,
489    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
490    captured: bool,
491    selected_icon: Option<IconSource>,
492    unselected_icon: Option<IconSource>,
493    selected_icon_geom: Option<(BezPath, f64)>,
494    unselected_icon_geom: Option<(BezPath, f64)>,
495    state_layer_size: Option<f64>,
496    on_toggle: frust::authoring::ErasedArgCallback<bool>,
497}
498
499fn inside(pos: Point, size: Size) -> bool {
500    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
501}
502
503impl<State: 'static> View<State> for SwitchView<State> {
504    type Element = SwitchWidget;
505
506    fn build(&self, _ctx: &mut BuildCtx<'_>) -> SwitchWidget {
507        // Zero duration: forward()/reverse() below always snap instantly (see
508        // AnimationController::start_duration), which only fling() ever
509        // overrides at paint time with a real spring.
510        let mut position_anim = AnimationController::new(Duration::ZERO);
511        let mut size_anim = AnimationController::new(Duration::ZERO);
512        if self.checked {
513            position_anim.forward();
514            size_anim.forward();
515        }
516        SwitchWidget {
517            checked: self.checked,
518            enabled: self.enabled,
519            position_anim,
520            size_anim,
521            anim_target: self.checked,
522            state_layer: StateLayer::new(),
523            pressed: false,
524            captured: false,
525            selected_icon: self.selected_icon,
526            unselected_icon: self.unselected_icon,
527            selected_icon_geom: resolve_icon(self.selected_icon),
528            unselected_icon_geom: resolve_icon(self.unselected_icon),
529            state_layer_size: self.state_layer_size,
530            on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
531        }
532    }
533
534    fn rebuild(
535        &self,
536        prev: &Self,
537        element: &mut SwitchWidget,
538        _ctx: &mut BuildCtx<'_>,
539    ) -> ChangeFlags {
540        element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
541        let mut flags = ChangeFlags::NONE;
542
543        if prev.checked != self.checked {
544            // The app is the source of truth: adopt the new value. Both
545            // flings start lazily in `paint`, once a theme is in scope
546            // again.
547            element.checked = self.checked;
548            flags |= ChangeFlags::PAINT;
549        }
550        if prev.enabled != self.enabled {
551            element.enabled = self.enabled;
552            if !self.enabled {
553                // A switch disabled mid-press keeps neither the press nor
554                // the capture it was holding (mirrors
555                // `button::ButtonWidget::rebuild`'s identical clear).
556                element.pressed = false;
557                element.captured = false;
558                element.state_layer.set_pressed(false);
559            }
560            flags |= ChangeFlags::PAINT;
561        }
562        if prev.state_layer_size != self.state_layer_size {
563            element.state_layer_size = self.state_layer_size;
564            flags |= ChangeFlags::PAINT;
565        }
566        if !same_icon_source(prev.selected_icon, self.selected_icon) {
567            element.selected_icon = self.selected_icon;
568            element.selected_icon_geom = resolve_icon(self.selected_icon);
569            flags |= ChangeFlags::PAINT;
570        }
571        if !same_icon_source(prev.unselected_icon, self.unselected_icon) {
572            element.unselected_icon = self.unselected_icon;
573            element.unselected_icon_geom = resolve_icon(self.unselected_icon);
574            flags |= ChangeFlags::PAINT;
575        }
576
577        flags
578    }
579}
580
581impl Widget for SwitchWidget {
582    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
583        bc.constrain(Size::new(TRACK_W, TRACK_H))
584    }
585
586    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
587        let theme = Theme::from_paint_ctx(ctx);
588        let colors = resolve_switch_colors(theme);
589
590        if self.checked != self.anim_target {
591            let velocity = if self.checked {
592                RELEASE_VELOCITY
593            } else {
594                -RELEASE_VELOCITY
595            };
596            self.position_anim.fling(velocity, POSITION_SPRING);
597            self.size_anim.fling(velocity, SIZE_SPRING);
598            self.anim_target = self.checked;
599        }
600        let position_animating = self.position_anim.advance(ctx.frame_time());
601        let size_animating = self.size_anim.advance(ctx.frame_time());
602        if position_animating || size_animating {
603            ctx.request_frame();
604        }
605
606        // Unclamped for spatial travel/size: a spring's overshoot past the
607        // target is real, intended motion (see AnimationController's
608        // Overshoot docs, and the module docs' Thumb travel section).
609        let pos_frac = self.position_anim.value();
610        let pos_frac_clamped = self.position_anim.value_clamped();
611        let size_frac = self.size_anim.value();
612        let size_frac_clamped = self.size_anim.value_clamped().clamp(0.0, 1.0);
613
614        let o = ctx.origin();
615
616        // Track fill: continuous cross-fade via the position spring's
617        // clamped fraction (a documented porting decision — see the module
618        // docs).
619        let track_off = track_color(colors, self.enabled, false);
620        let track_on = track_color(colors, self.enabled, true);
621        let track_fill = lerp_color_exact(track_off, track_on, pos_frac_clamped);
622        scene.fill_rounded_rect(o, Size::new(TRACK_W, TRACK_H), TRACK_H / 2.0, track_fill);
623
624        // Off-track outline ring (m3e_switch_control.dart:140-148: only
625        // while `!checked`).
626        if !self.checked {
627            let outline = outline_color(colors, self.enabled);
628            let inset = BORDER_WIDTH / 2.0;
629            let border_rect = Rect::new(inset, inset, TRACK_W - inset, TRACK_H - inset);
630            let border_radius = TRACK_H / 2.0 - inset;
631            let border_path =
632                RoundedRect::from_rect(border_rect, border_radius).to_path(PATH_TOLERANCE);
633            scene.stroke_path(o, &border_path, BORDER_WIDTH, &Brush::Solid(outline));
634        }
635
636        let base_diam = THUMB_UNSELECTED + size_frac * (THUMB_SELECTED - THUMB_UNSELECTED);
637        let diam = if self.pressed {
638            THUMB_PRESSED
639        } else {
640            base_diam
641        };
642        let (left, top) = thumb_origin(pos_frac, diam);
643
644        let state_layer_size = self.state_layer_size.unwrap_or(STATE_LAYER_SIZE_DEFAULT);
645        let center = Point::new(o.x + left + diam / 2.0, o.y + top + diam / 2.0);
646        self.state_layer.paint(
647            ctx,
648            scene,
649            Rect::from_center_size(center, Size::new(state_layer_size, state_layer_size)),
650            state_layer_size / 2.0,
651            state_layer_color(colors, self.checked),
652        );
653
654        let thumb_off = thumb_color(colors, self.enabled, false);
655        let thumb_on = thumb_color(colors, self.enabled, true);
656        let thumb_fill = lerp_color_exact(thumb_off, thumb_on, pos_frac_clamped);
657        scene.fill_rounded_rect(
658            Point::new(o.x + left, o.y + top),
659            Size::new(diam, diam),
660            diam / 2.0,
661            thumb_fill,
662        );
663
664        // Icon: value-selected, size-faded (see the module docs).
665        let icon_geom = if self.checked {
666            &self.selected_icon_geom
667        } else {
668            &self.unselected_icon_geom
669        };
670        if let Some((base_path, design)) = icon_geom {
671            let opacity = size_frac_clamped as f32;
672            if opacity > 0.0 {
673                let icon_render_scale = 0.5 + 0.5 * size_frac_clamped;
674                let effective = ICON_SIZE * icon_render_scale;
675                let path_scale = if *design > 0.0 {
676                    effective / design
677                } else {
678                    1.0
679                };
680                let icon_local = Point::new(
681                    left + diam / 2.0 - effective / 2.0,
682                    top + diam / 2.0 - effective / 2.0,
683                );
684                let transform = Affine::translate(Vec2::new(icon_local.x, icon_local.y))
685                    * Affine::scale(path_scale);
686                let scaled_path = transform * base_path.clone();
687                let icon_fill = with_alpha(icon_color(colors, self.checked), opacity);
688                scene.fill_path(o, &scaled_path, &Brush::Solid(icon_fill));
689            }
690        }
691    }
692
693    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
694        let InputEvent::Pointer(p) = event else {
695            return EventResult::Ignored;
696        };
697        match p.phase {
698            PointerPhase::Down => {
699                if !self.enabled || !presses(p) {
700                    return EventResult::Ignored;
701                }
702                self.pressed = true;
703                self.captured = true;
704                self.state_layer.set_pressed(true);
705                ctx.capture_pointer();
706                ctx.request_redraw();
707                EventResult::Handled
708            }
709            PointerPhase::Move => {
710                if !self.captured {
711                    return EventResult::Ignored;
712                }
713                let inside_now = inside(p.position, ctx.size());
714                self.pressed = inside_now;
715                self.state_layer.set_pressed(inside_now);
716                ctx.request_redraw();
717                EventResult::Handled
718            }
719            PointerPhase::Up => {
720                if !self.captured {
721                    return EventResult::Ignored;
722                }
723                // Belt-and-braces: `rebuild` already clears `captured` the
724                // moment `enabled` goes false (see the module docs' `enabled`
725                // section), so this only matters if a captured switch is
726                // still `!enabled` some other way — but a disabled control
727                // must fire neither the haptic nor the toggle regardless.
728                if self.enabled && inside(p.position, ctx.size()) {
729                    // Same call order as the reference's `_wrapTap`: haptic
730                    // first, then the tap's own effect. Fires `None` — see
731                    // the module docs' Haptics section.
732                    MaterialHaptics::fire(HapticSignal::None);
733                    // Report the *requested* value; never self-toggle.
734                    let requested = !self.checked;
735                    (self.on_toggle)(ctx, requested);
736                }
737                self.pressed = false;
738                self.captured = false;
739                self.state_layer.set_pressed(false);
740                ctx.request_redraw();
741                EventResult::Handled
742            }
743            PointerPhase::Cancel => {
744                if !self.captured {
745                    return EventResult::Ignored;
746                }
747                self.pressed = false;
748                self.captured = false;
749                self.state_layer.set_pressed(false);
750                ctx.request_redraw();
751                EventResult::Handled
752            }
753        }
754    }
755
756    fn semantics(&self, ctx: &mut SemanticsCtx) {
757        ctx.push_node(Role::Switch, |node| {
758            node.set_toggled(Toggled::from(self.checked));
759            if self.enabled {
760                node.add_action(Action::Click);
761            }
762        });
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use frust::FrameTime;
770    use std::any::Any;
771
772    fn ft_secs(s: f64) -> FrameTime {
773        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
774    }
775
776    #[derive(Default)]
777    struct ToggleState {
778        last: Option<bool>,
779        toggles: u32,
780    }
781
782    fn widget(checked: bool) -> SwitchWidget {
783        let view = switch::<ToggleState, _>(checked, |s: &mut ToggleState, v: bool| {
784            s.last = Some(v);
785            s.toggles += 1;
786        });
787        let mut counter = 0u64;
788        View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter))
789    }
790
791    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
792        InputEvent::Pointer(frust::authoring::PointerEvent {
793            phase,
794            position: Point::new(x, y),
795            button: frust::authoring::PointerButton::Primary,
796        })
797    }
798
799    /// The same event on the secondary (right) button.
800    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
801        InputEvent::Pointer(frust::authoring::PointerEvent {
802            phase,
803            position: Point::new(x, y),
804            button: frust::authoring::PointerButton::Secondary,
805        })
806    }
807
808    fn dispatch(w: &mut SwitchWidget, state: &mut ToggleState, event: &InputEvent) {
809        let state_any: &mut dyn Any = state;
810        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(TRACK_W, TRACK_H));
811        w.event(&mut ctx, event);
812    }
813
814    #[test]
815    fn a_secondary_press_never_presses_captures_or_toggles() {
816        let mut w = widget(false);
817        let mut state = ToggleState::default();
818        dispatch(
819            &mut w,
820            &mut state,
821            &secondary_ev(PointerPhase::Down, 5.0, 12.0),
822        );
823        assert!(!w.pressed, "no pressed state layer on a right-click");
824        assert!(!w.captured, "and no capture for the shell to wedge on");
825        dispatch(
826            &mut w,
827            &mut state,
828            &secondary_ev(PointerPhase::Up, 5.0, 12.0),
829        );
830        assert_eq!(state.toggles, 0);
831
832        // The primary gesture is untouched by the guard.
833        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
834        assert!(w.pressed);
835        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
836        assert_eq!(state.toggles, 1);
837    }
838
839    #[test]
840    fn off_fires_true_and_does_not_self_toggle() {
841        let mut w = widget(false);
842        let mut state = ToggleState::default();
843        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
844        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
845        assert_eq!(state.last, Some(true));
846        assert_eq!(state.toggles, 1);
847        assert!(!w.checked, "switch must not mutate its own checked flag");
848    }
849
850    #[test]
851    fn on_fires_false() {
852        let mut w = widget(true);
853        let mut state = ToggleState::default();
854        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
855        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
856        assert_eq!(state.last, Some(false));
857        assert!(w.checked, "still checked until the app rebuilds it");
858    }
859
860    #[test]
861    fn up_outside_does_not_fire() {
862        let mut w = widget(false);
863        let mut state = ToggleState::default();
864        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
865        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
866        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
867        assert_eq!(state.toggles, 0);
868    }
869
870    #[test]
871    fn hover_move_without_down_is_ignored_noop() {
872        let mut w = widget(false);
873        let mut state = ToggleState::default();
874        let state_any: &mut dyn Any = &mut state;
875        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(TRACK_W, TRACK_H));
876        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
877        assert!(matches!(result, EventResult::Ignored));
878        assert!(!w.pressed, "hover must not press");
879        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
880        assert_eq!(state.toggles, 0);
881    }
882
883    #[test]
884    fn cancel_clears_armed_state() {
885        let mut w = widget(false);
886        let mut state = ToggleState::default();
887        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
888        assert!(w.captured);
889        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
890        assert!(!w.captured, "Cancel disarms the press");
891        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
892        assert_eq!(state.toggles, 0);
893    }
894
895    #[test]
896    fn disabled_ignores_pointer_and_never_toggles() {
897        let view = switch::<ToggleState, _>(false, |s: &mut ToggleState, v: bool| {
898            s.last = Some(v);
899            s.toggles += 1;
900        })
901        .enabled(false);
902        let mut counter = 0u64;
903        let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
904        let mut state = ToggleState::default();
905        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
906        assert!(!w.pressed, "disabled never presses");
907        assert!(!w.captured, "disabled never captures");
908        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
909        assert_eq!(state.toggles, 0, "disabled never toggles");
910    }
911
912    #[test]
913    fn rebuild_adopts_new_checked_value_without_self_mutation() {
914        let mut counter = 0u64;
915        let prev = switch::<ToggleState, _>(false, |_s, _v| {});
916        let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
917        assert!(!w.checked);
918        assert!(!w.anim_target);
919        let next = switch::<ToggleState, _>(true, |_s, _v| {});
920        let flags =
921            View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
922        assert!(w.checked, "the confirmed value adopts immediately");
923        assert!(
924            !w.anim_target,
925            "both flings only start lazily in paint, once a theme is in scope"
926        );
927        assert!(flags.needs_paint());
928    }
929
930    #[test]
931    fn disabling_mid_press_clears_the_press_and_the_capture() {
932        let mut counter = 0u64;
933        let prev = switch::<ToggleState, _>(false, |_s, _v| {});
934        let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
935        w.pressed = true;
936        w.captured = true;
937        w.state_layer.set_pressed(true);
938
939        let next = switch::<ToggleState, _>(false, |_s, _v| {}).enabled(false);
940        let flags =
941            View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
942        assert!(!w.pressed);
943        assert!(!w.captured);
944        assert_eq!(
945            w.state_layer.opacity(),
946            0.0,
947            "the state-layer's own pressed flag clears too"
948        );
949        assert!(flags.needs_paint());
950    }
951
952    #[test]
953    fn disabled_up_fires_no_callback_even_if_still_captured() {
954        // Belt-and-braces coverage for the `Up` arm's own `self.enabled`
955        // guard, isolated from `rebuild`'s clear above: even a switch that
956        // is somehow still `captured` while `!enabled` must not report a
957        // toggle on `Up`. Not independently instrumented for the haptic
958        // (both the haptic fire and the toggle callback sit behind this
959        // same guard — installing a recording hook here would race
960        // `crate::interaction`'s own first-set-wins coverage; see
961        // `SliderView::haptic_sink`'s doc for the identical constraint).
962        let mut w = widget(false);
963        let mut state = ToggleState::default();
964        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
965        assert!(w.captured);
966        w.enabled = false;
967        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
968        assert_eq!(
969            state.toggles, 0,
970            "a disabled switch must not report a toggle on Up"
971        );
972    }
973
974    /// Records each rounded rect's `(origin, size, radius, color)` in paint order:
975    /// track, then (if active) the state-layer overlay, then the thumb.
976    #[derive(Default)]
977    struct RRectRecorder {
978        rrects: Vec<(Point, Size, f64, Color)>,
979    }
980
981    impl PaintScene for RRectRecorder {
982        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
983        fn draw_text(&mut self, _o: Point, _t: &str) {}
984        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
985            self.rrects.push((o, s, radius, color));
986        }
987    }
988
989    fn paint_rec(w: &mut SwitchWidget, theme: Option<&Theme>) -> RRectRecorder {
990        let mut rec = RRectRecorder::default();
991        let mut ctx = match theme {
992            Some(t) => PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H)).with_theme(t),
993            None => PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H)),
994        };
995        w.paint(&mut ctx, &mut rec);
996        rec
997    }
998
999    #[test]
1000    fn unthemed_paint_uses_fallback_constants() {
1001        let mut off = widget(false);
1002        let rec = paint_rec(&mut off, None);
1003        // Track first, thumb last (no active state layer): 2 rects. The
1004        // off-track outline border and any icon paint through
1005        // fill_path/stroke_path, not fill_rounded_rect, so they never touch
1006        // this recorder.
1007        assert_eq!(rec.rrects.len(), 2, "no active state layer paints nothing");
1008        assert_eq!(rec.rrects[0].3, TRACK_OFF);
1009        assert_eq!(rec.rrects[0].2, TRACK_H / 2.0, "pill radius");
1010        assert_eq!(rec.rrects[1].3, THUMB_OFF);
1011        assert_eq!(
1012            rec.rrects[1].1,
1013            Size::new(THUMB_UNSELECTED, THUMB_UNSELECTED)
1014        );
1015
1016        let mut on = widget(true);
1017        let rec = paint_rec(&mut on, None);
1018        assert_eq!(rec.rrects[0].3, TRACK_ON);
1019        assert_eq!(rec.rrects[1].3, THUMB_ON);
1020        assert_eq!(rec.rrects[1].1, Size::new(THUMB_SELECTED, THUMB_SELECTED));
1021    }
1022
1023    #[test]
1024    fn themed_paint_resolves_switch_tokens() {
1025        let theme = crate::baseline();
1026        let scheme = theme.scheme();
1027        let mut off = widget(false);
1028        let rec = paint_rec(&mut off, Some(&theme));
1029        assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
1030        assert_eq!(rec.rrects[1].3, scheme.outline);
1031
1032        let mut on = widget(true);
1033        let rec = paint_rec(&mut on, Some(&theme));
1034        assert_eq!(rec.rrects[0].3, scheme.primary);
1035        assert_eq!(rec.rrects[1].3, scheme.on_primary);
1036    }
1037
1038    #[test]
1039    fn pressed_thumb_expands_regardless_of_state() {
1040        let mut w = widget(false);
1041        w.pressed = true;
1042        let rec = paint_rec(&mut w, None);
1043        assert_eq!(rec.rrects[1].1, Size::new(THUMB_PRESSED, THUMB_PRESSED));
1044    }
1045
1046    #[test]
1047    fn paint_starts_both_flings_when_checked_disagrees_with_anim_target() {
1048        let mut w = widget(false);
1049        w.checked = true; // simulate the confirmed value rebuild adopts
1050        assert!(!w.position_anim.is_animating());
1051        assert!(!w.size_anim.is_animating());
1052        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1053        let mut rec = RRectRecorder::default();
1054        w.paint(&mut ctx, &mut rec);
1055        assert!(w.anim_target, "paint syncs the animation target");
1056        assert!(
1057            w.position_anim.is_animating(),
1058            "a position fling toward the new target started"
1059        );
1060        assert!(
1061            w.size_anim.is_animating(),
1062            "a size fling toward the new target started"
1063        );
1064        assert!(
1065            ctx.needs_frame(),
1066            "an in-flight fling must request another frame"
1067        );
1068    }
1069
1070    #[test]
1071    fn both_springs_progress_toward_and_settle_at_target() {
1072        let mut w = widget(false);
1073        w.checked = true;
1074        // First paint starts both flings (module docs: lazily, once themed).
1075        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1076        let mut rec = RRectRecorder::default();
1077        w.paint(&mut ctx, &mut rec);
1078
1079        // Directly advance both controllers with injected frame times
1080        // (mirrors frust-core::anim's own test pattern) rather than through
1081        // PaintCtx, whose frame_time setter is crate-private.
1082        let mut position_running = true;
1083        let mut size_running = true;
1084        let mut t = 0.0;
1085        for _ in 0..100_000 {
1086            position_running = w.position_anim.advance(ft_secs(t));
1087            size_running = w.size_anim.advance(ft_secs(t));
1088            if !position_running && !size_running {
1089                break;
1090            }
1091            t += 1.0 / 120.0;
1092        }
1093        assert!(!position_running, "position fling failed to settle");
1094        assert!(!size_running, "size fling failed to settle");
1095        assert!((w.position_anim.value() - 1.0).abs() < 1e-6);
1096        assert!((w.size_anim.value() - 1.0).abs() < 1e-6);
1097    }
1098
1099    #[test]
1100    fn spring_specs_match_the_m3e_reference_table() {
1101        // (spring, expected stiffness, expected damping ratio) — mirrors
1102        // m3e_switch_control.dart:66-76's `.copyWith(damping: ...)`
1103        // overrides atop MaterialSpringMotion.expressiveSpatialDefault's
1104        // stiffness (tmp/motor-1.1.0/lib/src/motion.dart:610-616: 380).
1105        let table = [
1106            ("position", POSITION_SPRING, 380.0_f64, 0.55_f64),
1107            ("size", SIZE_SPRING, 380.0_f64, 0.7_f64),
1108        ];
1109        for (name, spring, stiffness, damping_ratio) in table {
1110            assert_eq!(spring.mass, 1.0, "{name} spring mass");
1111            assert_eq!(spring.stiffness, stiffness, "{name} spring stiffness");
1112            assert_eq!(
1113                spring.damping_ratio, damping_ratio,
1114                "{name} spring damping ratio"
1115            );
1116        }
1117    }
1118
1119    #[test]
1120    fn thumb_rests_at_spec_exact_positions_off_and_on() {
1121        // Off at rest: pos_frac=0, diam=16 (unselected) — no bleed
1122        // (16 <= max_h=24). left = 4, top = 4 + (24-16)/2 = 8.
1123        let mut off = widget(false);
1124        let rec = paint_rec(&mut off, None);
1125        assert_eq!(rec.rrects[1].0, Point::new(4.0, 8.0));
1126        assert_eq!(rec.rrects[1].1, Size::new(16.0, 16.0));
1127
1128        // On at rest: pos_frac=1, diam=24 (selected) — no bleed
1129        // (24 <= max_h=24). left = 4 + (44-24) = 24, top = 4.
1130        let mut on = widget(true);
1131        let rec = paint_rec(&mut on, None);
1132        assert_eq!(rec.rrects[1].0, Point::new(24.0, 4.0));
1133        assert_eq!(rec.rrects[1].1, Size::new(24.0, 24.0));
1134    }
1135
1136    #[test]
1137    fn thumb_travel_is_monotone_toward_target_mid_flight() {
1138        let mut w = widget(false);
1139        w.checked = true;
1140        // First paint starts both flings and seeds the clock (module docs:
1141        // lazily, once themed) — mirrors
1142        // `both_springs_progress_toward_and_settle_at_target`'s pattern
1143        // rather than `PaintCtx::for_test` (a `frust-core` `test-support`-
1144        // feature-gated seam this crate's dev-dependency doesn't enable).
1145        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1146        let mut rec = RRectRecorder::default();
1147        w.paint(&mut ctx, &mut rec);
1148
1149        // Sample well inside the position spring's monotonic rise (its
1150        // damped period is ~0.39s at stiffness 380/damping 0.55, so the
1151        // first ~50ms stays short of any overshoot peak).
1152        let mut xs = Vec::new();
1153        let mut t = 0.0;
1154        for _ in 0..6 {
1155            t += 1.0 / 120.0;
1156            w.position_anim.advance(ft_secs(t));
1157            w.size_anim.advance(ft_secs(t));
1158            let diam = THUMB_UNSELECTED + w.size_anim.value() * (THUMB_SELECTED - THUMB_UNSELECTED);
1159            let (left, _top) = thumb_origin(w.position_anim.value(), diam);
1160            xs.push(left);
1161        }
1162        for pair in xs.windows(2) {
1163            assert!(
1164                pair[1] > pair[0],
1165                "thumb x-origin must move monotonically toward the target mid-flight: {xs:?}"
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn icon_paints_selected_variant_at_full_opacity_when_on_at_rest() {
1172        let view = switch::<ToggleState, _>(true, |_s, _v| {})
1173            .selected_icon(crate::icons::CHECK)
1174            .unselected_icon(crate::icons::CLOSE);
1175        let mut counter = 0u64;
1176        let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
1177
1178        #[derive(Default)]
1179        struct IconRecorder {
1180            fills: Vec<(BezPath, Color)>,
1181        }
1182        impl PaintScene for IconRecorder {
1183            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1184            fn draw_text(&mut self, _o: Point, _t: &str) {}
1185            fn fill_path(&mut self, _o: Point, path: &BezPath, brush: &Brush) {
1186                let Brush::Solid(color) = brush else {
1187                    panic!("expected a solid brush");
1188                };
1189                self.fills.push((path.clone(), *color));
1190            }
1191        }
1192
1193        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1194        let mut rec = IconRecorder::default();
1195        w.paint(&mut ctx, &mut rec);
1196        assert_eq!(rec.fills.len(), 1, "exactly one icon paints");
1197        assert_eq!(
1198            rec.fills[0].1.components[3], 1.0,
1199            "fully opaque at rest (size fraction 1.0)"
1200        );
1201        // Selected (CHECK), not unselected (CLOSE): bounding boxes of the
1202        // two source icons differ, so this pins which one was chosen.
1203        let (unselected_path, unselected_design) = IconData::from(crate::icons::CLOSE).resolve();
1204        let unselected_scale = ICON_SIZE / unselected_design;
1205        let unselected_bounds = (Affine::scale(unselected_scale) * unselected_path).bounding_box();
1206        assert_ne!(
1207            rec.fills[0].0.bounding_box().width(),
1208            unselected_bounds.width(),
1209            "must not paint the unselected icon while checked"
1210        );
1211    }
1212
1213    #[test]
1214    fn icon_selects_unselected_variant_from_confirmed_value_even_mid_size_flight() {
1215        // Force the size spring into a nonzero, non-rest state while
1216        // `checked` stays false, mirroring the reference's own decoupling
1217        // (icon choice follows the confirmed value, not the size spring) —
1218        // see the module docs' Icons section.
1219        let view = switch::<ToggleState, _>(false, |_s, _v| {})
1220            .selected_icon(crate::icons::CHECK)
1221            .unselected_icon(crate::icons::CLOSE);
1222        let mut counter = 0u64;
1223        let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
1224        w.size_anim.forward(); // value -> 1.0, but `checked`/`anim_target` stay false
1225
1226        #[derive(Default)]
1227        struct IconRecorder {
1228            count: u32,
1229        }
1230        impl PaintScene for IconRecorder {
1231            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1232            fn draw_text(&mut self, _o: Point, _t: &str) {}
1233            fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {
1234                self.count += 1;
1235            }
1236        }
1237        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1238        let mut rec = IconRecorder::default();
1239        w.paint(&mut ctx, &mut rec);
1240        assert_eq!(
1241            rec.count, 1,
1242            "the unselected icon still paints (opacity 1.0)"
1243        );
1244    }
1245
1246    #[test]
1247    fn no_icon_configured_paints_nothing() {
1248        let mut w = widget(true);
1249        #[derive(Default)]
1250        struct IconRecorder {
1251            count: u32,
1252        }
1253        impl PaintScene for IconRecorder {
1254            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1255            fn draw_text(&mut self, _o: Point, _t: &str) {}
1256            fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {
1257                self.count += 1;
1258            }
1259        }
1260        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1261        let mut rec = IconRecorder::default();
1262        w.paint(&mut ctx, &mut rec);
1263        assert_eq!(rec.count, 0);
1264    }
1265
1266    #[test]
1267    fn off_track_paints_an_outline_border_on_paints_none() {
1268        #[derive(Default)]
1269        struct StrokeRecorder {
1270            strokes: u32,
1271        }
1272        impl PaintScene for StrokeRecorder {
1273            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1274            fn draw_text(&mut self, _o: Point, _t: &str) {}
1275            fn stroke_path(&mut self, _o: Point, _p: &BezPath, _w: f64, _b: &Brush) {
1276                self.strokes += 1;
1277            }
1278        }
1279        let mut off = widget(false);
1280        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1281        let mut rec = StrokeRecorder::default();
1282        off.paint(&mut ctx, &mut rec);
1283        assert_eq!(rec.strokes, 1, "off track paints its outline ring");
1284
1285        let mut on = widget(true);
1286        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1287        let mut rec = StrokeRecorder::default();
1288        on.paint(&mut ctx, &mut rec);
1289        assert_eq!(rec.strokes, 0, "on track paints no outline ring");
1290    }
1291
1292    #[test]
1293    fn custom_state_layer_size_flows_into_paint() {
1294        let view = switch::<ToggleState, _>(false, |_s, _v| {}).state_layer_size(64.0);
1295        let mut counter = 0u64;
1296        let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
1297        w.pressed = true;
1298        w.state_layer.set_pressed(true);
1299        let rec = paint_rec(&mut w, None);
1300        assert_eq!(rec.rrects.len(), 3, "track, state layer, thumb");
1301        assert_eq!(rec.rrects[1].1, Size::new(64.0, 64.0));
1302        assert_eq!(rec.rrects[1].2, 32.0, "radius is half the custom diameter");
1303    }
1304
1305    #[test]
1306    fn color_table_matches_the_m3e_theme_for_every_enabled_value_combo() {
1307        let theme = crate::baseline();
1308        let scheme = theme.scheme();
1309        let colors = resolve_switch_colors(Some(&theme));
1310
1311        for enabled in [true, false] {
1312            for value in [true, false] {
1313                let track = track_color(colors, enabled, value);
1314                let thumb = thumb_color(colors, enabled, value);
1315                let expected_track = if !enabled {
1316                    with_alpha(
1317                        if value {
1318                            scheme.on_surface
1319                        } else {
1320                            scheme.surface_container_highest
1321                        },
1322                        DISABLED_TRACK_OPACITY,
1323                    )
1324                } else if value {
1325                    scheme.primary
1326                } else {
1327                    scheme.surface_container_highest
1328                };
1329                let expected_thumb = if !enabled {
1330                    with_alpha(scheme.on_surface, DISABLED_THUMB_OPACITY)
1331                } else if value {
1332                    scheme.on_primary
1333                } else {
1334                    scheme.outline
1335                };
1336                assert_eq!(track, expected_track, "enabled={enabled} value={value}");
1337                assert_eq!(thumb, expected_thumb, "enabled={enabled} value={value}");
1338            }
1339            let outline = outline_color(colors, enabled);
1340            let expected_outline = if !enabled {
1341                with_alpha(scheme.on_surface, DISABLED_OUTLINE_OPACITY)
1342            } else {
1343                scheme.outline
1344            };
1345            assert_eq!(outline, expected_outline, "enabled={enabled}");
1346        }
1347
1348        // icon_color/state_layer_color: no enabled branch (see module docs).
1349        assert_eq!(icon_color(colors, true), scheme.on_primary_container);
1350        assert_eq!(icon_color(colors, false), scheme.surface_container_highest);
1351        assert_eq!(state_layer_color(colors, true), scheme.primary);
1352        assert_eq!(state_layer_color(colors, false), scheme.on_surface);
1353    }
1354
1355    #[test]
1356    fn semantics_reports_switch_role_toggled_state_and_bounds() {
1357        // SemanticsCtx is only constructible inside frust-core (its `new`/
1358        // `finish` are crate-private there), so — mirroring
1359        // `tests/semantics_tree.rs` — this drives the widget through a real
1360        // `RenderRoot` rebuild/layout/semantics pass rather than poking the
1361        // pass's context directly.
1362        fn logic(_state: &mut ToggleState) -> SwitchView<ToggleState> {
1363            switch::<ToggleState, _>(true, |_s, _v| {})
1364        }
1365        let mut root: frust_core::RenderRoot<ToggleState, SwitchView<ToggleState>> =
1366            frust_core::RenderRoot::new();
1367        let mut state = ToggleState::default();
1368        root.rebuild(&mut logic, &mut state);
1369        root.layout(Size::new(200.0, 200.0));
1370        let update = root.semantics();
1371
1372        let (_, node) = update
1373            .nodes
1374            .iter()
1375            .find(|(_, n)| n.role() == Role::Switch)
1376            .expect("switch contributes a Role::Switch node");
1377        assert_eq!(node.toggled(), Some(Toggled::True));
1378        assert!(node.supports_action(Action::Click));
1379        let bounds = node.bounds().expect("switch node has bounds");
1380        assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
1381        assert_eq!(
1382            (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0),
1383            (TRACK_W, TRACK_H)
1384        );
1385    }
1386
1387    #[test]
1388    fn semantics_omits_click_action_when_disabled() {
1389        fn logic(_state: &mut ToggleState) -> SwitchView<ToggleState> {
1390            switch::<ToggleState, _>(true, |_s, _v| {}).enabled(false)
1391        }
1392        let mut root: frust_core::RenderRoot<ToggleState, SwitchView<ToggleState>> =
1393            frust_core::RenderRoot::new();
1394        let mut state = ToggleState::default();
1395        root.rebuild(&mut logic, &mut state);
1396        root.layout(Size::new(200.0, 200.0));
1397        let update = root.semantics();
1398
1399        let (_, node) = update
1400            .nodes
1401            .iter()
1402            .find(|(_, n)| n.role() == Role::Switch)
1403            .expect("switch contributes a Role::Switch node");
1404        assert!(!node.supports_action(Action::Click));
1405    }
1406}