Skip to main content

frus_widgets/
switch.rs

1//! [`Switch`]: a **controlled** toggle switch, shaped as a pill.
2
3use frus_core::{Color, Curve, Insets, Point, Rect, Scene};
4use frus_layout::{Dimension, Style};
5
6use crate::disabled::DISABLED_CONTAINER_OPACITY;
7use crate::disabled::{disabled_container, disabled_content, disabled_mark, over_surface};
8use crate::icons::IconData;
9use crate::interaction::Status;
10use crate::theme::{TapTarget, Theme};
11use crate::widget::Widget;
12use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
13
14/// The track, at the reference's size (`switch.dart:2378`, `:2375`).
15const W: f32 = 52.0;
16const H: f32 = 32.0;
17/// The thumb's radius **off** and **on** (`switch.dart:2354`, `:2317`). It grows as the
18/// switch is flipped: off it is a dot inside an outlined track, on it is a disc on a
19/// filled one, and that difference is most of what tells the two states apart at a
20/// glance.
21const THUMB_OFF: f32 = 8.0;
22const THUMB_ON: f32 = 12.0;
23/// The thumb while it is **held** (`switch.dart:2357`): larger than either end of the
24/// travel, which is the squish a finger expects back. It **grows** into it — the press is
25/// a progression since milestone 441, not a flag.
26const THUMB_PRESSED: f32 = 14.0;
27/// An end whose thumb **carries an icon** is the on-thumb's size (`switch.dart:2369`,
28/// `:1067`): 16 pixels of glyph do not fit in a 16-pixel dot.
29const THUMB_WITH_ICON: f32 = 12.0;
30/// The thumb half way along, a pill wider than it is tall (`switch.dart:2382`): the
31/// stretch of something being pulled across.
32const THUMB_MIDWAY: (f32, f32) = (34.0, 22.0);
33/// The glyph inside the thumb (`switch.dart:2314`).
34const ICON_SIZE: f32 = 16.0;
35/// The rule round an **off** track (`switch.dart:2298`).
36const TRACK_OUTLINE: f32 = 2.0;
37/// The halo's radius (`switch.dart:2301`).
38const SPLASH: f32 = 20.0;
39/// The room either side of the track (`switch.dart:2304`).
40const PADDING: Insets = Insets::new(0.0, 4.0, 0.0, 4.0);
41/// How long a flip takes (`switch.dart:2386`).
42const TOGGLE_SECONDS: f32 = 0.3;
43
44/// The reference's `easeOutBack` (`curves.dart:1727`): the thumb overshoots its end a
45/// little and settles back, which is the snap a switch has.
46fn ease_out_back() -> Curve {
47    Curve::Cubic {
48        x1: 0.175,
49        y1: 0.885,
50        x2: 0.32,
51        y2: 1.275,
52    }
53}
54
55/// An on/off switch.
56pub struct Switch<Msg = crate::callback::Callback> {
57    on: bool,
58    enabled: bool,
59    track_color: Option<Color>,
60    inactive_track_color: Option<Color>,
61    thumb_color: Option<Color>,
62    inactive_thumb_color: Option<Color>,
63    thumb_colors: Option<WidgetStateProperty<Color>>,
64    track_colors: Option<WidgetStateProperty<Color>>,
65    track_outline_color: Option<WidgetStateProperty<Color>>,
66    track_outline_width: Option<f32>,
67    overlay_color: Option<WidgetStateProperty<Color>>,
68    splash_radius: Option<f32>,
69    padding: Option<Insets>,
70    thumb_icon: Option<IconData>,
71    inactive_thumb_icon: Option<IconData>,
72    tap_target: Option<TapTarget>,
73    on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
74}
75
76impl<Msg> Switch<Msg> {
77    /// Creates a switch whose state is supplied.
78    pub fn new(on: bool) -> Self {
79        Self {
80            on,
81            enabled: true,
82            track_color: None,
83            inactive_track_color: None,
84            thumb_color: None,
85            inactive_thumb_color: None,
86            thumb_colors: None,
87            track_colors: None,
88            track_outline_color: None,
89            track_outline_width: None,
90            overlay_color: None,
91            splash_radius: None,
92            padding: None,
93            thumb_icon: None,
94            inactive_thumb_icon: None,
95            tap_target: None,
96            on_toggle: None,
97        }
98    }
99
100    /// **How much room it reserves for a finger** ([`TapTarget`]). Unset, the theme's
101    /// answer, which is [`Padded`](TapTarget::Padded) — at least 48 pixels either way,
102    /// whatever this control paints inside it.
103    pub fn tap_target(mut self, target: TapTarget) -> Self {
104        self.tap_target = Some(target);
105        self
106    }
107
108    /// The room this switch reserves, resolved as `caller ?? theme ?? framework`
109    /// (`switch.dart:603`).
110    fn reserved(&self, theme: &Theme) -> f32 {
111        self.tap_target
112            .or(theme.widgets.switch.tap_target)
113            .unwrap_or(theme.tap_target)
114            .min_side()
115    }
116
117    /// The room either side of the track: the caller's, the theme's, then 4 pixels.
118    fn room(&self, theme: &Theme) -> Insets {
119        self.padding
120            .or(theme.widgets.switch.padding)
121            .unwrap_or(PADDING)
122    }
123
124    /// The track's colour when the switch is **on**, in every state; the theme's
125    /// `primary` otherwise.
126    pub fn track_color(mut self, color: Color) -> Self {
127        self.track_color = Some(color);
128        self
129    }
130
131    /// The track's colour when the switch is **off**; the scheme's
132    /// `surface_container_highest` otherwise (`switch.dart:2246`), with a rule round it.
133    pub fn inactive_track_color(mut self, color: Color) -> Self {
134        self.inactive_track_color = Some(color);
135        self
136    }
137
138    /// **A glyph inside the thumb while the switch is on** (`switch.dart:2320`).
139    ///
140    /// Unset, as the reference's is: a switch is legible without one. It is there for the
141    /// setting that needs saying in more than colour and position — the two things a
142    /// reader may not be able to tell apart — and a tick inside the thumb says *on* in a
143    /// third way. The on thumb is already large enough to carry it.
144    pub fn thumb_icon(mut self, icon: IconData) -> Self {
145        self.thumb_icon = Some(icon);
146        self
147    }
148
149    /// The same while the switch is **off** — a cross beside the tick. The off thumb
150    /// grows to the on thumb's size to carry it (`switch.dart:1067`).
151    pub fn inactive_thumb_icon(mut self, icon: IconData) -> Self {
152        self.inactive_thumb_icon = Some(icon);
153        self
154    }
155
156    /// The thumb's colour when the switch is **on**, in every state; the scheme's
157    /// `on_primary` at rest otherwise (`switch.dart:2201`), `primary_container` under a
158    /// pointer, the keyboard or a finger.
159    pub fn thumb_color(mut self, color: Color) -> Self {
160        self.thumb_color = Some(color);
161        self
162    }
163
164    /// The thumb's colour when the switch is **off**, in every state; the scheme's
165    /// `outline` at rest otherwise (`switch.dart:2212`), `on_surface_variant` under a
166    /// pointer, the keyboard or a finger.
167    ///
168    /// The two ends are resolved apart and the thumb **travels between them**, so it is
169    /// still one thumb — one that changes colour as it travels, the way the track under
170    /// it does.
171    pub fn inactive_thumb_color(mut self, color: Color) -> Self {
172        self.inactive_thumb_color = Some(color);
173        self
174    }
175
176    /// **The thumb's colour in any state** — on or off ([`WidgetState::Selected`]),
177    /// hovered, focused, pressed, disabled — the reference's `thumbColor`. What it does
178    /// not answer falls to [`Self::thumb_color`] and [`Self::inactive_thumb_color`], then
179    /// the theme, then the reference's.
180    pub fn thumb_colors(mut self, colors: WidgetStateProperty<Color>) -> Self {
181        self.thumb_colors = Some(colors);
182        self
183    }
184
185    /// **The track's colour in any state** — the reference's `trackColor`. See
186    /// [`Self::thumb_colors`].
187    pub fn track_colors(mut self, colors: WidgetStateProperty<Color>) -> Self {
188        self.track_colors = Some(colors);
189        self
190    }
191
192    /// **The rule round the track in any state** (`switch.dart:2251`). Unset, `outline`
193    /// round an off track, none round an on one, and the disabled container colour round a
194    /// disabled off one.
195    pub fn track_outline_color(mut self, colors: WidgetStateProperty<Color>) -> Self {
196        self.track_outline_color = Some(colors);
197        self
198    }
199
200    /// **How wide that rule is.** Unset, the theme's, then 2 pixels.
201    pub fn track_outline_width(mut self, width: f32) -> Self {
202        self.track_outline_width = Some(width);
203        self
204    }
205
206    /// **The halo** round the thumb, state by state (`switch.dart:2264`). Unset, the
207    /// theme's, then `primary` round an on switch and `on_surface` round an off one, at
208    /// 8 % under a pointer and 10 % focused or pressed.
209    pub fn overlay_color(mut self, colors: WidgetStateProperty<Color>) -> Self {
210        self.overlay_color = Some(colors);
211        self
212    }
213
214    /// **The halo's radius.** Unset, the theme's, then 20.
215    pub fn splash_radius(mut self, radius: f32) -> Self {
216        self.splash_radius = Some(radius);
217        self
218    }
219
220    /// **The room either side of the track** (`switch.dart:604`). Unset, the theme's, then
221    /// 4 pixels left and right — which is what lets the halo spill past the track.
222    pub fn padding(mut self, padding: Insets) -> Self {
223        self.padding = Some(padding);
224        self
225    }
226
227    /// A closure producing a message from the new state.
228    pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
229        mut self,
230        on_toggle: impl Fn(bool) -> R + 'static,
231    ) -> Self {
232        self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
233        self
234    }
235
236    /// Whether the switch can be flipped. Disabled it is **inert** — no message, out of
237    /// the tab order, announced as unavailable — and it still shows which way it is set.
238    ///
239    /// See [`crate::disabled`] for the whole contract.
240    pub fn enabled(mut self, enabled: bool) -> Self {
241        self.enabled = enabled;
242        self
243    }
244
245    /// The states this switch is in, at one end of its travel.
246    fn states_at(&self, status: &Status, selected: bool) -> WidgetStates {
247        let states = if self.enabled {
248            status.states()
249        } else {
250            WidgetStates::EMPTY
251        };
252        states
253            .set(WidgetState::Selected, selected)
254            .set(WidgetState::Disabled, !self.enabled)
255    }
256
257    /// The thumb at one end: the caller's per-state colours, their plain colour for that
258    /// end, the theme's in the same order, then the reference's (`switch.dart:961`).
259    fn thumb_at(&self, theme: &Theme, states: WidgetStates) -> Color {
260        let t = &theme.widgets.switch;
261        let selected = states.contains(WidgetState::Selected);
262        let plain = |mine: Option<Color>, inactive: Option<Color>| {
263            if selected && self.enabled {
264                mine
265            } else {
266                inactive
267            }
268        };
269        self.thumb_colors
270            .as_ref()
271            .and_then(|p| p.resolve(states).copied())
272            .or_else(|| plain(self.thumb_color, self.inactive_thumb_color))
273            .or_else(|| {
274                t.thumb_colors
275                    .as_ref()
276                    .and_then(|p| p.resolve(states).copied())
277            })
278            .or_else(|| plain(t.thumb_color, t.inactive_thumb_color))
279            .unwrap_or_else(|| default_thumb(theme, states))
280    }
281
282    /// The track at one end, in the same order (`switch.dart:973`).
283    fn track_at(&self, theme: &Theme, states: WidgetStates) -> Color {
284        let t = &theme.widgets.switch;
285        let selected = states.contains(WidgetState::Selected);
286        let plain = |mine: Option<Color>, inactive: Option<Color>| {
287            if selected {
288                mine
289            } else {
290                inactive
291            }
292        };
293        self.track_colors
294            .as_ref()
295            .and_then(|p| p.resolve(states).copied())
296            .or_else(|| plain(self.track_color, self.inactive_track_color))
297            .or_else(|| {
298                t.track_colors
299                    .as_ref()
300                    .and_then(|p| p.resolve(states).copied())
301            })
302            .or_else(|| plain(t.track_color, t.inactive_track_color))
303            .unwrap_or_else(|| default_track(theme, states))
304    }
305
306    /// The rule round the track at one end (`switch.dart:981`).
307    fn outline_at(&self, theme: &Theme, states: WidgetStates) -> Color {
308        self.track_outline_color
309            .as_ref()
310            .and_then(|p| p.resolve(states).copied())
311            .or_else(|| {
312                theme
313                    .widgets
314                    .switch
315                    .track_outline_color
316                    .as_ref()
317                    .and_then(|p| p.resolve(states).copied())
318            })
319            .unwrap_or_else(|| {
320                if states.contains(WidgetState::Selected) {
321                    Color::TRANSPARENT
322                } else if states.contains(WidgetState::Disabled) {
323                    disabled_container(theme)
324                } else {
325                    theme.scheme.outline
326                }
327            })
328    }
329
330    /// The halo's colour with `state` added (`switch.dart:1014`).
331    fn halo_at(&self, theme: &Theme, selected: bool, state: WidgetState) -> Color {
332        let states = WidgetStates::of(state).set(WidgetState::Selected, selected);
333        self.overlay_color
334            .as_ref()
335            .and_then(|p| p.resolve(states).copied())
336            .or_else(|| {
337                theme
338                    .widgets
339                    .switch
340                    .overlay_color
341                    .as_ref()
342                    .and_then(|p| p.resolve(states).copied())
343            })
344            .unwrap_or_else(|| default_overlay(theme, states))
345    }
346
347    /// The thumb's size at linear progress `t` of a flip towards the switch's state
348    /// (`switch.dart:1569`): off to the midway pill over the first 11 %, to the on size
349    /// over the next 72 %, held for the rest — the other way about when it is turned off.
350    fn thumb_size(&self, t: f32) -> (f32, f32) {
351        let off = if self.inactive_thumb_icon.is_some() {
352            THUMB_WITH_ICON
353        } else {
354            THUMB_OFF
355        };
356        let (off, on) = ((2.0 * off, 2.0 * off), (2.0 * THUMB_ON, 2.0 * THUMB_ON));
357        let lerp =
358            |a: (f32, f32), b: (f32, f32), k: f32| (a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k);
359        let early = Curve::Cubic {
360            x1: 0.31,
361            y1: 0.0,
362            x2: 0.56,
363            y2: 1.0,
364        };
365        let late = Curve::Cubic {
366            x1: 0.2,
367            y1: 0.0,
368            x2: 0.0,
369            y2: 1.0,
370        };
371        if t <= 0.0 {
372            return off;
373        }
374        if t >= 1.0 {
375            return on;
376        }
377        if self.on {
378            if t < 0.11 {
379                lerp(off, THUMB_MIDWAY, early.transform(t / 0.11))
380            } else if t < 0.83 {
381                lerp(THUMB_MIDWAY, on, late.transform((t - 0.11) / 0.72))
382            } else {
383                on
384            }
385        } else if t < 0.17 {
386            off
387        } else if t < 0.89 {
388            let k = Curve::Flipped(Box::new(late)).transform((t - 0.17) / 0.72);
389            lerp(off, THUMB_MIDWAY, k)
390        } else {
391            let k = Curve::Flipped(Box::new(early)).transform((t - 0.89) / 0.11);
392            lerp(THUMB_MIDWAY, on, k)
393        }
394    }
395}
396
397/// **The reference's thumb** (`switch.dart:2183`): `on_primary` on, `outline` off, and
398/// under a pointer, the keyboard or a finger `primary_container` on and
399/// `on_surface_variant` off. Disabled, opaque, for the reason [`crate::disabled`] gives.
400fn default_thumb(theme: &Theme, states: WidgetStates) -> Color {
401    let c = &theme.scheme;
402    let selected = states.contains(WidgetState::Selected);
403    if states.contains(WidgetState::Disabled) {
404        return if selected {
405            disabled_mark(theme)
406        } else {
407            disabled_content(theme)
408        };
409    }
410    let busy = states.contains(WidgetState::Pressed)
411        || states.contains(WidgetState::Hovered)
412        || states.contains(WidgetState::Focused);
413    match (selected, busy) {
414        (true, true) => c.primary_container,
415        (true, false) => c.on_primary,
416        (false, true) => c.on_surface_variant,
417        (false, false) => c.outline,
418    }
419}
420
421/// **The reference's track** (`switch.dart:2217`): `primary` on, `surface_container_highest`
422/// off, in every state. Disabled, the flattened container on, and off the reference's 12 %
423/// wash of `surface_container_highest`, which is nearly the page itself (`:2223`) —
424/// resolved in sRGB for the reason [`crate::disabled::over_surface`] gives.
425fn default_track(theme: &Theme, states: WidgetStates) -> Color {
426    let selected = states.contains(WidgetState::Selected);
427    if states.contains(WidgetState::Disabled) {
428        return if selected {
429            disabled_container(theme)
430        } else {
431            theme.scheme.surface.lerp(
432                theme.scheme.surface_container_highest,
433                DISABLED_CONTAINER_OPACITY,
434            )
435        };
436    }
437    if selected {
438        theme.primary
439    } else {
440        theme.scheme.surface_container_highest
441    }
442}
443
444/// **The reference's halo** (`switch.dart:2264`): `primary` round an on switch and
445/// `on_surface` round an off one, 10 % pressed, 8 % under a pointer, 10 % focused.
446fn default_overlay(theme: &Theme, states: WidgetStates) -> Color {
447    let ink = if states.contains(WidgetState::Selected) {
448        theme.primary
449    } else {
450        theme.scheme.on_surface
451    };
452    if states.contains(WidgetState::Pressed) {
453        ink.with_alpha(0.1)
454    } else if states.contains(WidgetState::Hovered) {
455        ink.with_alpha(0.08)
456    } else if states.contains(WidgetState::Focused) {
457        ink.with_alpha(0.1)
458    } else {
459        Color::TRANSPARENT
460    }
461}
462
463/// `a` to `b` by `k`, landing **exactly** on either end: a lerp's arithmetic leaves the
464/// last bit off the colour it arrives at.
465fn travel(a: Color, b: Color, k: f32) -> Color {
466    if k <= 0.0 {
467        a
468    } else if k >= 1.0 {
469        b
470    } else {
471        a.lerp(b, k)
472    }
473}
474
475/// A colour that is not opaque, laid on the page first (`switch.dart:1667`), so the thumb
476/// never shows the track through it.
477fn opaque_on(page: Color, c: Color) -> Color {
478    if c.a >= 1.0 {
479        c
480    } else {
481        page.lerp(c.with_alpha(1.0), c.a)
482    }
483}
484
485impl<Msg> Widget<Msg> for Switch<Msg> {
486    fn style(&self) -> Style {
487        Widget::<Msg>::style_themed(self, &Theme::default())
488    }
489
490    /// **The box is the tap target, not the track** (`switch.dart:605`): 52 wide and the
491    /// room either side, and as tall as a finger. The track is painted in the middle of it.
492    fn style_themed(&self, theme: &Theme) -> Style {
493        let room = self.room(theme);
494        Style {
495            width: Dimension::Length(W + room.left + room.right),
496            height: Dimension::Length(H.max(self.reserved(theme)) + room.top + room.bottom),
497            ..Default::default()
498        }
499    }
500
501    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
502        &[]
503    }
504
505    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
506        let o = status.opacity;
507        // `t` is the flip's linear progress, 0 off and 1 on. The reference curves it two
508        // ways (`switch.dart:797`, `:1153`): the thumb's place overshoots and settles
509        // (`easeOutBack`, flipped on the way back), the colours ease out on the way on and
510        // in on the way off.
511        let t = status.value;
512        let curved = |curve: Curve| {
513            // The ends are the ends, as every curve's are in the reference
514            // (`Curve.transform`); a cubic's search stops a hair short of them.
515            if t <= 0.0 || t >= 1.0 {
516                t.clamp(0.0, 1.0)
517            } else {
518                curve.transform(t)
519            }
520        };
521        let (place, mix) = if status.value_placed {
522            // Under a finger, or settling from where one let go, the thumb is where the
523            // finger put it: no curve (`switch.dart:873`).
524            (t.clamp(0.0, 1.0), curved(Curve::ease_out()))
525        } else if self.on {
526            (curved(ease_out_back()), curved(Curve::ease_out()))
527        } else {
528            (
529                curved(Curve::Flipped(Box::new(ease_out_back()))),
530                curved(Curve::ease_in()),
531            )
532        };
533        // Each end of the travel is resolved on its own and the travel runs between them,
534        // so an override moves the whole animation with it.
535        let on_states = self.states_at(&status, true);
536        let off_states = self.states_at(&status, false);
537        let track = travel(
538            self.track_at(theme, off_states),
539            self.track_at(theme, on_states),
540            mix,
541        );
542        let thumb = opaque_on(
543            theme.scheme.surface,
544            travel(
545                self.thumb_at(theme, off_states),
546                self.thumb_at(theme, on_states),
547                mix,
548            ),
549        );
550        // The rule belongs to the **off** end: transparent round an on track
551        // (`switch.dart:2254`), so it fades out along the travel.
552        let (rule_off, rule_on) = (
553            self.outline_at(theme, off_states),
554            self.outline_at(theme, on_states),
555        );
556        let edge = if rule_on.a == 0.0 {
557            rule_off.fade(1.0 - mix)
558        } else if rule_off.a == 0.0 {
559            rule_on.fade(mix)
560        } else {
561            travel(rule_off, rule_on, mix)
562        };
563        let edge_width = self
564            .track_outline_width
565            .or(theme.widgets.switch.track_outline_width)
566            .unwrap_or(TRACK_OUTLINE);
567
568        // The track, centred in the box (`switch.dart:1706`).
569        let track_rect = Rect::new(
570            bounds.x + (bounds.width - W) * 0.5,
571            bounds.y + (bounds.height - H) * 0.5,
572            W,
573            H,
574        );
575        scene.draw_rect(track_rect, track.fade(o), H * 0.5, edge_width, edge.fade(o));
576
577        // The thumb's centre runs half a track-height in from either end, so it stays
578        // centred in the rounded cap; in right-to-left, from the right.
579        let along = if theme.direction == frus_core::TextDirection::Rtl {
580            1.0 - place
581        } else {
582            place
583        };
584        let cx = track_rect.x + H * 0.5 + (W - H) * along;
585        let cy = track_rect.y + H * 0.5;
586
587        // **The halo** (`switch.dart:1691`), over the track and under the thumb, round the
588        // thumb's centre and wider than the track: the room either side is what it spills
589        // into. Nothing round a switch that cannot be worked.
590        if self.enabled {
591            let radius = self
592                .splash_radius
593                .or(theme.widgets.switch.splash_radius)
594                .unwrap_or(SPLASH);
595            crate::toggleable::paint_halos(scene, Point::new(cx, cy), radius, &status, |s| {
596                self.halo_at(theme, self.on, s)
597            });
598        }
599
600        // The thumb: its size along the flip, swelling when held (`switch.dart:1627`) —
601        // grown into over the press's own 200 ms, from wherever it has got to.
602        let (mut w, mut h) = self.thumb_size(t);
603        if self.enabled {
604            let press = status.press_progress.clamp(0.0, 1.0);
605            w += (2.0 * THUMB_PRESSED - w) * press;
606            h += (2.0 * THUMB_PRESSED - h) * press;
607        }
608        scene.draw_rect(
609            Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
610            thumb.fade(o),
611            h * 0.5,
612            0.0,
613            Color::TRANSPARENT,
614        );
615
616        // And the glyph inside it, from the end the switch is **set** to. Its colour is
617        // the track's own at the off end (`switch.dart:2349`), so it reads as a hole
618        // punched through the thumb rather than as a mark drawn on it.
619        let icon = if self.on {
620            self.thumb_icon
621        } else {
622            self.inactive_thumb_icon
623        };
624        if let Some(icon) = icon {
625            let t_widget = &theme.widgets.switch;
626            let ink = if !self.enabled {
627                over_surface(theme, crate::disabled::DISABLED_CONTENT_OPACITY)
628            } else if self.on {
629                t_widget
630                    .icon_color
631                    .unwrap_or(theme.scheme.on_primary_container)
632            } else {
633                t_widget
634                    .inactive_icon_color
635                    .unwrap_or(theme.scheme.surface_container_highest)
636            };
637            let path = icon.placed(
638                ICON_SIZE,
639                cx - ICON_SIZE * 0.5,
640                cy - ICON_SIZE * 0.5,
641                theme.direction,
642            );
643            scene.fill_path(&path, ink.fade(o));
644        }
645    }
646
647    fn on_click(&self) -> Option<Msg> {
648        if !self.enabled {
649            return None;
650        }
651        self.on_toggle.as_ref().map(|make| make(!self.on))
652    }
653
654    /// **The thumb follows a horizontal drag** (`switch.dart:1078`), when there is
655    /// something to tell.
656    fn pan_axis(&self) -> Option<crate::PanAxis> {
657        (self.enabled && self.on_toggle.is_some()).then_some(crate::PanAxis::Horizontal)
658    }
659
660    /// Where the drag puts the thumb: moved by the finger's travel over the track's inner
661    /// length (`switch.dart:870`), the other way in right-to-left.
662    fn pan_value(&self, event: crate::PanEvent, value: f32, rtl: bool) -> Option<f32> {
663        match event {
664            crate::PanEvent::Start { .. } => Some(value.clamp(0.0, 1.0)),
665            crate::PanEvent::Update { delta, .. } => {
666                let along = delta.x / (W - H);
667                let along = if rtl { -along } else { along };
668                Some((value + along).clamp(0.0, 1.0))
669            }
670            crate::PanEvent::End { .. } => None,
671        }
672    }
673
674    /// **Let go past half way, it flips** (`switch.dart:885`); short of it, the thumb goes
675    /// back and nothing is said.
676    fn on_value_release(&self, value: f32) -> Option<Msg> {
677        if !self.enabled || (value >= 0.5) == self.on {
678            return None;
679        }
680        self.on_toggle.as_ref().map(|make| make(!self.on))
681    }
682
683    fn focusable(&self) -> bool {
684        self.enabled
685    }
686
687    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
688        // Still on or off, still announced — a switch that fell silent would read as a
689        // setting that had gone away rather than one that cannot be changed.
690        let semantics =
691            frus_core::SemanticsProperties::new(frus_core::Role::Switch).toggled(self.on);
692        Some(if self.enabled {
693            semantics.clickable()
694        } else {
695            semantics.disabled(true)
696        })
697    }
698
699    fn anim_target(&self) -> Option<f32> {
700        Some(if self.on { 1.0 } else { 0.0 })
701    }
702
703    /// The reference's 300 ms (`switch.dart:2386`).
704    fn anim_duration(&self) -> f32 {
705        TOGGLE_SECONDS
706    }
707
708    /// **Linear**: the value is the flip's progress, which the paint curves itself — one
709    /// way for the thumb's place, another for its colours and a third for its size, as the
710    /// reference curves one controller three ways.
711    fn anim_curve(&self) -> frus_core::Curve {
712        frus_core::Curve::Linear
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use crate::disabled::{disabled_container, disabled_content, disabled_mark};
720    use crate::icons::Icons;
721    use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
722    use crate::widget::Widget;
723
724    #[derive(Clone, Debug, PartialEq)]
725    enum Msg {
726        Set(bool),
727    }
728
729    /// Each rectangle the switch painted, as `(fill, ring)`.
730    fn painted(on: bool, enabled: bool, theme: &Theme) -> Vec<(Color, Color)> {
731        let mut scene = Scene::new();
732        Widget::<Msg>::paint(
733            &Switch::<Msg>::new(on).enabled(enabled),
734            Rect::new(0.0, 0.0, W, H),
735            Status {
736                opacity: 1.0,
737                value: if on { 1.0 } else { 0.0 },
738                ..Default::default()
739            },
740            theme,
741            &mut scene,
742        );
743        scene
744            .primitives()
745            .iter()
746            .filter_map(|p| match p {
747                frus_core::Primitive::Rect {
748                    color,
749                    border_color,
750                    ..
751                } => Some((*color, *border_color)),
752                _ => None,
753            })
754            .collect()
755    }
756
757    /// Every rectangle a switch paints, with its geometry, under a given interaction.
758    fn boxes(switch: &Switch<Msg>, status: Status, theme: &Theme) -> Vec<(Rect, Color)> {
759        let mut scene = Scene::new();
760        Widget::<Msg>::paint(switch, Rect::new(0.0, 0.0, W, H), status, theme, &mut scene);
761        scene
762            .primitives()
763            .iter()
764            .filter_map(|p| match p {
765                frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
766                _ => None,
767            })
768            .collect()
769    }
770
771    /// A switch being painted at one end of its travel, in a given interaction state.
772    fn state(on: bool) -> Status {
773        Status {
774            opacity: 1.0,
775            value: if on { 1.0 } else { 0.0 },
776            ..Default::default()
777        }
778    }
779
780    /// **A switch answers the pointer with the reference's halo** (`switch.dart:1691`):
781    /// a circle of radius 20 round the thumb, wider than the track, over the track and
782    /// under the thumb — `on_surface` round an off switch, `primary` round an on one, 8 %
783    /// under a pointer and 10 % pressed or focused (`switch.dart:2264`).
784    #[test]
785    fn a_switch_answers_the_pointer_with_a_halo() {
786        let theme = Theme::default();
787        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
788        let with = |on: bool, f: fn(&mut Status)| {
789            let mut status = state(on);
790            f(&mut status);
791            status
792        };
793        let hover = |s: &mut Status| s.hover_progress = 1.0;
794        let press = |s: &mut Status| s.press_progress = 1.0;
795        let focus = |s: &mut Status| s.focus_progress = 1.0;
796        assert_eq!(
797            boxes(&switch, state(false), &theme).len(),
798            2,
799            "track, thumb"
800        );
801        let lit = boxes(&switch, with(false, hover), &theme);
802        assert_eq!(lit.len(), 3, "track, halo, thumb");
803        let (halo, colour) = lit[1];
804        assert_eq!(colour, theme.scheme.on_surface.with_alpha(0.08));
805        assert_eq!((halo.width, halo.height), (2.0 * SPLASH, 2.0 * SPLASH));
806        assert!(halo.height > H, "wider than the track");
807        let thumb = lit[2].0;
808        assert_eq!(
809            (halo.x + SPLASH, halo.y + SPLASH),
810            (thumb.x + thumb.width * 0.5, thumb.y + thumb.height * 0.5),
811            "round the thumb"
812        );
813
814        let on = Switch::<Msg>::new(true).on_toggle(Msg::Set);
815        assert_eq!(
816            boxes(&on, with(true, hover), &theme)[1].1,
817            theme.primary.with_alpha(0.08)
818        );
819        assert_eq!(
820            boxes(&on, with(true, press), &theme)[1].1,
821            theme.primary.with_alpha(0.1)
822        );
823        assert_eq!(
824            boxes(&switch, with(false, focus), &theme)[1].1,
825            theme.scheme.on_surface.with_alpha(0.1)
826        );
827
828        // The caller's halo, and its radius.
829        let mine = Switch::<Msg>::new(false)
830            .on_toggle(Msg::Set)
831            .overlay_color(WidgetStateProperty::all(Color::rgb(1.0, 0.0, 0.0)))
832            .splash_radius(12.0);
833        let (halo, colour) = boxes(&mine, with(false, hover), &theme)[1];
834        assert_eq!((colour, halo.width), (Color::rgb(1.0, 0.0, 0.0), 24.0));
835    }
836
837    /// **The thumb answers too** (`switch.dart:2183`): under a pointer, the keyboard or a
838    /// finger it is `primary_container` on and `on_surface_variant` off.
839    #[test]
840    fn the_thumb_answers_the_pointer() {
841        let theme = Theme::light();
842        let hovered = |on: bool| Status {
843            hover_progress: 1.0,
844            interaction: crate::interaction::Interaction::Hovered,
845            ..state(on)
846        };
847        let thumb = |on: bool, status: Status| {
848            boxes(&Switch::<Msg>::new(on).on_toggle(Msg::Set), status, &theme)
849                .last()
850                .expect("a thumb")
851                .1
852        };
853        assert_eq!(thumb(true, hovered(true)), theme.scheme.primary_container);
854        assert_eq!(
855            thumb(false, hovered(false)),
856            theme.scheme.on_surface_variant
857        );
858        assert_eq!(thumb(true, state(true)), theme.scheme.on_primary);
859        assert_eq!(thumb(false, state(false)), theme.scheme.outline);
860
861        // The caller's colours, state by state, before anything else.
862        let mine = WidgetStateProperty::new()
863            .when(WidgetState::Hovered, Color::rgb(0.0, 0.0, 1.0))
864            .when(WidgetState::Selected, Color::rgb(0.0, 1.0, 0.0));
865        let painted = |on: bool, status: Status| {
866            boxes(
867                &Switch::<Msg>::new(on)
868                    .on_toggle(Msg::Set)
869                    .thumb_colors(mine.clone()),
870                status,
871                &theme,
872            )
873            .last()
874            .expect("a thumb")
875            .1
876        };
877        assert_eq!(painted(false, hovered(false)), Color::rgb(0.0, 0.0, 1.0));
878        assert_eq!(painted(true, state(true)), Color::rgb(0.0, 1.0, 0.0));
879        assert_eq!(
880            painted(false, state(false)),
881            theme.scheme.outline,
882            "what it leaves unsaid is the reference's"
883        );
884    }
885
886    /// **The track answers in any state** the caller names, and the rule round it too.
887    #[test]
888    fn the_track_and_its_rule_are_the_callers() {
889        let theme = Theme::light();
890        let red = Color::rgb(1.0, 0.0, 0.0);
891        let blue = Color::rgb(0.0, 0.0, 1.0);
892        let switch = Switch::<Msg>::new(false)
893            .on_toggle(Msg::Set)
894            .track_colors(WidgetStateProperty::all(red))
895            .track_outline_color(WidgetStateProperty::all(blue))
896            .track_outline_width(3.0);
897        let mut scene = Scene::new();
898        Widget::<Msg>::paint(
899            &switch,
900            Rect::new(0.0, 0.0, W, H),
901            state(false),
902            &theme,
903            &mut scene,
904        );
905        match scene.primitives()[0] {
906            frus_core::Primitive::Rect {
907                color,
908                border_color,
909                border_width,
910                ..
911            } => assert_eq!((color, border_color, border_width), (red, blue, 3.0)),
912            _ => panic!("the track is a rectangle"),
913        }
914        // Unsaid, the reference's 2 (`switch.dart:2298`).
915        let plain = Switch::<Msg>::new(false).on_toggle(Msg::Set);
916        let mut scene = Scene::new();
917        Widget::<Msg>::paint(
918            &plain,
919            Rect::new(0.0, 0.0, W, H),
920            state(false),
921            &theme,
922            &mut scene,
923        );
924        match scene.primitives()[0] {
925            frus_core::Primitive::Rect { border_width, .. } => assert_eq!(border_width, 2.0),
926            _ => panic!("the track is a rectangle"),
927        }
928    }
929
930    /// And a switch that cannot be worked does not light, in any state: a state layer is
931    /// the promise of an interaction.
932    #[test]
933    fn a_disabled_switch_does_not_light() {
934        let theme = Theme::default();
935        let dead = Switch::<Msg>::new(false).on_toggle(Msg::Set).enabled(false);
936        for status in [
937            Status {
938                hover_progress: 1.0,
939                ..state(false)
940            },
941            Status {
942                focus_progress: 1.0,
943                ..state(false)
944            },
945            Status {
946                press_progress: 1.0,
947                ..state(false)
948            },
949        ] {
950            assert_eq!(
951                boxes(&dead, status, &theme).len(),
952                2,
953                "track and thumb only"
954            );
955        }
956    }
957
958    /// The thumb's radius, at the end of the travel it was painted at.
959    fn thumb_radius(switch: &Switch<Msg>, status: Status, theme: &Theme) -> f32 {
960        boxes(switch, status, theme)
961            .last()
962            .map(|(rect, _)| rect.width * 0.5)
963            .expect("a switch paints a thumb")
964    }
965
966    /// **An end whose thumb carries a glyph is the larger one** (`switch.dart:1067`):
967    /// sixteen pixels of glyph do not fit in a sixteen-pixel dot. The on thumb is that
968    /// size already, so only an off glyph grows the off thumb.
969    #[test]
970    fn a_thumb_that_carries_a_glyph_is_the_larger_one() {
971        let theme = Theme::default();
972        let bare = Switch::<Msg>::new(false).on_toggle(Msg::Set);
973        assert_eq!(thumb_radius(&bare, state(false), &theme), THUMB_OFF);
974
975        let ticked = Switch::<Msg>::new(false)
976            .on_toggle(Msg::Set)
977            .thumb_icon(Icons::CHECK);
978        assert_eq!(
979            thumb_radius(&ticked, state(false), &theme),
980            THUMB_OFF,
981            "an on glyph leaves the off thumb alone"
982        );
983        let crossed = Switch::<Msg>::new(false)
984            .on_toggle(Msg::Set)
985            .inactive_thumb_icon(Icons::CLOSE);
986        assert_eq!(
987            thumb_radius(&crossed, state(false), &theme),
988            THUMB_WITH_ICON
989        );
990        assert_eq!(
991            thumb_radius(&crossed, state(true), &theme),
992            THUMB_ON,
993            "and the on thumb was already that size"
994        );
995    }
996
997    /// The glyph itself: drawn only at the end that has one, in the colour that end names.
998    #[test]
999    fn the_glyph_is_drawn_at_the_end_that_has_one() {
1000        let theme = Theme::default();
1001        let glyph = |switch: &Switch<Msg>, on: bool| {
1002            let mut scene = Scene::new();
1003            Widget::<Msg>::paint(
1004                switch,
1005                Rect::new(0.0, 0.0, W, H),
1006                state(on),
1007                &theme,
1008                &mut scene,
1009            );
1010            scene.primitives().iter().find_map(|p| match p {
1011                frus_core::Primitive::Path { fill, .. } => *fill,
1012                _ => None,
1013            })
1014        };
1015        let ticked = Switch::<Msg>::new(true)
1016            .on_toggle(Msg::Set)
1017            .thumb_icon(Icons::CHECK);
1018        assert_eq!(
1019            glyph(&ticked, true),
1020            Some(theme.scheme.on_primary_container)
1021        );
1022        // The end a switch is **set** to decides which glyph it carries, as the reference
1023        // resolves `thumbIcon` from the state rather than from the animation: a switch
1024        // that is off and names no off icon carries none.
1025        let unticked = Switch::<Msg>::new(false)
1026            .on_toggle(Msg::Set)
1027            .thumb_icon(Icons::CHECK);
1028        assert_eq!(glyph(&unticked, false), None, "nothing at the other end");
1029
1030        let crossed = Switch::<Msg>::new(false)
1031            .on_toggle(Msg::Set)
1032            .thumb_icon(Icons::CHECK)
1033            .inactive_thumb_icon(Icons::CLOSE);
1034        assert_eq!(
1035            glyph(&crossed, false),
1036            Some(theme.scheme.surface_container_highest),
1037            "off, the glyph takes the track's own colour: a hole, not a mark"
1038        );
1039    }
1040
1041    /// **A held thumb swells** (`switch.dart:2357`), past either end of the travel.
1042    #[test]
1043    fn a_held_thumb_swells() {
1044        let theme = Theme::default();
1045        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1046        let pressed = Status {
1047            press_progress: 1.0,
1048            ..state(false)
1049        };
1050        assert_eq!(thumb_radius(&switch, pressed, &theme), THUMB_PRESSED);
1051        // Past **both** ends of the travel, which is what makes it read as a press rather
1052        // than as the switch having moved.
1053        for end in [false, true] {
1054            assert!(
1055                thumb_radius(&switch, pressed, &theme) > thumb_radius(&switch, state(end), &theme),
1056                "not past the {end} end"
1057            );
1058        }
1059    }
1060
1061    /// And it **grows** into it (milestone 441): half way through the press the thumb is
1062    /// half way there, where it used to arrive whole on the first frame the finger was
1063    /// down and leave whole on the first frame it was not.
1064    #[test]
1065    fn a_held_thumb_grows_into_it() {
1066        let theme = Theme::default();
1067        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1068        let at = |p: f32| {
1069            thumb_radius(
1070                &switch,
1071                Status {
1072                    press_progress: p,
1073                    ..state(false)
1074                },
1075                &theme,
1076            )
1077        };
1078        assert_eq!(at(0.0), THUMB_OFF, "untouched");
1079        assert_eq!(at(1.0), THUMB_PRESSED, "held");
1080        assert!(
1081            (at(0.5) - (THUMB_OFF + THUMB_PRESSED) * 0.5).abs() < 0.01,
1082            "half way = {}",
1083            at(0.5)
1084        );
1085    }
1086
1087    /// The height a switch asks the layout for, under a given theme.
1088    fn reserved_height(switch: &Switch<Msg>, theme: &Theme) -> f32 {
1089        match Widget::<Msg>::style_themed(switch, theme).height {
1090            Dimension::Length(h) => h,
1091            other => panic!("a switch asks for a length, not {other:?}"),
1092        }
1093    }
1094
1095    /// **A switch's box is its tap target, not its track** (milestone 442).
1096    ///
1097    /// The track is 32 pixels tall and a finger is not. The reference lays a switch out
1098    /// at 52 × 48 and paints the track in the middle (`switch.dart:605`); this asked the
1099    /// layout for the track and nothing else, so the area a click could land in was the
1100    /// track exactly.
1101    #[test]
1102    fn a_switch_reserves_room_for_a_finger() {
1103        let theme = Theme::default();
1104        assert_eq!(
1105            reserved_height(&Switch::<Msg>::new(false).on_toggle(Msg::Set), &theme),
1106            MIN_TAP_TARGET
1107        );
1108        assert_eq!(
1109            reserved_height(
1110                &Switch::<Msg>::new(false)
1111                    .on_toggle(Msg::Set)
1112                    .tap_target(TapTarget::ShrinkWrap),
1113                &theme
1114            ),
1115            SHRUNK_TAP_TARGET,
1116            "and a caller may ask for the smaller answer"
1117        );
1118
1119        // The theme answers for a switch that has not said, and the widget theme sits
1120        // between the two.
1121        let dense = Theme {
1122            tap_target: TapTarget::ShrinkWrap,
1123            ..Theme::default()
1124        };
1125        let plain = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1126        assert_eq!(reserved_height(&plain, &dense), SHRUNK_TAP_TARGET);
1127        let mut mixed = dense;
1128        mixed.widgets.switch.tap_target = Some(TapTarget::Padded);
1129        assert_eq!(reserved_height(&plain, &mixed), MIN_TAP_TARGET);
1130    }
1131
1132    /// And **nothing it paints moves**: the track is centred in the room, and the thumb
1133    /// stays centred on the track.
1134    #[test]
1135    fn the_track_is_centred_in_the_room() {
1136        let theme = Theme::default();
1137        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1138        let mut scene = Scene::new();
1139        Widget::<Msg>::paint(
1140            &switch,
1141            Rect::new(0.0, 0.0, W, MIN_TAP_TARGET),
1142            state(false),
1143            &theme,
1144            &mut scene,
1145        );
1146        let rects: Vec<Rect> = scene
1147            .primitives()
1148            .iter()
1149            .filter_map(|p| match p {
1150                frus_core::Primitive::Rect { rect, .. } => Some(*rect),
1151                _ => None,
1152            })
1153            .collect();
1154
1155        let track = rects[0];
1156        assert_eq!(track.height, H, "the track keeps its own height");
1157        assert!(
1158            (track.y - (MIN_TAP_TARGET - H) * 0.5).abs() < 0.01,
1159            "and sits in the middle of the room: {track:?}"
1160        );
1161        let thumb = *rects.last().expect("a switch paints a thumb");
1162        assert!(
1163            (thumb.y + thumb.height * 0.5 - MIN_TAP_TARGET * 0.5).abs() < 0.01,
1164            "the thumb is centred on the track: {thumb:?}"
1165        );
1166    }
1167
1168    /// **A switch that can be flipped takes a horizontal drag** (`switch.dart:1078`); one
1169    /// that cannot, or has no one to tell, takes none, so a drag there is the page's.
1170    #[test]
1171    fn a_live_switch_takes_a_horizontal_drag() {
1172        let live = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1173        assert_eq!(
1174            Widget::<Msg>::pan_axis(&live),
1175            Some(crate::PanAxis::Horizontal)
1176        );
1177        assert_eq!(Widget::<Msg>::pan_axis(&live.enabled(false)), None);
1178        assert_eq!(Widget::<Msg>::pan_axis(&Switch::<Msg>::new(false)), None);
1179    }
1180
1181    /// **The thumb moves by the finger's travel over the track's inner length**
1182    /// (`switch.dart:875`) — 20 pixels end to end — the other way in right-to-left, and
1183    /// never past an end.
1184    #[test]
1185    fn the_drag_moves_the_thumb_by_the_finger() {
1186        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1187        let update = |dx: f32| crate::PanEvent::Update {
1188            local: frus_core::Point::new(0.0, 0.0),
1189            delta: frus_core::Point::new(dx, 0.0),
1190        };
1191        let start = crate::PanEvent::Start {
1192            local: frus_core::Point::new(0.0, 0.0),
1193        };
1194        let value = |e, v, rtl| Widget::<Msg>::pan_value(&switch, e, v, rtl);
1195        assert_eq!(value(start, 0.0, false), Some(0.0), "held where it is");
1196        assert_eq!(value(update(5.0), 0.0, false), Some(0.25));
1197        assert_eq!(value(update(5.0), 0.5, true), Some(0.25), "mirrored");
1198        assert_eq!(
1199            value(update(50.0), 0.5, false),
1200            Some(1.0),
1201            "not past the end"
1202        );
1203        assert_eq!(value(update(-50.0), 0.5, false), Some(0.0), "nor the start");
1204        let end = crate::PanEvent::End {
1205            velocity: frus_core::Point::new(0.0, 0.0),
1206        };
1207        assert_eq!(value(end, 0.7, false), None, "the release is not a move");
1208    }
1209
1210    /// **Let go past half way it flips, short of it nothing is said** (`switch.dart:885`).
1211    #[test]
1212    fn let_go_past_half_way_it_flips() {
1213        let off = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1214        assert_eq!(
1215            Widget::<Msg>::on_value_release(&off, 0.5),
1216            Some(Msg::Set(true))
1217        );
1218        assert_eq!(Widget::<Msg>::on_value_release(&off, 0.49), None);
1219        let on = Switch::<Msg>::new(true).on_toggle(Msg::Set);
1220        assert_eq!(
1221            Widget::<Msg>::on_value_release(&on, 0.3),
1222            Some(Msg::Set(false))
1223        );
1224        assert_eq!(Widget::<Msg>::on_value_release(&on, 0.5), None);
1225        assert_eq!(
1226            Widget::<Msg>::on_value_release(&off.enabled(false), 1.0),
1227            None,
1228            "a disabled switch never flips"
1229        );
1230    }
1231
1232    /// **A placed value is painted straight** (`switch.dart:873`): the thumb is where the
1233    /// finger put it, not where the overshooting curve would take that value.
1234    #[test]
1235    fn a_placed_thumb_is_where_the_finger_put_it() {
1236        let theme = Theme::default();
1237        let switch = Switch::<Msg>::new(true).on_toggle(Msg::Set);
1238        let centre = |placed: bool| {
1239            let thumb = boxes(
1240                &switch,
1241                Status {
1242                    value: 0.7,
1243                    value_placed: placed,
1244                    ..state(true)
1245                },
1246                &theme,
1247            )
1248            .last()
1249            .expect("a thumb")
1250            .0;
1251            thumb.x + thumb.width * 0.5
1252        };
1253        assert!((centre(true) - (H * 0.5 + (W - H) * 0.7)).abs() < 1e-4);
1254        assert!(
1255            centre(false) > centre(true),
1256            "the curve would have overshot"
1257        );
1258    }
1259
1260    #[test]
1261    fn click_toggles() {
1262        assert_eq!(
1263            Widget::on_click(&Switch::new(false).on_toggle(Msg::Set)),
1264            Some(Msg::Set(true))
1265        );
1266    }
1267
1268    #[test]
1269    fn a_disabled_switch_is_inert_but_still_says_which_way_it_is_set() {
1270        let dead = Switch::new(true).on_toggle(Msg::Set).enabled(false);
1271        assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
1272        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
1273        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1274        assert!(semantics.disabled, "and announced as unavailable");
1275        assert_eq!(semantics.toggled, frus_core::Toggled::True, "still on");
1276    }
1277
1278    /// The switch is the control that takes **both** halves of the rule at once, which is
1279    /// the argument that the split is container-against-content rather than one rule per
1280    /// widget. If these two ever collapse to the same opacity, that argument is gone.
1281    #[test]
1282    fn a_disabled_switch_takes_both_halves_of_the_rule() {
1283        for theme in [Theme::dark(), Theme::light()] {
1284            let off = painted(false, false, &theme);
1285            let ((track, ring), thumb) = (off[0], off[1].0);
1286            // Since milestone 428 the **off** track is the reference's
1287            // `surfaceContainerHighest` wash, which is near enough the page to be nothing;
1288            // the container half of the rule is the ring round it. The switch still shows
1289            // both halves at once, which is the whole argument — the container is the
1290            // pill's edge rather than its fill while it is off.
1291            assert_eq!(ring, disabled_container(&theme), "the ring is a container");
1292            assert_eq!(
1293                thumb,
1294                disabled_content(&theme),
1295                "the thumb is content on it"
1296            );
1297            // Quieter means closer to the surface: since milestone 329 both tokens are
1298            // opaque, which is the flattening this rule asks for.
1299            let from_surface = |c: Color| {
1300                (c.r - theme.scheme.surface.r).abs()
1301                    + (c.g - theme.scheme.surface.g).abs()
1302                    + (c.b - theme.scheme.surface.b).abs()
1303            };
1304            assert!(
1305                from_surface(ring) < from_surface(thumb),
1306                "and the container is the quieter of the two"
1307            );
1308            assert!(
1309                from_surface(track) < from_surface(ring),
1310                "with the wash inside it quieter still"
1311            );
1312
1313            // Flipped on, the track *is* the flattened container and the thumb sits on
1314            // it, so it punches through opaquely rather than adding a third translucent
1315            // layer — and the ring goes, a filled track having no edge.
1316            let on = painted(true, false, &theme);
1317            assert_eq!(on[0].0, disabled_container(&theme), "a filled track");
1318            assert_eq!(on[1].0, disabled_mark(&theme), "an opaque thumb");
1319            assert_eq!(on[0].1.a, 0.0, "and no ring round it");
1320        }
1321    }
1322
1323    #[test]
1324    fn a_live_switch_is_untouched_by_any_of_it() {
1325        let theme = Theme::dark();
1326        let live = painted(true, true, &theme);
1327        assert_ne!(live[0].0, disabled_container(&theme));
1328        assert_ne!(live[1].0, disabled_content(&theme));
1329    }
1330}
1331
1332#[cfg(test)]
1333mod color_tests {
1334    use super::*;
1335    use crate::widget::Widget;
1336
1337    const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
1338    const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
1339    const KNOB: Color = Color::rgb(0.1, 0.1, 0.9);
1340
1341    /// The (track, thumb, ring) a switch painted.
1342    fn painted(switch: &Switch<()>, on: bool, theme: &Theme) -> (Color, Color, Color) {
1343        at(switch, if on { 1.0 } else { 0.0 }, theme)
1344    }
1345
1346    /// The same, anywhere along the travel.
1347    fn at(switch: &Switch<()>, t: f32, theme: &Theme) -> (Color, Color, Color) {
1348        let mut scene = Scene::new();
1349        Widget::<()>::paint(
1350            switch,
1351            Rect::new(0.0, 0.0, W, H),
1352            Status {
1353                opacity: 1.0,
1354                value: t,
1355                ..Default::default()
1356            },
1357            theme,
1358            &mut scene,
1359        );
1360        let rects: Vec<(Color, Color)> = scene
1361            .primitives()
1362            .iter()
1363            .filter_map(|p| match p {
1364                frus_core::Primitive::Rect {
1365                    color,
1366                    border_color,
1367                    ..
1368                } => Some((*color, *border_color)),
1369                _ => None,
1370            })
1371            .collect();
1372        (rects[0].0, rects[1].0, rects[0].1)
1373    }
1374
1375    /// Nothing said: the four colours the reference names, and the ring that only the
1376    /// off end has.
1377    #[test]
1378    fn the_defaults_are_the_reference_s() {
1379        let theme = Theme::default();
1380        // Both ends are the arrival of a lerp, so they land on the colour rather than
1381        // matching it bit for bit — see `lands_on`.
1382        let (track, thumb, ring) = painted(&Switch::<()>::new(true), true, &theme);
1383        lands_on(track, theme.primary); // `switch.dart:2235`
1384        lands_on(thumb, theme.scheme.on_primary); // `switch.dart:2201`
1385        assert_eq!(ring.a, 0.0, "a filled track has no edge (`:2254`)");
1386
1387        let (track, thumb, ring) = painted(&Switch::<()>::new(false), false, &theme);
1388        lands_on(track, theme.scheme.surface_container_highest); // `switch.dart:2246`
1389        lands_on(thumb, theme.scheme.outline); // `switch.dart:2212`
1390        lands_on(ring, theme.scheme.outline); // `switch.dart:2259`
1391        assert_eq!(ring.a, 1.0, "and it is drawn");
1392    }
1393
1394    /// The **ring fades out along the travel**, which is the animated form of the
1395    /// reference's either-or: an edge round an empty track, none round a full one.
1396    #[test]
1397    fn the_ring_belongs_to_the_off_end_alone() {
1398        let theme = Theme::default();
1399        let switch = Switch::<()>::new(false);
1400        let alpha = |t: f32| at(&switch, t, &theme).2.a;
1401        assert_eq!(alpha(0.0), 1.0, "fully drawn off");
1402        assert_eq!(alpha(1.0), 0.0, "gone on");
1403        // Turned off, the colours ease in (`switch.dart:1156`).
1404        assert!(
1405            (alpha(0.5) - (1.0 - Curve::ease_in().transform(0.5))).abs() < 1e-6,
1406            "and part drawn halfway, rather than snapping at one end"
1407        );
1408    }
1409
1410    /// The **thumb grows** as the switch is flipped — a dot inside an outlined track
1411    /// becoming a disc on a filled one (`switch.dart:2354`, `:2317`). It is most of what
1412    /// tells the two states apart at a glance, and it is the half a colour change alone
1413    /// would have missed.
1414    #[test]
1415    fn the_thumb_grows_as_it_travels() {
1416        let theme = Theme::default();
1417        let switch = Switch::<()>::new(false);
1418        let thumb = |t: f32| {
1419            let mut scene = Scene::new();
1420            Widget::<()>::paint(
1421                &switch,
1422                Rect::new(0.0, 0.0, W, H),
1423                Status {
1424                    opacity: 1.0,
1425                    value: t,
1426                    ..Default::default()
1427                },
1428                &theme,
1429                &mut scene,
1430            );
1431            match scene.primitives()[1] {
1432                frus_core::Primitive::Rect { rect, .. } => rect,
1433                _ => panic!("the thumb is a rectangle"),
1434            }
1435        };
1436        assert_eq!(thumb(0.0).width, THUMB_OFF * 2.0);
1437        assert_eq!(thumb(1.0).width, THUMB_ON * 2.0);
1438        // Centred in the rounded cap at both ends, so it never breaks the pill.
1439        assert!((thumb(0.0).x - (H * 0.5 - THUMB_OFF)).abs() < 1e-4);
1440        assert!((thumb(1.0).x + thumb(1.0).width - (W - H * 0.5 + THUMB_ON)).abs() < 1e-4);
1441        // Half way, it is pulled into a pill wider than it is tall (`switch.dart:2382`).
1442        let mid = thumb(0.5);
1443        assert!(mid.width > mid.height, "a pill: {mid:?}");
1444        assert!(mid.width <= THUMB_MIDWAY.0 && mid.height <= THUMB_MIDWAY.1);
1445    }
1446
1447    /// **The flip is the reference's**: 300 ms (`switch.dart:2386`), the thumb overshooting
1448    /// its end and settling back (`easeOutBack`, `:800`), and through the midway pill.
1449    #[test]
1450    fn the_flip_overshoots_and_settles() {
1451        let theme = Theme::default();
1452        let on = Switch::<()>::new(true);
1453        assert_eq!(Widget::<()>::anim_duration(&on), 0.3);
1454        assert_eq!(
1455            Widget::<()>::anim_curve(&on),
1456            Curve::Linear,
1457            "curved in the paint"
1458        );
1459        let centre = |switch: &Switch<()>, t: f32| {
1460            let mut scene = Scene::new();
1461            Widget::<()>::paint(
1462                switch,
1463                Rect::new(0.0, 0.0, W, H),
1464                Status {
1465                    opacity: 1.0,
1466                    value: t,
1467                    ..Default::default()
1468                },
1469                &theme,
1470                &mut scene,
1471            );
1472            match scene.primitives()[1] {
1473                frus_core::Primitive::Rect { rect, .. } => rect.x + rect.width * 0.5,
1474                _ => panic!("the thumb is a rectangle"),
1475            }
1476        };
1477        let end = W - H * 0.5;
1478        assert!(centre(&on, 0.7) > end, "past the end on the way on");
1479        assert!((centre(&on, 1.0) - end).abs() < 1e-4, "and back on it");
1480        let off = Switch::<()>::new(false);
1481        assert!(centre(&off, 0.3) < H * 0.5, "past the start on the way off");
1482        // Thumb sizes along the way on: off, the pill at 11 %, on by 83 %.
1483        let size = |t: f32| on.thumb_size(t);
1484        let early = size(0.05);
1485        assert!(
1486            early.0 > early.1 && early.0 > 2.0 * THUMB_OFF,
1487            "already stretching towards the pill: {early:?}"
1488        );
1489        assert_eq!(size(0.11), THUMB_MIDWAY);
1490        assert_eq!(size(0.9), (2.0 * THUMB_ON, 2.0 * THUMB_ON));
1491        assert_eq!(off.thumb_size(0.89), THUMB_MIDWAY, "and back the other way");
1492        assert_eq!(off.thumb_size(0.1), (2.0 * THUMB_OFF, 2.0 * THUMB_OFF));
1493    }
1494
1495    /// **The box is the reference's**: the track and 4 pixels either side
1496    /// (`switch.dart:604`), as the caller or the theme may change.
1497    #[test]
1498    fn the_box_has_room_either_side() {
1499        let theme = Theme::default();
1500        let width =
1501            |switch: &Switch<()>, theme: &Theme| match Widget::<()>::style_themed(switch, theme)
1502                .width
1503            {
1504                Dimension::Length(w) => w,
1505                other => panic!("a length, not {other:?}"),
1506            };
1507        assert_eq!(width(&Switch::<()>::new(false), &theme), 60.0);
1508        assert_eq!(
1509            width(&Switch::<()>::new(false).padding(Insets::ZERO), &theme),
1510            W
1511        );
1512        let mut roomy = Theme::default();
1513        roomy.widgets.switch.padding = Some(Insets::new(0.0, 10.0, 0.0, 10.0));
1514        assert_eq!(width(&Switch::<()>::new(false), &roomy), 72.0);
1515    }
1516
1517    /// Each end of the travel takes its own colour.
1518    #[test]
1519    fn each_end_takes_its_own_colour() {
1520        let theme = Theme::default();
1521        let switch = Switch::<()>::new(true)
1522            .track_color(BRAND)
1523            .inactive_track_color(RAIL)
1524            .thumb_color(KNOB);
1525        let (track, thumb, _) = painted(&switch, true, &theme);
1526        lands_on(track, BRAND);
1527        lands_on(thumb, KNOB);
1528        lands_on(painted(&switch, false, &theme).0, RAIL);
1529    }
1530
1531    /// The mix happens between the two **resolved** ends, so an override moves the whole
1532    /// animation rather than being a colour the switch passes through.
1533    #[test]
1534    fn the_travel_runs_between_the_two_overrides() {
1535        let theme = Theme::default();
1536        let switch = Switch::<()>::new(true)
1537            .track_color(BRAND)
1538            .inactive_track_color(RAIL);
1539        let mut scene = Scene::new();
1540        Widget::<()>::paint(
1541            &switch,
1542            Rect::new(0.0, 0.0, W, H),
1543            Status {
1544                opacity: 1.0,
1545                value: 0.5,
1546                ..Default::default()
1547            },
1548            &theme,
1549            &mut scene,
1550        );
1551        let track = match scene.primitives()[0] {
1552            frus_core::Primitive::Rect { color, .. } => color,
1553            _ => panic!("the track is a rectangle"),
1554        };
1555        // Turned on, the colours ease out (`switch.dart:1155`).
1556        assert_eq!(
1557            track,
1558            RAIL.lerp(BRAND, Curve::ease_out().transform(0.5)),
1559            "between the two ends"
1560        );
1561    }
1562
1563    /// The two ends of the thumb are **two colours**, not one.
1564    ///
1565    /// They used to be one: an unsaid off thumb followed the on one, on the reasoning
1566    /// that a switch is a single thumb sliding rather than two swapping places. The
1567    /// reasoning holds and the conclusion did not — the reference resolves both ends and
1568    /// interpolates between them, so it is still one thumb, one that changes colour as it
1569    /// travels. Saying the on colour therefore no longer says the off one.
1570    #[test]
1571    fn the_two_ends_of_the_thumb_are_two_colours() {
1572        let theme = Theme::default();
1573        let switch = Switch::<()>::new(false).thumb_color(KNOB);
1574        lands_on(painted(&switch, false, &theme).1, theme.scheme.outline);
1575        lands_on(painted(&switch, true, &theme).1, KNOB);
1576        let both = Switch::<()>::new(false)
1577            .thumb_color(KNOB)
1578            .inactive_thumb_color(RAIL);
1579        lands_on(painted(&both, false, &theme).1, RAIL);
1580    }
1581
1582    /// A colour the track lands on at the end of its travel. It is a **lerp** to get
1583    /// there, so the arrival is within a rounding step of the colour asked for rather
1584    /// than bit-identical to it — which is why this compares by eye rather than by bits.
1585    fn lands_on(got: Color, want: Color) {
1586        let off = (got.r - want.r)
1587            .abs()
1588            .max((got.g - want.g).abs())
1589            .max((got.b - want.b).abs());
1590        assert!(off < 1e-4, "{got:?} is not {want:?}");
1591    }
1592
1593    /// The theme answers when the instance does not, and loses when it does.
1594    #[test]
1595    fn the_theme_answers_and_the_instance_overrules_it() {
1596        let mut theme = Theme::default();
1597        theme.widgets.switch.track_color = Some(RAIL);
1598        lands_on(painted(&Switch::<()>::new(true), true, &theme).0, RAIL);
1599        lands_on(
1600            painted(&Switch::<()>::new(true).track_color(BRAND), true, &theme).0,
1601            BRAND,
1602        );
1603    }
1604}