Skip to main content

gpui_kit_material/components/
switch.rs

1//! M3 switch composed over GPUI Base's switch behavior.
2//! Reference playground: <https://md3.io/components/switch/>.
3use super::InteractionPreview;
4use super::button::ButtonFocusRing;
5use crate::{
6    color::ColorRole,
7    icons::IconName,
8    motion::{self, MotionToken},
9    theme::color,
10    tokens::{state, typography::rem},
11};
12use gpui::{prelude::*, *};
13use std::rc::Rc;
14
15/// Controlled M3 switch: a 52×32 track whose handle moves and resizes on the
16/// fast spatial spring (16dp unselected, 24dp selected or with an icon, 28dp
17/// while pressed), with a 40dp state layer, the per-state handle colors and
18/// optional check/close icons inside the handle.
19#[derive(gpui::IntoElement)]
20pub struct MaterialSwitch {
21    id: ElementId,
22    label: SharedString,
23    checked: bool,
24    disabled: bool,
25    error: bool,
26    icons: SwitchIcons,
27    preview: Option<InteractionPreview>,
28    on_change: Option<SwitchChange>,
29}
30type SwitchChange = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
31
32/// Which states show an icon inside the handle.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum SwitchIcons {
35    /// No icons: a 16dp unselected and 24dp selected handle.
36    #[default]
37    None,
38    /// A check on the selected handle only.
39    Selected,
40    /// A check when selected and a close mark when not; both handles are
41    /// 24dp.
42    Both,
43}
44
45impl SwitchIcons {
46    fn shows(self, checked: bool) -> bool {
47        match self {
48            Self::None => false,
49            Self::Selected => checked,
50            Self::Both => true,
51        }
52    }
53}
54
55impl MaterialSwitch {
56    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
57        Self {
58            id: id.into(),
59            label: label.into(),
60            checked: false,
61            disabled: false,
62            error: false,
63            icons: SwitchIcons::None,
64            preview: None,
65            on_change: None,
66        }
67    }
68    /// Render a static specimen in one forced interaction state, painting
69    /// what the live state paints: the hover, focus or press state layer and
70    /// handle color, the pressed handle and the focus ring. The specimen
71    /// leaves the tab order and ignores live hover and press. See
72    /// [`InteractionPreview`].
73    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
74        self.preview = Some(state);
75        self
76    }
77    pub fn checked(mut self, checked: bool) -> Self {
78        self.checked = checked;
79        self
80    }
81    pub fn disabled(mut self, disabled: bool) -> Self {
82        self.disabled = disabled;
83        self
84    }
85    /// Paint the error colors (md3.io's invalid switch, e.g. a required
86    /// setting left off): error replaces primary on a selected switch and
87    /// the outline on an unselected one. A disabled switch ignores it.
88    pub fn error(mut self, error: bool) -> Self {
89        self.error = error;
90        self
91    }
92    /// Show icons inside the handle.
93    pub fn icons(mut self, icons: SwitchIcons) -> Self {
94        self.icons = icons;
95        self
96    }
97    pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
98        self.on_change = Some(Rc::new(f));
99        self
100    }
101}
102/// The switch's track, in dp: 52×32 with a 2dp outline. The handle must
103/// stay inside the outline, so its travel is bounded by the track's inner
104/// edges.
105const TRACK_WIDTH: f32 = 52.;
106const TRACK_HEIGHT: f32 = 32.;
107const TRACK_OUTLINE: f32 = 2.;
108/// The handle: 16dp unselected, 24dp selected or holding an icon, 28dp while
109/// pressed, inside a 40dp state layer. Icons are 16dp.
110const HANDLE_UNSELECTED: f32 = 16.;
111const HANDLE_SELECTED: f32 = 24.;
112const HANDLE_PRESSED: f32 = 28.;
113const STATE_LAYER: f32 = 40.;
114const ICON: f32 = 16.;
115/// The handle's center, from the track's outer leading edge, at rest
116/// unselected and selected.
117const CENTER_UNSELECTED: f32 = 16.;
118const CENTER_SELECTED: f32 = 36.;
119
120/// The handle's resting size.
121fn handle_size(checked: bool, icon: bool, pressed: bool) -> f32 {
122    if pressed {
123        HANDLE_PRESSED
124    } else if checked || icon {
125        HANDLE_SELECTED
126    } else {
127        HANDLE_UNSELECTED
128    }
129}
130
131/// Handle leading offset (from the track's outer edge) for a selection
132/// amount `t` and a presented handle `size`. Both follow the fast spatial
133/// spring, whose overshoot is kept, bounded only so the handle never crosses
134/// the outline.
135fn handle_left(t: f32, size: f32) -> f32 {
136    let t = if t.is_finite() { t } else { 0. };
137    let size = size.clamp(0., HANDLE_PRESSED);
138    let center = CENTER_UNSELECTED + (CENTER_SELECTED - CENTER_UNSELECTED) * t;
139    (center - size / 2.)
140        .max(TRACK_OUTLINE)
141        .min(TRACK_WIDTH - TRACK_OUTLINE - size)
142}
143
144/// The paint of a switch in one state.
145#[derive(Clone, Copy, Debug, PartialEq)]
146struct SwitchPaint {
147    track: Rgba,
148    outline: Rgba,
149    handle: Rgba,
150    /// The handle while hovered, focused or pressed.
151    handle_active: Rgba,
152    icon: Rgba,
153    /// The state layer's content color.
154    layer: Rgba,
155}
156
157/// Disabled colors follow the Figma kit's tokens literally, through
158/// [`state`]: a disabled container (track, outline) is its role at
159/// [`state::DISABLED_CONTAINER`] and disabled content (handle, icon) is its
160/// role at [`state::DISABLED_CONTENT`], composited over whatever is beneath
161/// rather than pre-mixed into a surface. The one exception is the token's
162/// opaque surface handle of a selected disabled switch.
163///
164/// An error switch swaps its accent for the error roles, as the text field
165/// does: error / on-error / error-container / on-error-container where the
166/// selected switch uses the primary family, and an error outline and
167/// handle (on-error-container while active) on an unselected one.
168fn switch_paint(checked: bool, disabled: bool, error: bool) -> SwitchPaint {
169    let role = color;
170    let container = |r| role(r).opacity(state::DISABLED_CONTAINER);
171    if error && !disabled {
172        return if checked {
173            SwitchPaint {
174                track: role(ColorRole::ERROR),
175                outline: role(ColorRole::ERROR),
176                handle: role(ColorRole::ON_ERROR),
177                handle_active: role(ColorRole::ERROR_CONTAINER),
178                icon: role(ColorRole::ON_ERROR_CONTAINER),
179                layer: role(ColorRole::ERROR),
180            }
181        } else {
182            SwitchPaint {
183                track: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
184                outline: role(ColorRole::ERROR),
185                handle: role(ColorRole::ERROR),
186                handle_active: role(ColorRole::ON_ERROR_CONTAINER),
187                icon: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
188                layer: role(ColorRole::ERROR),
189            }
190        };
191    }
192    match (checked, disabled) {
193        (true, false) => SwitchPaint {
194            track: role(ColorRole::PRIMARY),
195            outline: role(ColorRole::PRIMARY),
196            handle: role(ColorRole::ON_PRIMARY),
197            handle_active: role(ColorRole::PRIMARY_CONTAINER),
198            icon: role(ColorRole::ON_PRIMARY_CONTAINER),
199            layer: role(ColorRole::PRIMARY),
200        },
201        (false, false) => SwitchPaint {
202            track: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
203            outline: role(ColorRole::OUTLINE),
204            handle: role(ColorRole::OUTLINE),
205            handle_active: role(ColorRole::ON_SURFACE_VARIANT),
206            icon: role(ColorRole::SURFACE_CONTAINER_HIGHEST),
207            layer: role(ColorRole::ON_SURFACE),
208        },
209        (true, true) => SwitchPaint {
210            track: container(ColorRole::ON_SURFACE),
211            outline: Rgba {
212                a: 0.,
213                ..role(ColorRole::ON_SURFACE)
214            },
215            handle: role(ColorRole::SURFACE),
216            handle_active: role(ColorRole::SURFACE),
217            icon: state::disabled(role(ColorRole::ON_SURFACE)),
218            layer: role(ColorRole::ON_SURFACE),
219        },
220        (false, true) => {
221            let handle = state::disabled(role(ColorRole::ON_SURFACE));
222            SwitchPaint {
223                track: container(ColorRole::SURFACE_CONTAINER_HIGHEST),
224                outline: container(ColorRole::ON_SURFACE),
225                handle,
226                handle_active: handle,
227                icon: state::disabled(role(ColorRole::SURFACE_CONTAINER_HIGHEST)),
228                layer: role(ColorRole::ON_SURFACE),
229            }
230        }
231    }
232}
233
234/// Hover styles resolve against this group.
235const SWITCH_GROUP: &str = "material-switch";
236
237impl RenderOnce for MaterialSwitch {
238    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
239        let focus = window
240            .use_keyed_state((self.id.clone(), "focus"), cx, |_, cx| cx.focus_handle())
241            .read(cx)
242            .clone();
243        let press_state = window.use_keyed_state((self.id.clone(), "pressed"), cx, |_, _| false);
244        let preview = self.preview;
245        let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
246        let enabled = !disabled;
247        let live = enabled && preview.is_none();
248        let forced = |state| enabled && preview == Some(state);
249        let keyboard_focus = forced(InteractionPreview::Focused)
250            || (live && focus.is_focused(window) && window.last_input_was_keyboard());
251        // The release handlers are only installed while live, so a press
252        // that outlives a switch to disabled or a preview ends here rather
253        // than sticking once it is live again.
254        if !live && *press_state.read(cx) {
255            press_state.update(cx, |pressed, _| *pressed = false);
256        }
257        let pressed = forced(InteractionPreview::Pressed) || (live && *press_state.read(cx));
258        let icon = self.icons.shows(self.checked);
259        // Position and size follow separate fast spatial springs, so a press
260        // during a toggle retargets only the size, from where it is.
261        let t = motion::selection_value(
262            (self.id.clone(), "thumb"),
263            if self.checked { 1. } else { 0. },
264            window,
265            cx,
266        );
267        let target = handle_size(self.checked, icon, pressed);
268        let size = if preview.is_some() {
269            target
270        } else {
271            motion::token_value(
272                (self.id.clone(), "handle-size"),
273                target,
274                MotionToken::FastSpatial,
275                window,
276                cx,
277            )
278        }
279        .clamp(0., HANDLE_PRESSED);
280        let left = handle_left(t, size);
281        let paint = switch_paint(self.checked, disabled, self.error);
282        let clear = Rgba {
283            a: 0.,
284            ..paint.layer
285        };
286        let (hover, press, focused) = (
287            state::hovered(clear, paint.layer),
288            state::pressed(clear, paint.layer),
289            state::layer(clear, paint.layer, state::FOCUS),
290        );
291        let active_handle = keyboard_focus || pressed || forced(InteractionPreview::Hovered);
292        // Positions inside the track's padding box, which starts inside the
293        // outline.
294        let center = left + size / 2.;
295        let layer_origin = (
296            center - STATE_LAYER / 2. - TRACK_OUTLINE,
297            TRACK_HEIGHT / 2. - STATE_LAYER / 2. - TRACK_OUTLINE,
298        );
299        let glyph = if self.checked {
300            IconName::Check
301        } else {
302            IconName::Close
303        };
304        let action = self.on_change;
305        let release = press_state.clone();
306        let release_out = press_state.clone();
307        let switch = gpui_base::Switch::new(self.id)
308            .track_focus(&focus)
309            .accessibility_label(self.label)
310            .checked(self.checked)
311            .disabled(disabled)
312            .tab_stop(preview.is_none())
313            .on_change(move |value, _, window, cx| {
314                if let Some(f) = &action {
315                    f(&value, window, cx);
316                }
317            })
318            .when(live, |switch| {
319                switch
320                    .on_mouse_down(MouseButton::Left, move |_, window, cx| {
321                        press_state.update(cx, |pressed, _| *pressed = true);
322                        window.refresh();
323                    })
324                    .on_mouse_up(MouseButton::Left, move |_, window, cx| {
325                        release.update(cx, |pressed, _| *pressed = false);
326                        window.refresh();
327                    })
328                    .on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
329                        release_out.update(cx, |pressed, _| *pressed = false);
330                        window.refresh();
331                    })
332            })
333            .group(SWITCH_GROUP)
334            .relative()
335            .w(rem(TRACK_WIDTH))
336            .h(rem(TRACK_HEIGHT))
337            .rounded_full()
338            .border(rem(TRACK_OUTLINE))
339            .border_color(paint.outline)
340            .bg(paint.track)
341            .child(
342                div()
343                    .id("state-layer")
344                    .absolute()
345                    .left(rem(layer_origin.0))
346                    .top(rem(layer_origin.1))
347                    .size(rem(STATE_LAYER))
348                    .flex()
349                    .items_center()
350                    .justify_center()
351                    .rounded_full()
352                    .when(keyboard_focus, |layer| layer.bg(focused))
353                    .when(forced(InteractionPreview::Hovered), |layer| layer.bg(hover))
354                    .when(pressed, |layer| layer.bg(press))
355                    .when(live && !pressed && !keyboard_focus, |layer| {
356                        layer.group_hover(SWITCH_GROUP, move |style| style.bg(hover))
357                    })
358                    .child(
359                        div()
360                            .id("handle")
361                            .when(cfg!(test), |handle| {
362                                handle.debug_selector(|| "switch-handle".into())
363                            })
364                            .flex()
365                            .flex_shrink_0()
366                            .items_center()
367                            .justify_center()
368                            .size(rem(size))
369                            .rounded_full()
370                            .bg(if active_handle {
371                                paint.handle_active
372                            } else {
373                                paint.handle
374                            })
375                            .when(live, |handle| {
376                                let active = paint.handle_active;
377                                // A cursor gives the handle the hitbox its
378                                // group hover needs.
379                                handle
380                                    .cursor_pointer()
381                                    .group_hover(SWITCH_GROUP, move |style| style.bg(active))
382                            })
383                            .when(icon, |handle| {
384                                handle.child(glyph.icon().size(rem(ICON)).color(paint.icon))
385                            }),
386                    ),
387            );
388        ButtonFocusRing::new(switch, focus, enabled, rem(TRACK_HEIGHT / 2.))
389            .forced(forced(InteractionPreview::Focused))
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use core::prelude::v1::test;
397
398    #[test]
399    fn the_handle_stays_inside_the_outline_through_overshoot() {
400        for t in [-0.3, -0.05, 0., 0.25, 0.5, 0.75, 1., 1.05, 1.3] {
401            for size in [14., 16., 24., 28., 29.] {
402                let left = handle_left(t, size);
403                let size = f32::min(size, HANDLE_PRESSED);
404                assert!(left >= TRACK_OUTLINE, "{t}: inside the leading outline");
405                assert!(
406                    left + size <= TRACK_WIDTH - TRACK_OUTLINE,
407                    "{t}: inside the trailing outline"
408                );
409            }
410        }
411        assert_eq!(handle_left(0., 16.), 8.);
412        assert_eq!(handle_left(1., 24.), 24.);
413        assert_eq!(handle_left(0., 24.), 4., "an unselected icon handle");
414    }
415
416    #[test]
417    fn spring_overshoot_moves_the_handle_past_rest() {
418        assert!(handle_left(1.05, 24.) > handle_left(1., 24.) || handle_left(1., 24.) == 24.);
419        assert!(handle_left(-0.05, 16.) < handle_left(0., 16.));
420    }
421
422    #[test]
423    fn handle_sizes_follow_the_m3_spec() {
424        assert_eq!(handle_size(false, false, false), 16.);
425        assert_eq!(handle_size(false, true, false), 24.);
426        assert_eq!(handle_size(true, false, false), 24.);
427        assert_eq!(handle_size(true, true, true), 28.);
428        assert_eq!(handle_size(false, false, true), 28.);
429        assert!(!SwitchIcons::Selected.shows(false));
430        assert!(SwitchIcons::Selected.shows(true));
431        assert!(SwitchIcons::Both.shows(false));
432    }
433
434    #[test]
435    fn switch_uses_m3_roles_in_every_state() {
436        let c = color;
437        let on = switch_paint(true, false, false);
438        assert_eq!(on.track, c(ColorRole::PRIMARY));
439        assert_eq!(on.handle, c(ColorRole::ON_PRIMARY));
440        assert_eq!(on.handle_active, c(ColorRole::PRIMARY_CONTAINER));
441        assert_eq!(on.icon, c(ColorRole::ON_PRIMARY_CONTAINER));
442        let off = switch_paint(false, false, false);
443        assert_eq!(off.track, c(ColorRole::SURFACE_CONTAINER_HIGHEST));
444        assert_eq!(off.outline, c(ColorRole::OUTLINE));
445        assert_eq!(off.handle, c(ColorRole::OUTLINE));
446        assert_eq!(off.handle_active, c(ColorRole::ON_SURFACE_VARIANT));
447        assert_eq!(off.icon, c(ColorRole::SURFACE_CONTAINER_HIGHEST));
448        let on_surface = c(ColorRole::ON_SURFACE);
449        let disabled_on = switch_paint(true, true, false);
450        assert_eq!(
451            disabled_on.track,
452            on_surface.opacity(state::DISABLED_CONTAINER)
453        );
454        assert_eq!(disabled_on.handle, c(ColorRole::SURFACE));
455        assert_eq!(disabled_on.icon, state::disabled(on_surface));
456        let disabled_off = switch_paint(false, true, false);
457        assert_eq!(disabled_off.handle, state::disabled(on_surface));
458        assert_eq!(
459            disabled_off.outline,
460            on_surface.opacity(state::DISABLED_CONTAINER)
461        );
462        assert_eq!(
463            disabled_off.track,
464            c(ColorRole::SURFACE_CONTAINER_HIGHEST).opacity(state::DISABLED_CONTAINER)
465        );
466        assert_eq!(STATE_LAYER, 40.);
467        assert_eq!(ICON, 16.);
468    }
469
470    #[test]
471    fn an_error_switch_uses_the_error_roles_unless_disabled() {
472        let c = color;
473        let on = switch_paint(true, false, true);
474        assert_eq!(on.track, c(ColorRole::ERROR));
475        assert_eq!(on.handle, c(ColorRole::ON_ERROR));
476        assert_eq!(on.handle_active, c(ColorRole::ERROR_CONTAINER));
477        assert_eq!(on.icon, c(ColorRole::ON_ERROR_CONTAINER));
478        assert_eq!(on.layer, c(ColorRole::ERROR));
479        let off = switch_paint(false, false, true);
480        assert_eq!(off.track, c(ColorRole::SURFACE_CONTAINER_HIGHEST));
481        assert_eq!(off.outline, c(ColorRole::ERROR));
482        assert_eq!(off.handle, c(ColorRole::ERROR));
483        assert_eq!(off.handle_active, c(ColorRole::ON_ERROR_CONTAINER));
484        assert_eq!(
485            switch_paint(true, true, true),
486            switch_paint(true, true, false)
487        );
488        assert_eq!(
489            switch_paint(false, true, true),
490            switch_paint(false, true, false)
491        );
492    }
493
494    struct PressHarness(SwitchIcons, bool, bool);
495    impl Render for PressHarness {
496        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
497            window.set_rem_size(px(14.));
498            div().p(px(20.)).child(
499                MaterialSwitch::new("press-switch", "Wi-Fi")
500                    .checked(self.1)
501                    .disabled(self.2)
502                    .icons(self.0),
503            )
504        }
505    }
506
507    #[gpui::test]
508    fn a_press_ended_while_disabled_does_not_stick(cx: &mut TestAppContext) {
509        cx.update(|cx| {
510            gpui_base::init(cx);
511            cx.set_reduce_motion(true);
512        });
513        let (view, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::None, true, false));
514        draw(cx);
515        let rest = cx.debug_bounds("switch-handle").unwrap();
516        cx.simulate_mouse_move(rest.center(), None, Modifiers::default());
517        cx.simulate_mouse_down(rest.center(), MouseButton::Left, Modifiers::default());
518        draw(cx);
519        let set_disabled = |disabled, cx: &mut VisualTestContext| {
520            view.update(cx, |view, cx| {
521                view.2 = disabled;
522                cx.notify();
523            });
524            draw(cx);
525        };
526        set_disabled(true, cx);
527        cx.simulate_mouse_up(rest.center(), MouseButton::Left, Modifiers::default());
528        draw(cx);
529        set_disabled(false, cx);
530        assert_eq!(
531            cx.debug_bounds("switch-handle").unwrap().size,
532            size(px(24.), px(24.)),
533            "the handle is back at rest"
534        );
535    }
536
537    #[gpui::test]
538    fn pressing_grows_the_handle_to_28dp_around_its_center(cx: &mut TestAppContext) {
539        cx.update(|cx| {
540            gpui_base::init(cx);
541            cx.set_reduce_motion(true);
542        });
543        let (_, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::None, true, false));
544        draw(cx);
545        let rest = cx.debug_bounds("switch-handle").unwrap();
546        assert_eq!(rest.size, size(px(24.), px(24.)));
547        cx.simulate_mouse_move(rest.center(), None, Modifiers::default());
548        cx.simulate_mouse_down(rest.center(), MouseButton::Left, Modifiers::default());
549        draw(cx);
550        let pressed = cx.debug_bounds("switch-handle").unwrap();
551        assert_eq!(pressed.size, size(px(28.), px(28.)));
552        assert_eq!(pressed.center(), rest.center());
553        cx.simulate_mouse_up(rest.center(), MouseButton::Left, Modifiers::default());
554        draw(cx);
555        assert_eq!(
556            cx.debug_bounds("switch-handle").unwrap().size,
557            size(px(24.), px(24.)),
558            "releasing returns the handle to rest"
559        );
560    }
561
562    #[gpui::test]
563    fn an_icon_grows_the_unselected_handle_to_24dp(cx: &mut TestAppContext) {
564        cx.update(|cx| {
565            gpui_base::init(cx);
566            cx.set_reduce_motion(true);
567        });
568        let (_, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::Both, false, false));
569        draw(cx);
570        assert_eq!(
571            cx.debug_bounds("switch-handle").unwrap().size,
572            size(px(24.), px(24.))
573        );
574        let (_, cx) = cx.add_window_view(|_, _| PressHarness(SwitchIcons::Selected, false, false));
575        draw(cx);
576        assert_eq!(
577            cx.debug_bounds("switch-handle").unwrap().size,
578            size(px(16.), px(16.))
579        );
580    }
581
582    fn draw(cx: &mut VisualTestContext) {
583        cx.update(|w, cx| {
584            w.refresh();
585            w.draw(cx).clear(cx);
586        });
587    }
588}