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