Skip to main content

frus_widgets/
switch.rs

1//! [`Switch`]: a **controlled** toggle switch, shaped as a pill.
2
3use frus_core::{Color, 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;
12
13/// The track, at the reference's size (`switch.dart:2378`, `:2375`).
14const W: f32 = 52.0;
15const H: f32 = 32.0;
16/// The thumb's radius **off** and **on** (`switch.dart:2354`, `:2317`). It grows as the
17/// switch is flipped: off it is a dot inside an outlined track, on it is a disc on a
18/// filled one, and that difference is most of what tells the two states apart at a
19/// glance.
20const THUMB_OFF: f32 = 8.0;
21const THUMB_ON: f32 = 12.0;
22/// The thumb while it is **held** (`switch.dart:2357`): larger than either end of the
23/// travel, which is the squish a finger expects back. It **grows** into it — the press is
24/// a progression since milestone 441, not a flag.
25const THUMB_PRESSED: f32 = 14.0;
26/// A thumb **carrying an icon** is the on-thumb's size at both ends (`switch.dart:2369`):
27/// 16 pixels of glyph do not fit in a 16-pixel dot, and a switch whose thumb changed size
28/// only when it had something to show would be two different switches.
29const THUMB_WITH_ICON: f32 = 12.0;
30/// The glyph inside the thumb (`switch.dart:2314`).
31const ICON_SIZE: f32 = 16.0;
32/// The rule round an **off** track (`switch.dart:2298`).
33const TRACK_OUTLINE: f32 = 2.0;
34
35/// An on/off switch.
36pub struct Switch<Msg = crate::callback::Callback> {
37    on: bool,
38    enabled: bool,
39    track_color: Option<Color>,
40    inactive_track_color: Option<Color>,
41    thumb_color: Option<Color>,
42    inactive_thumb_color: Option<Color>,
43    thumb_icon: Option<IconData>,
44    inactive_thumb_icon: Option<IconData>,
45    tap_target: Option<TapTarget>,
46    on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
47}
48
49impl<Msg> Switch<Msg> {
50    /// Creates a switch whose state is supplied.
51    pub fn new(on: bool) -> Self {
52        Self {
53            on,
54            enabled: true,
55            track_color: None,
56            inactive_track_color: None,
57            thumb_color: None,
58            inactive_thumb_color: None,
59            thumb_icon: None,
60            inactive_thumb_icon: None,
61            tap_target: None,
62            on_toggle: None,
63        }
64    }
65
66    /// **How much room it reserves for a finger** ([`TapTarget`]). Unset, the theme's
67    /// answer, which is [`Padded`](TapTarget::Padded) — at least 48 pixels either way,
68    /// whatever this control paints inside it.
69    pub fn tap_target(mut self, target: TapTarget) -> Self {
70        self.tap_target = Some(target);
71        self
72    }
73
74    /// The room this switch reserves, resolved as `caller ?? theme ?? framework`
75    /// (`switch.dart:603`).
76    fn reserved(&self, theme: &Theme) -> f32 {
77        self.tap_target
78            .or(theme.widgets.switch.tap_target)
79            .unwrap_or(theme.tap_target)
80            .min_side()
81    }
82
83    /// The track's colour when the switch is **on**; the theme's `primary` otherwise.
84    pub fn track_color(mut self, color: Color) -> Self {
85        self.track_color = Some(color);
86        self
87    }
88
89    /// The track's colour when the switch is **off**; the scheme's
90    /// `surface_container_highest` otherwise (`switch.dart:2246`), with a rule round it.
91    pub fn inactive_track_color(mut self, color: Color) -> Self {
92        self.inactive_track_color = Some(color);
93        self
94    }
95
96    /// **A glyph inside the thumb while the switch is on** (`switch.dart:2320`).
97    ///
98    /// Unset, as the reference's is: a switch is legible without one. It is there for the
99    /// setting that needs saying in more than colour and position — the two things a
100    /// reader may not be able to tell apart — and a tick inside the thumb says *on* in a
101    /// third way.
102    ///
103    /// Giving either end an icon makes **both** thumbs the on-thumb's size
104    /// (`switch.dart:2369`), because a switch whose thumb changed size only when it had
105    /// something to show would be two different switches.
106    pub fn thumb_icon(mut self, icon: IconData) -> Self {
107        self.thumb_icon = Some(icon);
108        self
109    }
110
111    /// The same while the switch is **off** — a cross beside the tick. See
112    /// [`Self::thumb_icon`].
113    pub fn inactive_thumb_icon(mut self, icon: IconData) -> Self {
114        self.inactive_thumb_icon = Some(icon);
115        self
116    }
117
118    /// The thumb's colour when the switch is **on**; the scheme's `on_primary`
119    /// otherwise (`switch.dart:2201`) — the content colour of the track it sits on.
120    pub fn thumb_color(mut self, color: Color) -> Self {
121        self.thumb_color = Some(color);
122        self
123    }
124
125    /// The thumb's colour when the switch is **off**; the scheme's `outline` otherwise
126    /// (`switch.dart:2212`).
127    ///
128    /// It used to follow the on colour, on the reasoning that a switch is one thumb
129    /// sliding rather than two swapping places. The reasoning was right and the
130    /// conclusion was not: the reference resolves both ends and **interpolates between
131    /// them**, so it is still one thumb — one that changes colour as it travels, the way
132    /// the track under it does.
133    pub fn inactive_thumb_color(mut self, color: Color) -> Self {
134        self.inactive_thumb_color = Some(color);
135        self
136    }
137
138    /// A closure producing a message from the new state.
139    pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
140        mut self,
141        on_toggle: impl Fn(bool) -> R + 'static,
142    ) -> Self {
143        self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
144        self
145    }
146
147    /// Whether the switch can be flipped. Disabled it is **inert** — no message, out of
148    /// the tab order, announced as unavailable — and it still shows which way it is set.
149    ///
150    /// See [`crate::disabled`] for the whole contract.
151    pub fn enabled(mut self, enabled: bool) -> Self {
152        self.enabled = enabled;
153        self
154    }
155}
156
157impl<Msg> Widget<Msg> for Switch<Msg> {
158    fn style(&self) -> Style {
159        Widget::<Msg>::style_themed(self, &Theme::default())
160    }
161
162    /// **The box is the tap target, not the track** (`switch.dart:605`). The track is 32
163    /// tall and a finger is not; the reference lays a switch out at 52 × 48 and paints
164    /// the track in the middle of it. Nothing about the switch moves — the room around it
165    /// appears, and with it the area a click may land in.
166    fn style_themed(&self, theme: &Theme) -> Style {
167        Style {
168            width: Dimension::Length(W),
169            height: Dimension::Length(H.max(self.reserved(theme))),
170            ..Default::default()
171        }
172    }
173
174    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
175        &[]
176    }
177
178    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
179        let o = status.opacity;
180        // `t` is the switch's animated position: 0 is off, 1 is on.
181        let t = status.value;
182        // A switch is the control that takes **both** halves of the disabled rule: the
183        // track is a container (12 %), the thumb is content drawn on it (38 %), and when
184        // the thumb sits on that flattened track it punches through opaquely instead.
185        // Each end of the travel is resolved on its own -- the caller's word, then the
186        // theme's, then the scheme's -- and `t` interpolates between the two ends rather
187        // than between two already-resolved colours. An override therefore moves the
188        // whole animation with it instead of being a colour the switch passes through.
189        let (track, thumb, edge) = if self.enabled {
190            let on_track = self
191                .track_color
192                .or(theme.widgets.switch.track_color)
193                .unwrap_or(theme.primary);
194            let off_track = self
195                .inactive_track_color
196                .or(theme.widgets.switch.inactive_track_color)
197                .unwrap_or(theme.scheme.surface_container_highest);
198            let on_thumb = self
199                .thumb_color
200                .or(theme.widgets.switch.thumb_color)
201                .unwrap_or(theme.scheme.on_primary);
202            let off_thumb = self
203                .inactive_thumb_color
204                .or(theme.widgets.switch.inactive_thumb_color)
205                .unwrap_or(theme.scheme.outline);
206            (
207                off_track.lerp(on_track, t),
208                off_thumb.lerp(on_thumb, t),
209                theme.scheme.outline,
210            )
211        } else {
212            (
213                if self.on {
214                    // A disabled **on** track is the flattened container
215                    // (`switch.dart:2221`).
216                    disabled_container(theme)
217                } else {
218                    // A disabled **off** one is not: the reference washes
219                    // `surfaceContainerHighest` over the page at the same 12 %
220                    // (`switch.dart:2223`), which is nearly the page itself — a faint
221                    // ring with almost nothing inside. `disabled_container` is the
222                    // *ring's* colour, and filling the pill with it would draw the
223                    // opposite. Resolved in sRGB for the reason
224                    // [`crate::disabled::over_surface`] gives.
225                    theme.scheme.surface.lerp(
226                        theme.scheme.surface_container_highest,
227                        DISABLED_CONTAINER_OPACITY,
228                    )
229                },
230                if self.on {
231                    disabled_mark(theme)
232                } else {
233                    disabled_content(theme)
234                },
235                disabled_container(theme),
236            )
237        };
238        // The rule belongs to the **off** end alone. A filled track needs no edge, and the
239        // reference returns a transparent one for a switch that is on whether it is
240        // available or not (`switch.dart:2254`) — so fading it out along the travel *is*
241        // that rule, written as the animation it already was.
242        // The box a switch is given is its **tap target**, which is taller than the
243        // track it paints. Everything below is measured from the track, centred in it.
244        let track_rect = Rect::new(
245            bounds.x,
246            bounds.y + (bounds.height - H) * 0.5,
247            bounds.width,
248            H,
249        );
250        scene.draw_rect(
251            track_rect,
252            track.fade(o),
253            H * 0.5,
254            TRACK_OUTLINE,
255            edge.fade((1.0 - t) * o),
256        );
257
258        // Both ends of the travel put the thumb's centre half a track-height in from their
259        // own edge, so it stays centred in the rounded cap whichever size it is.
260        let icon = if self.on {
261            self.thumb_icon
262        } else {
263            self.inactive_thumb_icon
264        };
265        // **A thumb that carries a glyph is the on-thumb's size at both ends**
266        // (`switch.dart:2369`): sixteen pixels of glyph do not fit in a sixteen-pixel dot.
267        // The rule is about the switch, not about this end of it — either icon sets both,
268        // or a switch would change size when it was flipped for a reason that has nothing
269        // to do with being flipped.
270        let carries_an_icon = self.thumb_icon.is_some() || self.inactive_thumb_icon.is_some();
271        let off_r = if carries_an_icon {
272            THUMB_WITH_ICON
273        } else {
274            THUMB_OFF
275        };
276        let mut r = off_r + (THUMB_ON - off_r) * t;
277        // Held, it swells past either end (`switch.dart:2357`) — the squish a finger
278        // expects back, **grown** into over the press's own 200 ms rather than jumped to.
279        // It is measured from wherever the thumb has got to, so a switch held mid-travel
280        // swells from where it is instead of snapping back to an end first.
281        if self.enabled {
282            r += (THUMB_PRESSED - r) * status.press_progress.clamp(0.0, 1.0);
283        }
284        let cx = track_rect.x + H * 0.5 + (W - H) * t;
285        let cy = track_rect.y + H * 0.5;
286
287        // **The state layer**, which this had none of. The reference paints the toggle's
288        // radial reaction over the track and under the thumb (`switch.dart:2264`); here it
289        // is the theme's one rule, resolved opaquely from the track it stands on — a
290        // translucent circle would blend in linear light and paint at something other than
291        // the number it names (milestones 329, 437).
292        //
293        // **The ink is the track's content colour, where the reference's is `primary`.**
294        // That is not a disagreement about the role: the reference's reaction circle is
295        // wider than its track and spills onto the page, so `primary` over the page is
296        // visible at either end. This one is bounded by the switch's own box, so its
297        // ground is the track — and `primary` lerped over a `primary` track is that track
298        // again. A state layer takes the content colour of what it stands on, which is
299        // what [`Theme::state_layer`] asks for and what makes it visible at both ends.
300        //
301        // Nothing at all on a disabled switch: a state layer is the promise of an
302        // interaction, and there is none.
303        if self.enabled {
304            let ink = theme.scheme.on_surface.lerp(theme.scheme.on_primary, t);
305            let layer = theme.state_layer(track, ink, &status);
306            if layer != track {
307                let reach = H * 0.5;
308                scene.draw_rect(
309                    Rect::new(cx - reach, cy - reach, reach * 2.0, reach * 2.0),
310                    layer.fade(o),
311                    reach,
312                    0.0,
313                    Color::TRANSPARENT,
314                );
315            }
316        }
317
318        scene.draw_rect(
319            Rect::new(cx - r, cy - r, r * 2.0, r * 2.0),
320            thumb.fade(o),
321            r,
322            0.0,
323            Color::TRANSPARENT,
324        );
325
326        // And the glyph inside it. Its colour is the track's own at the off end
327        // (`switch.dart:2349`), so it reads as a hole punched through the thumb rather
328        // than as a mark drawn on it.
329        if let Some(icon) = icon {
330            let t_widget = &theme.widgets.switch;
331            let ink = if !self.enabled {
332                over_surface(theme, crate::disabled::DISABLED_CONTENT_OPACITY)
333            } else if self.on {
334                t_widget
335                    .icon_color
336                    .unwrap_or(theme.scheme.on_primary_container)
337            } else {
338                t_widget
339                    .inactive_icon_color
340                    .unwrap_or(theme.scheme.surface_container_highest)
341            };
342            let path = icon.placed(
343                ICON_SIZE,
344                cx - ICON_SIZE * 0.5,
345                cy - ICON_SIZE * 0.5,
346                theme.direction,
347            );
348            scene.fill_path(&path, ink.fade(o));
349        }
350    }
351
352    fn on_click(&self) -> Option<Msg> {
353        if !self.enabled {
354            return None;
355        }
356        self.on_toggle.as_ref().map(|make| make(!self.on))
357    }
358
359    fn focusable(&self) -> bool {
360        self.enabled
361    }
362
363    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
364        // Still on or off, still announced — a switch that fell silent would read as a
365        // setting that had gone away rather than one that cannot be changed.
366        let semantics =
367            frus_core::SemanticsProperties::new(frus_core::Role::Switch).toggled(self.on);
368        Some(if self.enabled {
369            semantics.clickable()
370        } else {
371            semantics.disabled(true)
372        })
373    }
374
375    fn anim_target(&self) -> Option<f32> {
376        Some(if self.on { 1.0 } else { 0.0 })
377    }
378
379    /// The thumb glides smoothly, accelerating then braking, rather than at a constant
380    /// speed — a switch's standard implicit animation.
381    fn anim_curve(&self) -> frus_core::Curve {
382        frus_core::Curve::ease_in_out()
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::disabled::{disabled_container, disabled_content, disabled_mark};
390    use crate::icons::Icons;
391    use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
392    use crate::widget::Widget;
393
394    #[derive(Clone, Debug, PartialEq)]
395    enum Msg {
396        Set(bool),
397    }
398
399    /// Each rectangle the switch painted, as `(fill, ring)`.
400    fn painted(on: bool, enabled: bool, theme: &Theme) -> Vec<(Color, Color)> {
401        let mut scene = Scene::new();
402        Widget::<Msg>::paint(
403            &Switch::<Msg>::new(on).enabled(enabled),
404            Rect::new(0.0, 0.0, W, H),
405            Status {
406                opacity: 1.0,
407                value: if on { 1.0 } else { 0.0 },
408                ..Default::default()
409            },
410            theme,
411            &mut scene,
412        );
413        scene
414            .primitives()
415            .iter()
416            .filter_map(|p| match p {
417                frus_core::Primitive::Rect {
418                    color,
419                    border_color,
420                    ..
421                } => Some((*color, *border_color)),
422                _ => None,
423            })
424            .collect()
425    }
426
427    /// Every rectangle a switch paints, with its geometry, under a given interaction.
428    fn boxes(switch: &Switch<Msg>, status: Status, theme: &Theme) -> Vec<(Rect, Color)> {
429        let mut scene = Scene::new();
430        Widget::<Msg>::paint(switch, Rect::new(0.0, 0.0, W, H), status, theme, &mut scene);
431        scene
432            .primitives()
433            .iter()
434            .filter_map(|p| match p {
435                frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
436                _ => None,
437            })
438            .collect()
439    }
440
441    /// A switch being painted at one end of its travel, in a given interaction state.
442    fn state(on: bool) -> Status {
443        Status {
444            opacity: 1.0,
445            value: if on { 1.0 } else { 0.0 },
446            ..Default::default()
447        }
448    }
449
450    /// **A switch answers the pointer** (milestone 440).
451    ///
452    /// It had no state layer at all: hovering one, focusing it, holding it — nothing
453    /// changed. The reference paints the toggle's radial reaction over the track and under
454    /// the thumb (`switch.dart:2264`); here it is the theme's one rule, resolved opaquely
455    /// from the track it stands on, because a translucent circle would blend in linear
456    /// light and paint at something other than the number it names.
457    #[test]
458    fn a_switch_answers_the_pointer() {
459        let theme = Theme::default();
460        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
461        let hovered = Status {
462            hover_progress: 1.0,
463            ..state(false)
464        };
465        assert_eq!(
466            boxes(&switch, state(false), &theme).len(),
467            2,
468            "track, thumb"
469        );
470        let lit = boxes(&switch, hovered, &theme);
471        assert_eq!(lit.len(), 3, "track, layer, thumb");
472
473        let (rect, color) = lit[1];
474        assert_eq!(color.a, 1.0, "resolved here, not handed over as an alpha");
475        assert_eq!(
476            color,
477            theme.state_layer(
478                theme.scheme.surface_container_highest,
479                theme.scheme.on_surface,
480                &hovered
481            ),
482            "the theme's rule, over the track it stands on"
483        );
484        // Centred on the thumb, and inside the switch either way it is set.
485        assert!(
486            rect.x >= 0.0 && rect.x + rect.width <= W,
487            "the layer left the switch: {rect:?}"
488        );
489        assert!(
490            (rect.height - H).abs() < 0.01,
491            "and it reaches the track's full height"
492        );
493    }
494
495    /// Its ink is **the track's content colour**, which the travel interpolates between
496    /// like everything else here.
497    ///
498    /// The reference names `primary` at the on end (`switch.dart:2266`), which works there
499    /// because its reaction circle is wider than its track and spills onto the page. This
500    /// one is bounded by the switch's box, so `primary` over a `primary` track would be
501    /// that track again — the layer would vanish exactly where a pointer is most likely to
502    /// be.
503    #[test]
504    fn the_layer_is_the_accent_at_one_end_and_the_ink_at_the_other() {
505        let theme = Theme::default();
506        let switch = Switch::<Msg>::new(true).on_toggle(Msg::Set);
507        let hovered = |on: bool| Status {
508            hover_progress: 1.0,
509            ..state(on)
510        };
511        let off = boxes(&switch, hovered(false), &theme)[1].1;
512        let on = boxes(&switch, hovered(true), &theme)[1].1;
513        assert_eq!(
514            on,
515            theme.state_layer(theme.primary, theme.scheme.on_primary, &hovered(true)),
516            "the accent track, tinted with what is legible on it"
517        );
518        assert_ne!(off, on, "the two ends do not light the same way");
519        assert_ne!(on, theme.primary, "and it is visible against the track");
520    }
521
522    /// And a switch that cannot be worked does not light, in any state: a state layer is
523    /// the promise of an interaction.
524    #[test]
525    fn a_disabled_switch_does_not_light() {
526        let theme = Theme::default();
527        let dead = Switch::<Msg>::new(false).on_toggle(Msg::Set).enabled(false);
528        for status in [
529            Status {
530                hover_progress: 1.0,
531                ..state(false)
532            },
533            Status {
534                focus_progress: 1.0,
535                ..state(false)
536            },
537            Status {
538                press_progress: 1.0,
539                ..state(false)
540            },
541        ] {
542            assert_eq!(
543                boxes(&dead, status, &theme).len(),
544                2,
545                "track and thumb only"
546            );
547        }
548    }
549
550    /// The thumb's radius, at the end of the travel it was painted at.
551    fn thumb_radius(switch: &Switch<Msg>, status: Status, theme: &Theme) -> f32 {
552        boxes(switch, status, theme)
553            .last()
554            .map(|(rect, _)| rect.width * 0.5)
555            .expect("a switch paints a thumb")
556    }
557
558    /// **A thumb that carries a glyph is the larger one at both ends**
559    /// (`switch.dart:2369`): sixteen pixels of glyph do not fit in a sixteen-pixel dot.
560    ///
561    /// And the rule is about the *switch*, not about the end it is at — giving only the on
562    /// end an icon still grows the off thumb, or the switch would change size when flipped
563    /// for a reason that has nothing to do with being flipped.
564    #[test]
565    fn a_thumb_that_carries_a_glyph_is_the_larger_one() {
566        let theme = Theme::default();
567        let bare = Switch::<Msg>::new(false).on_toggle(Msg::Set);
568        assert_eq!(thumb_radius(&bare, state(false), &theme), THUMB_OFF);
569
570        let ticked = Switch::<Msg>::new(false)
571            .on_toggle(Msg::Set)
572            .thumb_icon(Icons::CHECK);
573        assert_eq!(
574            thumb_radius(&ticked, state(false), &theme),
575            THUMB_WITH_ICON,
576            "the on end's icon grows the off thumb too"
577        );
578        assert_eq!(
579            thumb_radius(&ticked, state(true), &theme),
580            THUMB_ON,
581            "and the on thumb was already that size"
582        );
583    }
584
585    /// The glyph itself: drawn only at the end that has one, in the colour that end names.
586    #[test]
587    fn the_glyph_is_drawn_at_the_end_that_has_one() {
588        let theme = Theme::default();
589        let glyph = |switch: &Switch<Msg>, on: bool| {
590            let mut scene = Scene::new();
591            Widget::<Msg>::paint(
592                switch,
593                Rect::new(0.0, 0.0, W, H),
594                state(on),
595                &theme,
596                &mut scene,
597            );
598            scene.primitives().iter().find_map(|p| match p {
599                frus_core::Primitive::Path { fill, .. } => *fill,
600                _ => None,
601            })
602        };
603        let ticked = Switch::<Msg>::new(true)
604            .on_toggle(Msg::Set)
605            .thumb_icon(Icons::CHECK);
606        assert_eq!(
607            glyph(&ticked, true),
608            Some(theme.scheme.on_primary_container)
609        );
610        // The end a switch is **set** to decides which glyph it carries, as the reference
611        // resolves `thumbIcon` from the state rather than from the animation: a switch
612        // that is off and names no off icon carries none.
613        let unticked = Switch::<Msg>::new(false)
614            .on_toggle(Msg::Set)
615            .thumb_icon(Icons::CHECK);
616        assert_eq!(glyph(&unticked, false), None, "nothing at the other end");
617
618        let crossed = Switch::<Msg>::new(false)
619            .on_toggle(Msg::Set)
620            .thumb_icon(Icons::CHECK)
621            .inactive_thumb_icon(Icons::CLOSE);
622        assert_eq!(
623            glyph(&crossed, false),
624            Some(theme.scheme.surface_container_highest),
625            "off, the glyph takes the track's own colour: a hole, not a mark"
626        );
627    }
628
629    /// **A held thumb swells** (`switch.dart:2357`), past either end of the travel.
630    #[test]
631    fn a_held_thumb_swells() {
632        let theme = Theme::default();
633        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
634        let pressed = Status {
635            press_progress: 1.0,
636            ..state(false)
637        };
638        assert_eq!(thumb_radius(&switch, pressed, &theme), THUMB_PRESSED);
639        // Past **both** ends of the travel, which is what makes it read as a press rather
640        // than as the switch having moved.
641        for end in [false, true] {
642            assert!(
643                thumb_radius(&switch, pressed, &theme) > thumb_radius(&switch, state(end), &theme),
644                "not past the {end} end"
645            );
646        }
647    }
648
649    /// And it **grows** into it (milestone 441): half way through the press the thumb is
650    /// half way there, where it used to arrive whole on the first frame the finger was
651    /// down and leave whole on the first frame it was not.
652    #[test]
653    fn a_held_thumb_grows_into_it() {
654        let theme = Theme::default();
655        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
656        let at = |p: f32| {
657            thumb_radius(
658                &switch,
659                Status {
660                    press_progress: p,
661                    ..state(false)
662                },
663                &theme,
664            )
665        };
666        assert_eq!(at(0.0), THUMB_OFF, "untouched");
667        assert_eq!(at(1.0), THUMB_PRESSED, "held");
668        assert!(
669            (at(0.5) - (THUMB_OFF + THUMB_PRESSED) * 0.5).abs() < 0.01,
670            "half way = {}",
671            at(0.5)
672        );
673    }
674
675    /// The height a switch asks the layout for, under a given theme.
676    fn reserved_height(switch: &Switch<Msg>, theme: &Theme) -> f32 {
677        match Widget::<Msg>::style_themed(switch, theme).height {
678            Dimension::Length(h) => h,
679            other => panic!("a switch asks for a length, not {other:?}"),
680        }
681    }
682
683    /// **A switch's box is its tap target, not its track** (milestone 442).
684    ///
685    /// The track is 32 pixels tall and a finger is not. The reference lays a switch out
686    /// at 52 × 48 and paints the track in the middle (`switch.dart:605`); this asked the
687    /// layout for the track and nothing else, so the area a click could land in was the
688    /// track exactly.
689    #[test]
690    fn a_switch_reserves_room_for_a_finger() {
691        let theme = Theme::default();
692        assert_eq!(
693            reserved_height(&Switch::<Msg>::new(false).on_toggle(Msg::Set), &theme),
694            MIN_TAP_TARGET
695        );
696        assert_eq!(
697            reserved_height(
698                &Switch::<Msg>::new(false)
699                    .on_toggle(Msg::Set)
700                    .tap_target(TapTarget::ShrinkWrap),
701                &theme
702            ),
703            SHRUNK_TAP_TARGET,
704            "and a caller may ask for the smaller answer"
705        );
706
707        // The theme answers for a switch that has not said, and the widget theme sits
708        // between the two.
709        let dense = Theme {
710            tap_target: TapTarget::ShrinkWrap,
711            ..Theme::default()
712        };
713        let plain = Switch::<Msg>::new(false).on_toggle(Msg::Set);
714        assert_eq!(reserved_height(&plain, &dense), SHRUNK_TAP_TARGET);
715        let mut mixed = dense;
716        mixed.widgets.switch.tap_target = Some(TapTarget::Padded);
717        assert_eq!(reserved_height(&plain, &mixed), MIN_TAP_TARGET);
718    }
719
720    /// And **nothing it paints moves**: the track is centred in the room, and the thumb
721    /// stays centred on the track.
722    #[test]
723    fn the_track_is_centred_in_the_room() {
724        let theme = Theme::default();
725        let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
726        let mut scene = Scene::new();
727        Widget::<Msg>::paint(
728            &switch,
729            Rect::new(0.0, 0.0, W, MIN_TAP_TARGET),
730            state(false),
731            &theme,
732            &mut scene,
733        );
734        let rects: Vec<Rect> = scene
735            .primitives()
736            .iter()
737            .filter_map(|p| match p {
738                frus_core::Primitive::Rect { rect, .. } => Some(*rect),
739                _ => None,
740            })
741            .collect();
742
743        let track = rects[0];
744        assert_eq!(track.height, H, "the track keeps its own height");
745        assert!(
746            (track.y - (MIN_TAP_TARGET - H) * 0.5).abs() < 0.01,
747            "and sits in the middle of the room: {track:?}"
748        );
749        let thumb = *rects.last().expect("a switch paints a thumb");
750        assert!(
751            (thumb.y + thumb.height * 0.5 - MIN_TAP_TARGET * 0.5).abs() < 0.01,
752            "the thumb is centred on the track: {thumb:?}"
753        );
754    }
755
756    #[test]
757    fn click_toggles() {
758        assert_eq!(
759            Widget::on_click(&Switch::new(false).on_toggle(Msg::Set)),
760            Some(Msg::Set(true))
761        );
762    }
763
764    #[test]
765    fn a_disabled_switch_is_inert_but_still_says_which_way_it_is_set() {
766        let dead = Switch::new(true).on_toggle(Msg::Set).enabled(false);
767        assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
768        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
769        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
770        assert!(semantics.disabled, "and announced as unavailable");
771        assert_eq!(semantics.toggled, frus_core::Toggled::True, "still on");
772    }
773
774    /// The switch is the control that takes **both** halves of the rule at once, which is
775    /// the argument that the split is container-against-content rather than one rule per
776    /// widget. If these two ever collapse to the same opacity, that argument is gone.
777    #[test]
778    fn a_disabled_switch_takes_both_halves_of_the_rule() {
779        for theme in [Theme::dark(), Theme::light()] {
780            let off = painted(false, false, &theme);
781            let ((track, ring), thumb) = (off[0], off[1].0);
782            // Since milestone 428 the **off** track is the reference's
783            // `surfaceContainerHighest` wash, which is near enough the page to be nothing;
784            // the container half of the rule is the ring round it. The switch still shows
785            // both halves at once, which is the whole argument — the container is the
786            // pill's edge rather than its fill while it is off.
787            assert_eq!(ring, disabled_container(&theme), "the ring is a container");
788            assert_eq!(
789                thumb,
790                disabled_content(&theme),
791                "the thumb is content on it"
792            );
793            // Quieter means closer to the surface: since milestone 329 both tokens are
794            // opaque, which is the flattening this rule asks for.
795            let from_surface = |c: Color| {
796                (c.r - theme.scheme.surface.r).abs()
797                    + (c.g - theme.scheme.surface.g).abs()
798                    + (c.b - theme.scheme.surface.b).abs()
799            };
800            assert!(
801                from_surface(ring) < from_surface(thumb),
802                "and the container is the quieter of the two"
803            );
804            assert!(
805                from_surface(track) < from_surface(ring),
806                "with the wash inside it quieter still"
807            );
808
809            // Flipped on, the track *is* the flattened container and the thumb sits on
810            // it, so it punches through opaquely rather than adding a third translucent
811            // layer — and the ring goes, a filled track having no edge.
812            let on = painted(true, false, &theme);
813            assert_eq!(on[0].0, disabled_container(&theme), "a filled track");
814            assert_eq!(on[1].0, disabled_mark(&theme), "an opaque thumb");
815            assert_eq!(on[0].1.a, 0.0, "and no ring round it");
816        }
817    }
818
819    #[test]
820    fn a_live_switch_is_untouched_by_any_of_it() {
821        let theme = Theme::dark();
822        let live = painted(true, true, &theme);
823        assert_ne!(live[0].0, disabled_container(&theme));
824        assert_ne!(live[1].0, disabled_content(&theme));
825    }
826}
827
828#[cfg(test)]
829mod color_tests {
830    use super::*;
831    use crate::widget::Widget;
832
833    const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
834    const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
835    const KNOB: Color = Color::rgb(0.1, 0.1, 0.9);
836
837    /// The (track, thumb, ring) a switch painted.
838    fn painted(switch: &Switch<()>, on: bool, theme: &Theme) -> (Color, Color, Color) {
839        at(switch, if on { 1.0 } else { 0.0 }, theme)
840    }
841
842    /// The same, anywhere along the travel.
843    fn at(switch: &Switch<()>, t: f32, theme: &Theme) -> (Color, Color, Color) {
844        let mut scene = Scene::new();
845        Widget::<()>::paint(
846            switch,
847            Rect::new(0.0, 0.0, W, H),
848            Status {
849                opacity: 1.0,
850                value: t,
851                ..Default::default()
852            },
853            theme,
854            &mut scene,
855        );
856        let rects: Vec<(Color, Color)> = scene
857            .primitives()
858            .iter()
859            .filter_map(|p| match p {
860                frus_core::Primitive::Rect {
861                    color,
862                    border_color,
863                    ..
864                } => Some((*color, *border_color)),
865                _ => None,
866            })
867            .collect();
868        (rects[0].0, rects[1].0, rects[0].1)
869    }
870
871    /// Nothing said: the four colours the reference names, and the ring that only the
872    /// off end has.
873    #[test]
874    fn the_defaults_are_the_reference_s() {
875        let theme = Theme::default();
876        // Both ends are the arrival of a lerp, so they land on the colour rather than
877        // matching it bit for bit — see `lands_on`.
878        let (track, thumb, ring) = painted(&Switch::<()>::new(true), true, &theme);
879        lands_on(track, theme.primary); // `switch.dart:2235`
880        lands_on(thumb, theme.scheme.on_primary); // `switch.dart:2201`
881        assert_eq!(ring.a, 0.0, "a filled track has no edge (`:2254`)");
882
883        let (track, thumb, ring) = painted(&Switch::<()>::new(false), false, &theme);
884        lands_on(track, theme.scheme.surface_container_highest); // `switch.dart:2246`
885        lands_on(thumb, theme.scheme.outline); // `switch.dart:2212`
886        lands_on(ring, theme.scheme.outline); // `switch.dart:2259`
887        assert_eq!(ring.a, 1.0, "and it is drawn");
888    }
889
890    /// The **ring fades out along the travel**, which is the animated form of the
891    /// reference's either-or: an edge round an empty track, none round a full one.
892    #[test]
893    fn the_ring_belongs_to_the_off_end_alone() {
894        let theme = Theme::default();
895        let switch = Switch::<()>::new(false);
896        let alpha = |t: f32| at(&switch, t, &theme).2.a;
897        assert_eq!(alpha(0.0), 1.0, "fully drawn off");
898        assert_eq!(alpha(1.0), 0.0, "gone on");
899        assert!(
900            (alpha(0.5) - 0.5).abs() < 1e-6,
901            "and half drawn halfway, rather than snapping at one end"
902        );
903    }
904
905    /// The **thumb grows** as the switch is flipped — a dot inside an outlined track
906    /// becoming a disc on a filled one (`switch.dart:2354`, `:2317`). It is most of what
907    /// tells the two states apart at a glance, and it is the half a colour change alone
908    /// would have missed.
909    #[test]
910    fn the_thumb_grows_as_it_travels() {
911        let theme = Theme::default();
912        let switch = Switch::<()>::new(false);
913        let thumb = |t: f32| {
914            let mut scene = Scene::new();
915            Widget::<()>::paint(
916                &switch,
917                Rect::new(0.0, 0.0, W, H),
918                Status {
919                    opacity: 1.0,
920                    value: t,
921                    ..Default::default()
922                },
923                &theme,
924                &mut scene,
925            );
926            match scene.primitives()[1] {
927                frus_core::Primitive::Rect { rect, .. } => rect,
928                _ => panic!("the thumb is a rectangle"),
929            }
930        };
931        assert_eq!(thumb(0.0).width, THUMB_OFF * 2.0);
932        assert_eq!(thumb(1.0).width, THUMB_ON * 2.0);
933        // Centred in the rounded cap at both ends, so it never breaks the pill.
934        assert!((thumb(0.0).x - (H * 0.5 - THUMB_OFF)).abs() < 1e-4);
935        assert!((thumb(1.0).x + thumb(1.0).width - (W - H * 0.5 + THUMB_ON)).abs() < 1e-4);
936    }
937
938    /// Each end of the travel takes its own colour.
939    #[test]
940    fn each_end_takes_its_own_colour() {
941        let theme = Theme::default();
942        let switch = Switch::<()>::new(true)
943            .track_color(BRAND)
944            .inactive_track_color(RAIL)
945            .thumb_color(KNOB);
946        let (track, thumb, _) = painted(&switch, true, &theme);
947        lands_on(track, BRAND);
948        lands_on(thumb, KNOB);
949        lands_on(painted(&switch, false, &theme).0, RAIL);
950    }
951
952    /// The mix happens between the two **resolved** ends, so an override moves the whole
953    /// animation rather than being a colour the switch passes through.
954    #[test]
955    fn the_travel_runs_between_the_two_overrides() {
956        let theme = Theme::default();
957        let switch = Switch::<()>::new(true)
958            .track_color(BRAND)
959            .inactive_track_color(RAIL);
960        let mut scene = Scene::new();
961        Widget::<()>::paint(
962            &switch,
963            Rect::new(0.0, 0.0, W, H),
964            Status {
965                opacity: 1.0,
966                value: 0.5,
967                ..Default::default()
968            },
969            &theme,
970            &mut scene,
971        );
972        let track = match scene.primitives()[0] {
973            frus_core::Primitive::Rect { color, .. } => color,
974            _ => panic!("the track is a rectangle"),
975        };
976        assert_eq!(track, RAIL.lerp(BRAND, 0.5), "halfway between the two ends");
977    }
978
979    /// The two ends of the thumb are **two colours**, not one.
980    ///
981    /// They used to be one: an unsaid off thumb followed the on one, on the reasoning
982    /// that a switch is a single thumb sliding rather than two swapping places. The
983    /// reasoning holds and the conclusion did not — the reference resolves both ends and
984    /// interpolates between them, so it is still one thumb, one that changes colour as it
985    /// travels. Saying the on colour therefore no longer says the off one.
986    #[test]
987    fn the_two_ends_of_the_thumb_are_two_colours() {
988        let theme = Theme::default();
989        let switch = Switch::<()>::new(false).thumb_color(KNOB);
990        lands_on(painted(&switch, false, &theme).1, theme.scheme.outline);
991        lands_on(painted(&switch, true, &theme).1, KNOB);
992        let both = Switch::<()>::new(false)
993            .thumb_color(KNOB)
994            .inactive_thumb_color(RAIL);
995        lands_on(painted(&both, false, &theme).1, RAIL);
996    }
997
998    /// A colour the track lands on at the end of its travel. It is a **lerp** to get
999    /// there, so the arrival is within a rounding step of the colour asked for rather
1000    /// than bit-identical to it — which is why this compares by eye rather than by bits.
1001    fn lands_on(got: Color, want: Color) {
1002        let off = (got.r - want.r)
1003            .abs()
1004            .max((got.g - want.g).abs())
1005            .max((got.b - want.b).abs());
1006        assert!(off < 1e-4, "{got:?} is not {want:?}");
1007    }
1008
1009    /// The theme answers when the instance does not, and loses when it does.
1010    #[test]
1011    fn the_theme_answers_and_the_instance_overrules_it() {
1012        let mut theme = Theme::default();
1013        theme.widgets.switch.track_color = Some(RAIL);
1014        lands_on(painted(&Switch::<()>::new(true), true, &theme).0, RAIL);
1015        lands_on(
1016            painted(&Switch::<()>::new(true).track_color(BRAND), true, &theme).0,
1017            BRAND,
1018        );
1019    }
1020}