Skip to main content

gpui_component/
switch.rs

1use crate::{
2    ActiveTheme, Disableable, FocusableExt, Side, Sizable, Size, StyleSized as _, StyledExt,
3    ThemeStyled as _, text::Text, tooltip::ComponentTooltip,
4};
5use gpui::{
6    App, Background, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement as _,
7    RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
8    px,
9};
10use gpui_base::{Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
11use std::rc::Rc;
12
13/// A Switch element that can be toggled on or off.
14#[derive(IntoElement)]
15pub struct Switch {
16    id: ElementId,
17    style: StyleRefinement,
18    checked: bool,
19    disabled: bool,
20    label: Option<Text>,
21    /// The announced name, when the visible label is not it.
22    accessibility_label: Option<SharedString>,
23    label_side: Side,
24    on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
25    size: Size,
26    color: Option<Hsla>,
27    tooltip: ComponentTooltip,
28    tab_stop: bool,
29    tab_index: isize,
30    focus_ring_enabled: bool,
31}
32
33impl Switch {
34    /// Create a new Switch element.
35    pub fn new(id: impl Into<ElementId>) -> Self {
36        let id: ElementId = id.into();
37        Self {
38            id: id.clone(),
39            style: StyleRefinement::default(),
40            checked: false,
41            disabled: false,
42            label: None,
43            accessibility_label: None,
44            on_click: None,
45            label_side: Side::Right,
46            size: Size::Medium,
47            color: None,
48            tooltip: ComponentTooltip::default(),
49            tab_stop: true,
50            tab_index: 0,
51            focus_ring_enabled: true,
52        }
53    }
54
55    /// Set the checked state of the switch.
56    pub fn checked(mut self, checked: bool) -> Self {
57        self.checked = checked;
58        self
59    }
60
61    /// Set the label of the switch.
62    pub fn label(mut self, label: impl Into<Text>) -> Self {
63        self.label = Some(label.into());
64        self
65    }
66
67    /// Set the name a screen reader announces, when the visible label is not
68    /// it.
69    ///
70    /// A switch's name comes from its [`label`](Self::label) by default.
71    /// Setting this replaces the announced name without changing what is
72    /// displayed.
73    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
74        self.accessibility_label = Some(label.into());
75        self
76    }
77
78    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
79    pub fn on_click<F>(self, handler: F) -> Self
80    where
81        F: Fn(&bool, &mut Window, &mut App) + 'static,
82    {
83        self.on_change(handler)
84    }
85
86    /// Handle a requested checked value from pointer or keyboard activation.
87    ///
88    /// This is a controlled value: the owner must write the requested value and
89    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
90    /// This and [`Self::on_click`] share one callback; chaining them replaces
91    /// the previous handler instead of calling both.
92    pub fn on_change<F>(mut self, handler: F) -> Self
93    where
94        F: Fn(&bool, &mut Window, &mut App) + 'static,
95    {
96        self.on_click = Some(Rc::new(handler));
97        self
98    }
99
100    /// Set the background color of the switch when checked.
101    /// Defaults to `cx.theme().primary`.
102    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
103        self.color = Some(color.into());
104        self
105    }
106
107    /// Set tooltip text for the switch.
108    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
109        self.tooltip.text = Some((tooltip.into(), None));
110        self
111    }
112
113    /// Set whether the switch participates in keyboard focus traversal,
114    /// default is true.
115    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
116        self.tab_stop = tab_stop;
117        self
118    }
119
120    /// Set the focus traversal index within a GPUI tab group, default is 0.
121    pub fn tab_index(mut self, tab_index: isize) -> Self {
122        self.tab_index = tab_index;
123        self
124    }
125}
126
127impl Styled for Switch {
128    fn style(&mut self) -> &mut gpui::StyleRefinement {
129        &mut self.style
130    }
131}
132
133impl Sizable for Switch {
134    fn with_size(mut self, size: impl Into<Size>) -> Self {
135        self.size = size.into();
136        self
137    }
138}
139
140impl Disableable for Switch {
141    fn disabled(mut self, disabled: bool) -> Self {
142        self.disabled = disabled;
143        self
144    }
145}
146
147impl FocusableExt for Switch {
148    fn focus_ring(mut self, enabled: bool) -> Self {
149        self.focus_ring_enabled = enabled;
150        self
151    }
152
153    fn is_focus_ring_enabled(&self) -> bool {
154        self.focus_ring_enabled
155    }
156}
157
158impl RenderOnce for Switch {
159    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
160        let checked = self.checked;
161        let on_click = self.on_click.clone();
162        let accessibility_label = self
163            .accessibility_label
164            .clone()
165            .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
166        let focus_handle = window
167            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
168            .read(cx)
169            .clone();
170        let is_focused = focus_handle.is_focused(window);
171
172        let checked_bg = self
173            .color
174            .map(Background::from)
175            .unwrap_or(cx.theme().tokens.primary.into());
176        let unchecked_bg: Background = cx.theme().tokens.switch.into();
177        // GPUI's element opacity multiplies each primitive's alpha instead of
178        // compositing the subtree as one group, so fading the whole control
179        // would let the track show through the thumb. Fading the track alone
180        // lands on the pixels a grouped fade would: the thumb is `background`.
181        let disabled_bg = if checked { checked_bg } else { unchecked_bg }.opacity(0.5);
182        let toggle_bg: Background = cx.theme().tokens.switch_thumb.into();
183        let disabled_label_color = cx.theme().muted_foreground;
184
185        let (bg_width, bg_height) = match self.size {
186            Size::XSmall | Size::Small => (px(28.), px(16.)),
187            Size::Large => (px(44.), px(24.)),
188            _ => (px(36.), px(20.)),
189        };
190        let bar_width = match self.size {
191            Size::XSmall | Size::Small => px(12.),
192            Size::Large => px(20.),
193            _ => px(16.),
194        };
195        let inset = px(2.);
196        let radius = if cx.theme().radius >= px(4.) {
197            bg_height
198        } else {
199            cx.theme().radius
200        };
201
202        // The thumb's position is geometry, not a semantic state style: a
203        // `checked` style setting `left` outranks the instance style by the
204        // documented precedence, which left the travel visible in one direction
205        // only. The spring owns it end to end and reverses from wherever the
206        // thumb is when the switch is toggled again mid-travel.
207        let thumb_x = spring(
208            (self.id.clone(), "thumb"),
209            if checked {
210                bg_width - bar_width - inset * 2
211            } else {
212                px(0.)
213            },
214            cx.theme().motion_tokens().spring_move,
215            window,
216            cx,
217        );
218
219        div().refine_style(&self.style).child(
220            BaseSwitch::new(self.id.clone())
221                .checked(checked)
222                .disabled(self.disabled)
223                .styles(|styles| {
224                    styles.disabled(|style| {
225                        style.text_color(disabled_label_color).cursor_not_allowed()
226                    })
227                })
228                .when_some(accessibility_label, |this, label| {
229                    this.accessibility_label(label)
230                })
231                .when_some(on_click, |this, on_click| {
232                    this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
233                })
234                .tab_stop(self.tab_stop)
235                .tab_index(self.tab_index)
236                .track_focus(&focus_handle)
237                .h_flex()
238                .gap_2()
239                .items_start()
240                .when(self.label_side.is_left(), |this| this.flex_row_reverse())
241                .child(
242                    // Switch Bar
243                    SwitchTrack::new((self.id.clone(), "track"))
244                        .checked(checked)
245                        .disabled(self.disabled)
246                        .when(cfg!(test), |this| {
247                            this.debug_selector(|| "switch-bar".into())
248                        })
249                        .w(bg_width)
250                        .h(bg_height)
251                        .flex_shrink_0()
252                        .rounded(radius)
253                        .flex()
254                        .items_center()
255                        // The thumb inset is a 1px border plus 1px padding,
256                        // not a 2px border: the focus ring tints the border
257                        // solid, and that 1px line is what keeps the ring
258                        // visible on an unchecked track. Its 50% halo alone
259                        // lands within a few values of `switch.background`
260                        // in both default modes.
261                        .border_1()
262                        .border_color(cx.theme().transparent)
263                        .p(inset - px(1.))
264                        .when(!checked, |this| this.bg(unchecked_bg))
265                        .styles(|styles| {
266                            styles
267                                .checked(|style| style.bg(checked_bg))
268                                .disabled(|style| style.bg(disabled_bg))
269                        })
270                        // The ring hugs the track, not the row, so the label
271                        // stays outside it.
272                        .when(is_focused && self.focus_ring_enabled, |this| {
273                            this.focus_ring_style(window, cx)
274                        })
275                        .map(|this| self.tooltip.apply(this))
276                        .child(
277                            // Switch Toggle
278                            SwitchThumb::new(checked)
279                                .rounded(radius)
280                                .size(bar_width)
281                                .left(thumb_x)
282                                .bg(toggle_bg),
283                        ),
284                )
285                .when_some(self.label, |this, label| {
286                    this.child(
287                        div()
288                            .when(cfg!(test), |this| {
289                                this.debug_selector(|| "switch-label".into())
290                            })
291                            .min_w_0()
292                            .line_height(bg_height)
293                            .child(label)
294                            .map(|this| match self.size {
295                                Size::Size(_) => this.input_text_size(Size::Medium),
296                                size => this.input_text_size(size),
297                            }),
298                    )
299                }),
300        )
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use std::{cell::Cell, rc::Rc};
307
308    use gpui::{
309        Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
310        StatefulInteractiveElement as _, TestAppContext, VisualTestContext, point,
311    };
312
313    use super::*;
314
315    #[test]
316    fn an_explicit_accessibility_label_replaces_the_visible_one() {
317        let plain = Switch::new("wifi").label("Wi-Fi");
318        assert_eq!(plain.accessibility_label, None);
319        assert!(matches!(
320            &plain.label,
321            Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
322        ));
323
324        let named = Switch::new("wifi")
325            .label("Wi-Fi")
326            .accessibility_label("Toggle Wi-Fi");
327        assert_eq!(
328            named.accessibility_label.as_deref(),
329            Some("Toggle Wi-Fi"),
330            "an explicit name must win over the visible label"
331        );
332        assert!(
333            matches!(
334                &named.label,
335                Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
336            ),
337            "and must not change what is drawn"
338        );
339    }
340
341    struct SwitchHarness {
342        disabled: bool,
343        toggles: Rc<Cell<usize>>,
344        parent_clicks: Rc<Cell<usize>>,
345    }
346
347    impl Render for SwitchHarness {
348        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
349            let toggles = self.toggles.clone();
350            let parent_clicks = self.parent_clicks.clone();
351            div()
352                .id("switch-parent")
353                .tab_group()
354                .size(px(100.))
355                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
356                .child(Switch::new("switch").disabled(self.disabled).on_click(
357                    move |checked, _, _| {
358                        assert!(*checked);
359                        toggles.set(toggles.get() + 1);
360                    },
361                ))
362        }
363    }
364
365    fn harness(
366        cx: &mut TestAppContext,
367        disabled: bool,
368    ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
369        cx.update(crate::init);
370        let toggles = Rc::new(Cell::new(0));
371        let parent_clicks = Rc::new(Cell::new(0));
372        let (_, cx) = cx.add_window_view({
373            let toggles = toggles.clone();
374            let parent_clicks = parent_clicks.clone();
375            move |_, _| SwitchHarness {
376                disabled,
377                toggles,
378                parent_clicks,
379            }
380        });
381        cx.update(|window, cx| window.draw(cx).clear(cx));
382        (cx, toggles, parent_clicks)
383    }
384
385    fn activate_key(cx: &mut VisualTestContext, key: &str) {
386        let keystroke = Keystroke::parse(key).unwrap();
387        cx.simulate_event(KeyDownEvent {
388            keystroke: keystroke.clone(),
389            is_held: false,
390            prefer_character_input: false,
391        });
392        cx.simulate_event(KeyUpEvent { keystroke });
393    }
394
395    #[gpui::test]
396    fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
397        let (cx, toggles, _) = harness(cx, false);
398        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
399
400        assert_eq!(toggles.get(), 1);
401        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
402    }
403
404    #[gpui::test]
405    fn canonical_switch_supports_tab_enter_and_space(cx: &mut TestAppContext) {
406        let (cx, toggles, _) = harness(cx, false);
407        cx.update(|window, cx| window.focus_next(cx));
408        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
409
410        activate_key(cx, "enter");
411        activate_key(cx, "space");
412
413        assert_eq!(toggles.get(), 2);
414    }
415
416    #[gpui::test]
417    fn canonical_disabled_switch_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
418        let (cx, toggles, parent_clicks) = harness(cx, true);
419        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
420
421        assert_eq!(toggles.get(), 0);
422        assert_eq!(parent_clicks.get(), 0);
423        cx.update(|window, cx| assert!(window.focused(cx).is_none()));
424    }
425
426    struct FocusRingHarness {
427        disabled: bool,
428        focus_ring: bool,
429    }
430
431    impl Render for FocusRingHarness {
432        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
433            div().id("switch-parent").tab_group().size(px(100.)).child(
434                Switch::new("switch")
435                    .label("Airplane mode")
436                    .disabled(self.disabled)
437                    .focus_ring(self.focus_ring),
438            )
439        }
440    }
441
442    fn focus_ring_harness(
443        cx: &mut TestAppContext,
444        disabled: bool,
445        focus_ring: bool,
446    ) -> &mut VisualTestContext {
447        cx.update(crate::init);
448        let (_, cx) = cx.add_window_view(move |_, _| FocusRingHarness {
449            disabled,
450            focus_ring,
451        });
452        cx.update(|window, cx| window.draw(cx).clear(cx));
453        cx
454    }
455
456    #[gpui::test]
457    fn focus_ring_hugs_the_track_when_the_switch_is_focused(cx: &mut TestAppContext) {
458        let cx = focus_ring_harness(cx, false, true);
459        assert!(
460            cx.debug_bounds("focus-ring").is_none(),
461            "an unfocused switch draws no ring"
462        );
463
464        cx.update(|window, cx| window.focus_next(cx));
465        cx.update(|window, cx| {
466            assert!(window.focused(cx).is_some());
467            window.draw(cx).clear(cx);
468        });
469
470        let ring = cx
471            .debug_bounds("focus-ring")
472            .expect("a focused switch must draw its focus ring");
473        let bar = cx.debug_bounds("switch-bar").unwrap();
474        let label = cx.debug_bounds("switch-label").unwrap();
475        assert!(ring.contains(&bar.origin), "the ring surrounds the track");
476        assert!(
477            ring.right() < label.origin.x,
478            "the ring hugs the track and leaves the label outside"
479        );
480    }
481
482    #[gpui::test]
483    fn focus_ring_can_be_turned_off(cx: &mut TestAppContext) {
484        let cx = focus_ring_harness(cx, false, false);
485        cx.update(|window, cx| window.focus_next(cx));
486        cx.update(|window, cx| {
487            assert!(window.focused(cx).is_some());
488            window.draw(cx).clear(cx);
489        });
490
491        assert!(
492            cx.debug_bounds("focus-ring").is_none(),
493            "`focus_ring(false)` must not draw a ring"
494        );
495    }
496
497    #[gpui::test]
498    fn disabled_switch_takes_no_focus_and_draws_no_ring(cx: &mut TestAppContext) {
499        let cx = focus_ring_harness(cx, true, true);
500        cx.update(|window, cx| window.focus_next(cx));
501        cx.update(|window, cx| {
502            assert!(window.focused(cx).is_none());
503            window.draw(cx).clear(cx);
504        });
505
506        assert!(cx.debug_bounds("focus-ring").is_none());
507    }
508
509    #[gpui::test]
510    fn long_labels_preserve_track_size_in_narrow_containers(cx: &mut TestAppContext) {
511        struct NarrowSwitch {
512            size: Size,
513            checked: bool,
514            disabled: bool,
515        }
516
517        impl Render for NarrowSwitch {
518            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
519                div()
520                    .w(px(160.))
521                    .debug_selector(|| "narrow-switch".into())
522                    .child(
523                        Switch::new("switch")
524                            .with_size(self.size)
525                            .checked(self.checked)
526                            .disabled(self.disabled)
527                            .label("Automatically transcribe downloaded episodes"),
528                    )
529            }
530        }
531
532        cx.update(crate::init);
533        for (size, width, height) in [
534            (Size::Small, 28., 16.),
535            (Size::Medium, 36., 20.),
536            (Size::Large, 44., 24.),
537        ] {
538            for checked in [false, true] {
539                for disabled in [false, true] {
540                    let (_, cx) = cx.add_window_view(move |_, _| NarrowSwitch {
541                        size,
542                        checked,
543                        disabled,
544                    });
545                    cx.update(|window, cx| window.draw(cx).clear(cx));
546
547                    let container = cx.debug_bounds("narrow-switch").unwrap();
548                    let track = cx.debug_bounds("switch-bar").unwrap();
549                    let label = cx.debug_bounds("switch-label").unwrap();
550                    assert_eq!(track.size.width, px(width), "the track must not shrink");
551                    assert_eq!(track.size.height, px(height));
552                    assert!(label.origin.x >= track.right());
553                    assert!(label.right() <= container.right());
554                    assert!(label.size.height > track.size.height, "the label must wrap");
555                }
556            }
557        }
558    }
559
560    #[gpui::test]
561    fn label_prepaints_with_the_base_switch_content(cx: &mut TestAppContext) {
562        struct LabelHarness;
563
564        impl Render for LabelHarness {
565            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
566                div()
567                    .debug_selector(|| "labeled-switch".into())
568                    .child(Switch::new("switch").label("Airplane mode"))
569            }
570        }
571
572        cx.update(crate::init);
573        let (_, cx) = cx.add_window_view(|_, _| LabelHarness);
574        cx.update(|window, cx| window.draw(cx).clear(cx));
575
576        let bounds = cx
577            .debug_bounds("labeled-switch")
578            .expect("the complete labeled Switch must participate in prepaint");
579        assert!(bounds.size.width > px(36.));
580        let bar = cx
581            .debug_bounds("switch-bar")
582            .expect("the Switch bar must participate in prepaint");
583        let label = cx
584            .debug_bounds("switch-label")
585            .expect("the Switch label must participate in prepaint");
586        assert_eq!(bar.origin.y, label.origin.y);
587    }
588}