Skip to main content

gpui_component/
switch.rs

1use crate::{
2    ActiveTheme, Disableable, FocusableExt, Side, Sizable, Size, StyledExt, ThemeStyled as _,
3    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            _ => (px(36.), px(20.)),
188        };
189        let bar_width = match self.size {
190            Size::XSmall | Size::Small => px(12.),
191            _ => px(16.),
192        };
193        let inset = px(2.);
194        let radius = if cx.theme().radius >= px(4.) {
195            bg_height
196        } else {
197            cx.theme().radius
198        };
199
200        // The thumb's position is geometry, not a semantic state style: a
201        // `checked` style setting `left` outranks the instance style by the
202        // documented precedence, which left the travel visible in one direction
203        // only. The spring owns it end to end and reverses from wherever the
204        // thumb is when the switch is toggled again mid-travel.
205        let thumb_x = spring(
206            (self.id.clone(), "thumb"),
207            if checked {
208                bg_width - bar_width - inset * 2
209            } else {
210                px(0.)
211            },
212            cx.theme().motion_tokens().spring_move,
213            window,
214            cx,
215        );
216
217        div().refine_style(&self.style).child(
218            BaseSwitch::new(self.id.clone())
219                .checked(checked)
220                .disabled(self.disabled)
221                .styles(|styles| {
222                    styles.disabled(|style| {
223                        style.text_color(disabled_label_color).cursor_not_allowed()
224                    })
225                })
226                .when_some(accessibility_label, |this, label| {
227                    this.accessibility_label(label)
228                })
229                .when_some(on_click, |this, on_click| {
230                    this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
231                })
232                .tab_stop(self.tab_stop)
233                .tab_index(self.tab_index)
234                .track_focus(&focus_handle)
235                .h_flex()
236                .gap_2()
237                .items_start()
238                .when(self.label_side.is_left(), |this| this.flex_row_reverse())
239                .child(
240                    // Switch Bar
241                    SwitchTrack::new((self.id.clone(), "track"))
242                        .checked(checked)
243                        .disabled(self.disabled)
244                        .when(cfg!(test), |this| {
245                            this.debug_selector(|| "switch-bar".into())
246                        })
247                        .w(bg_width)
248                        .h(bg_height)
249                        .flex_shrink_0()
250                        .rounded(radius)
251                        .flex()
252                        .items_center()
253                        // The thumb inset is a 1px border plus 1px padding,
254                        // not a 2px border: the focus ring tints the border
255                        // solid, and that 1px line is what keeps the ring
256                        // visible on an unchecked track. Its 50% halo alone
257                        // lands within a few values of `switch.background`
258                        // in both default modes.
259                        .border_1()
260                        .border_color(cx.theme().transparent)
261                        .p(inset - px(1.))
262                        .when(!checked, |this| this.bg(unchecked_bg))
263                        .styles(|styles| {
264                            styles
265                                .checked(|style| style.bg(checked_bg))
266                                .disabled(|style| style.bg(disabled_bg))
267                        })
268                        // The ring hugs the track, not the row, so the label
269                        // stays outside it.
270                        .when(is_focused && self.focus_ring_enabled, |this| {
271                            this.focus_ring_style(window, cx)
272                        })
273                        .map(|this| self.tooltip.apply(this))
274                        .child(
275                            // Switch Toggle
276                            SwitchThumb::new(checked)
277                                .rounded(radius)
278                                .size(bar_width)
279                                .left(thumb_x)
280                                .bg(toggle_bg),
281                        ),
282                )
283                .when_some(self.label, |this, label| {
284                    this.child(
285                        div()
286                            .when(cfg!(test), |this| {
287                                this.debug_selector(|| "switch-label".into())
288                            })
289                            .min_w_0()
290                            .line_height(bg_height)
291                            .child(label)
292                            .map(|this| match self.size {
293                                Size::XSmall | Size::Small => this.text_sm(),
294                                _ => this.text_base(),
295                            }),
296                    )
297                }),
298        )
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use std::{cell::Cell, rc::Rc};
305
306    use gpui::{
307        Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
308        StatefulInteractiveElement as _, TestAppContext, VisualTestContext, point,
309    };
310
311    use super::*;
312
313    #[test]
314    fn an_explicit_accessibility_label_replaces_the_visible_one() {
315        let plain = Switch::new("wifi").label("Wi-Fi");
316        assert_eq!(plain.accessibility_label, None);
317        assert!(matches!(
318            &plain.label,
319            Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
320        ));
321
322        let named = Switch::new("wifi")
323            .label("Wi-Fi")
324            .accessibility_label("Toggle Wi-Fi");
325        assert_eq!(
326            named.accessibility_label.as_deref(),
327            Some("Toggle Wi-Fi"),
328            "an explicit name must win over the visible label"
329        );
330        assert!(
331            matches!(
332                &named.label,
333                Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
334            ),
335            "and must not change what is drawn"
336        );
337    }
338
339    struct SwitchHarness {
340        disabled: bool,
341        toggles: Rc<Cell<usize>>,
342        parent_clicks: Rc<Cell<usize>>,
343    }
344
345    impl Render for SwitchHarness {
346        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
347            let toggles = self.toggles.clone();
348            let parent_clicks = self.parent_clicks.clone();
349            div()
350                .id("switch-parent")
351                .tab_group()
352                .size(px(100.))
353                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
354                .child(Switch::new("switch").disabled(self.disabled).on_click(
355                    move |checked, _, _| {
356                        assert!(*checked);
357                        toggles.set(toggles.get() + 1);
358                    },
359                ))
360        }
361    }
362
363    fn harness(
364        cx: &mut TestAppContext,
365        disabled: bool,
366    ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
367        cx.update(crate::init);
368        let toggles = Rc::new(Cell::new(0));
369        let parent_clicks = Rc::new(Cell::new(0));
370        let (_, cx) = cx.add_window_view({
371            let toggles = toggles.clone();
372            let parent_clicks = parent_clicks.clone();
373            move |_, _| SwitchHarness {
374                disabled,
375                toggles,
376                parent_clicks,
377            }
378        });
379        cx.update(|window, cx| window.draw(cx).clear(cx));
380        (cx, toggles, parent_clicks)
381    }
382
383    fn activate_key(cx: &mut VisualTestContext, key: &str) {
384        let keystroke = Keystroke::parse(key).unwrap();
385        cx.simulate_event(KeyDownEvent {
386            keystroke: keystroke.clone(),
387            is_held: false,
388            prefer_character_input: false,
389        });
390        cx.simulate_event(KeyUpEvent { keystroke });
391    }
392
393    #[gpui::test]
394    fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
395        let (cx, toggles, _) = harness(cx, false);
396        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
397
398        assert_eq!(toggles.get(), 1);
399        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
400    }
401
402    #[gpui::test]
403    fn canonical_switch_supports_tab_enter_and_space(cx: &mut TestAppContext) {
404        let (cx, toggles, _) = harness(cx, false);
405        cx.update(|window, cx| window.focus_next(cx));
406        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
407
408        activate_key(cx, "enter");
409        activate_key(cx, "space");
410
411        assert_eq!(toggles.get(), 2);
412    }
413
414    #[gpui::test]
415    fn canonical_disabled_switch_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
416        let (cx, toggles, parent_clicks) = harness(cx, true);
417        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
418
419        assert_eq!(toggles.get(), 0);
420        assert_eq!(parent_clicks.get(), 0);
421        cx.update(|window, cx| assert!(window.focused(cx).is_none()));
422    }
423
424    struct FocusRingHarness {
425        disabled: bool,
426        focus_ring: bool,
427    }
428
429    impl Render for FocusRingHarness {
430        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
431            div().id("switch-parent").tab_group().size(px(100.)).child(
432                Switch::new("switch")
433                    .label("Airplane mode")
434                    .disabled(self.disabled)
435                    .focus_ring(self.focus_ring),
436            )
437        }
438    }
439
440    fn focus_ring_harness(
441        cx: &mut TestAppContext,
442        disabled: bool,
443        focus_ring: bool,
444    ) -> &mut VisualTestContext {
445        cx.update(crate::init);
446        let (_, cx) = cx.add_window_view(move |_, _| FocusRingHarness {
447            disabled,
448            focus_ring,
449        });
450        cx.update(|window, cx| window.draw(cx).clear(cx));
451        cx
452    }
453
454    #[gpui::test]
455    fn focus_ring_hugs_the_track_when_the_switch_is_focused(cx: &mut TestAppContext) {
456        let cx = focus_ring_harness(cx, false, true);
457        assert!(
458            cx.debug_bounds("focus-ring").is_none(),
459            "an unfocused switch draws no ring"
460        );
461
462        cx.update(|window, cx| window.focus_next(cx));
463        cx.update(|window, cx| {
464            assert!(window.focused(cx).is_some());
465            window.draw(cx).clear(cx);
466        });
467
468        let ring = cx
469            .debug_bounds("focus-ring")
470            .expect("a focused switch must draw its focus ring");
471        let bar = cx.debug_bounds("switch-bar").unwrap();
472        let label = cx.debug_bounds("switch-label").unwrap();
473        assert!(ring.contains(&bar.origin), "the ring surrounds the track");
474        assert!(
475            ring.right() < label.origin.x,
476            "the ring hugs the track and leaves the label outside"
477        );
478    }
479
480    #[gpui::test]
481    fn focus_ring_can_be_turned_off(cx: &mut TestAppContext) {
482        let cx = focus_ring_harness(cx, false, false);
483        cx.update(|window, cx| window.focus_next(cx));
484        cx.update(|window, cx| {
485            assert!(window.focused(cx).is_some());
486            window.draw(cx).clear(cx);
487        });
488
489        assert!(
490            cx.debug_bounds("focus-ring").is_none(),
491            "`focus_ring(false)` must not draw a ring"
492        );
493    }
494
495    #[gpui::test]
496    fn disabled_switch_takes_no_focus_and_draws_no_ring(cx: &mut TestAppContext) {
497        let cx = focus_ring_harness(cx, true, true);
498        cx.update(|window, cx| window.focus_next(cx));
499        cx.update(|window, cx| {
500            assert!(window.focused(cx).is_none());
501            window.draw(cx).clear(cx);
502        });
503
504        assert!(cx.debug_bounds("focus-ring").is_none());
505    }
506
507    #[gpui::test]
508    fn long_labels_preserve_track_size_in_narrow_containers(cx: &mut TestAppContext) {
509        struct NarrowSwitch {
510            size: Size,
511            checked: bool,
512            disabled: bool,
513        }
514
515        impl Render for NarrowSwitch {
516            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
517                div()
518                    .w(px(160.))
519                    .debug_selector(|| "narrow-switch".into())
520                    .child(
521                        Switch::new("switch")
522                            .with_size(self.size)
523                            .checked(self.checked)
524                            .disabled(self.disabled)
525                            .label("Automatically transcribe downloaded episodes"),
526                    )
527            }
528        }
529
530        cx.update(crate::init);
531        for (size, width, height) in [(Size::Small, 28., 16.), (Size::Medium, 36., 20.)] {
532            for checked in [false, true] {
533                for disabled in [false, true] {
534                    let (_, cx) = cx.add_window_view(move |_, _| NarrowSwitch {
535                        size,
536                        checked,
537                        disabled,
538                    });
539                    cx.update(|window, cx| window.draw(cx).clear(cx));
540
541                    let container = cx.debug_bounds("narrow-switch").unwrap();
542                    let track = cx.debug_bounds("switch-bar").unwrap();
543                    let label = cx.debug_bounds("switch-label").unwrap();
544                    assert_eq!(track.size.width, px(width), "the track must not shrink");
545                    assert_eq!(track.size.height, px(height));
546                    assert!(label.origin.x >= track.right());
547                    assert!(label.right() <= container.right());
548                    assert!(label.size.height > track.size.height, "the label must wrap");
549                }
550            }
551        }
552    }
553
554    #[gpui::test]
555    fn label_prepaints_with_the_base_switch_content(cx: &mut TestAppContext) {
556        struct LabelHarness;
557
558        impl Render for LabelHarness {
559            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
560                div()
561                    .debug_selector(|| "labeled-switch".into())
562                    .child(Switch::new("switch").label("Airplane mode"))
563            }
564        }
565
566        cx.update(crate::init);
567        let (_, cx) = cx.add_window_view(|_, _| LabelHarness);
568        cx.update(|window, cx| window.draw(cx).clear(cx));
569
570        let bounds = cx
571            .debug_bounds("labeled-switch")
572            .expect("the complete labeled Switch must participate in prepaint");
573        assert!(bounds.size.width > px(36.));
574        let bar = cx
575            .debug_bounds("switch-bar")
576            .expect("the Switch bar must participate in prepaint");
577        let label = cx
578            .debug_bounds("switch-label")
579            .expect("the Switch label must participate in prepaint");
580        assert_eq!(bar.origin.y, label.origin.y);
581    }
582}