Skip to main content

gpui_component/
switch.rs

1use crate::{
2    ActiveTheme, Disableable, Side, Sizable, Size, StyledExt, text::Text, tooltip::ComponentTooltip,
3};
4use gpui::{
5    App, Background, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement as _,
6    RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
7    px,
8};
9use gpui_base::{Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
10use std::rc::Rc;
11
12/// A Switch element that can be toggled on or off.
13#[derive(IntoElement)]
14pub struct Switch {
15    id: ElementId,
16    style: StyleRefinement,
17    checked: bool,
18    disabled: bool,
19    label: Option<Text>,
20    /// The announced name, when the visible label is not it.
21    accessibility_label: Option<SharedString>,
22    label_side: Side,
23    on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
24    size: Size,
25    color: Option<Hsla>,
26    tooltip: ComponentTooltip,
27}
28
29impl Switch {
30    /// Create a new Switch element.
31    pub fn new(id: impl Into<ElementId>) -> Self {
32        let id: ElementId = id.into();
33        Self {
34            id: id.clone(),
35            style: StyleRefinement::default(),
36            checked: false,
37            disabled: false,
38            label: None,
39            accessibility_label: None,
40            on_click: None,
41            label_side: Side::Right,
42            size: Size::Medium,
43            color: None,
44            tooltip: ComponentTooltip::default(),
45        }
46    }
47
48    /// Set the checked state of the switch.
49    pub fn checked(mut self, checked: bool) -> Self {
50        self.checked = checked;
51        self
52    }
53
54    /// Set the label of the switch.
55    pub fn label(mut self, label: impl Into<Text>) -> Self {
56        self.label = Some(label.into());
57        self
58    }
59
60    /// Set the name a screen reader announces, when the visible label is not
61    /// it.
62    ///
63    /// A switch's name comes from its [`label`](Self::label) by default.
64    /// Setting this replaces the announced name without changing what is
65    /// displayed.
66    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
67        self.accessibility_label = Some(label.into());
68        self
69    }
70
71    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
72    pub fn on_click<F>(self, handler: F) -> Self
73    where
74        F: Fn(&bool, &mut Window, &mut App) + 'static,
75    {
76        self.on_change(handler)
77    }
78
79    /// Handle a requested checked value from pointer or keyboard activation.
80    ///
81    /// This is a controlled value: the owner must write the requested value and
82    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
83    /// This and [`Self::on_click`] share one callback; chaining them replaces
84    /// the previous handler instead of calling both.
85    pub fn on_change<F>(mut self, handler: F) -> Self
86    where
87        F: Fn(&bool, &mut Window, &mut App) + 'static,
88    {
89        self.on_click = Some(Rc::new(handler));
90        self
91    }
92
93    /// Set the background color of the switch when checked.
94    /// Defaults to `cx.theme().primary`.
95    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
96        self.color = Some(color.into());
97        self
98    }
99
100    /// Set tooltip text for the switch.
101    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
102        self.tooltip.text = Some((tooltip.into(), None));
103        self
104    }
105}
106
107impl Styled for Switch {
108    fn style(&mut self) -> &mut gpui::StyleRefinement {
109        &mut self.style
110    }
111}
112
113impl Sizable for Switch {
114    fn with_size(mut self, size: impl Into<Size>) -> Self {
115        self.size = size.into();
116        self
117    }
118}
119
120impl Disableable for Switch {
121    fn disabled(mut self, disabled: bool) -> Self {
122        self.disabled = disabled;
123        self
124    }
125}
126
127impl RenderOnce for Switch {
128    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
129        let checked = self.checked;
130        let on_click = self.on_click.clone();
131        let accessibility_label = self
132            .accessibility_label
133            .clone()
134            .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
135
136        let checked_bg = self
137            .color
138            .map(Background::from)
139            .unwrap_or(cx.theme().tokens.primary.into());
140        let unchecked_bg: Background = cx.theme().tokens.switch.into();
141        // GPUI's element opacity multiplies each primitive's alpha instead of
142        // compositing the subtree as one group, so fading the whole control
143        // would let the track show through the thumb. Fading the track alone
144        // lands on the pixels a grouped fade would: the thumb is `background`.
145        let disabled_bg = if checked { checked_bg } else { unchecked_bg }.opacity(0.5);
146        let toggle_bg: Background = cx.theme().tokens.switch_thumb.into();
147        let disabled_label_color = cx.theme().muted_foreground;
148
149        let (bg_width, bg_height) = match self.size {
150            Size::XSmall | Size::Small => (px(28.), px(16.)),
151            _ => (px(36.), px(20.)),
152        };
153        let bar_width = match self.size {
154            Size::XSmall | Size::Small => px(12.),
155            _ => px(16.),
156        };
157        let inset = px(2.);
158        let radius = if cx.theme().radius >= px(4.) {
159            bg_height
160        } else {
161            cx.theme().radius
162        };
163
164        // The thumb's position is geometry, not a semantic state style: a
165        // `checked` style setting `left` outranks the instance style by the
166        // documented precedence, which left the travel visible in one direction
167        // only. The spring owns it end to end and reverses from wherever the
168        // thumb is when the switch is toggled again mid-travel.
169        let thumb_x = spring(
170            (self.id.clone(), "thumb"),
171            if checked {
172                bg_width - bar_width - inset * 2
173            } else {
174                px(0.)
175            },
176            cx.theme().motion_tokens().spring_move,
177            window,
178            cx,
179        );
180
181        div().refine_style(&self.style).child(
182            BaseSwitch::new(self.id.clone())
183                .checked(checked)
184                .disabled(self.disabled)
185                .styles(|styles| {
186                    styles.disabled(|style| {
187                        style.text_color(disabled_label_color).cursor_not_allowed()
188                    })
189                })
190                .when_some(accessibility_label, |this, label| {
191                    this.accessibility_label(label)
192                })
193                .when_some(on_click, |this, on_click| {
194                    this.on_change(move |next, _, window, cx| on_click(&next, window, cx))
195                })
196                .h_flex()
197                .gap_2()
198                .items_start()
199                .when(self.label_side.is_left(), |this| this.flex_row_reverse())
200                .child(
201                    // Switch Bar
202                    SwitchTrack::new((self.id.clone(), "track"))
203                        .checked(checked)
204                        .disabled(self.disabled)
205                        .when(cfg!(test), |this| {
206                            this.debug_selector(|| "switch-bar".into())
207                        })
208                        .w(bg_width)
209                        .h(bg_height)
210                        .rounded(radius)
211                        .flex()
212                        .items_center()
213                        .border(inset)
214                        .border_color(cx.theme().transparent)
215                        .when(!checked, |this| this.bg(unchecked_bg))
216                        .styles(|styles| {
217                            styles
218                                .checked(|style| style.bg(checked_bg))
219                                .disabled(|style| style.bg(disabled_bg))
220                        })
221                        .map(|this| self.tooltip.apply(this))
222                        .child(
223                            // Switch Toggle
224                            SwitchThumb::new(checked)
225                                .rounded(radius)
226                                .size(bar_width)
227                                .left(thumb_x)
228                                .bg(toggle_bg),
229                        ),
230                )
231                .when_some(self.label, |this, label| {
232                    this.child(
233                        div()
234                            .when(cfg!(test), |this| {
235                                this.debug_selector(|| "switch-label".into())
236                            })
237                            .line_height(bg_height)
238                            .child(label)
239                            .map(|this| match self.size {
240                                Size::XSmall | Size::Small => this.text_sm(),
241                                _ => this.text_base(),
242                            }),
243                    )
244                }),
245        )
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use std::{cell::Cell, rc::Rc};
252
253    use gpui::{
254        Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
255        StatefulInteractiveElement as _, TestAppContext, VisualTestContext, point,
256    };
257
258    use super::*;
259
260    #[test]
261    fn an_explicit_accessibility_label_replaces_the_visible_one() {
262        let plain = Switch::new("wifi").label("Wi-Fi");
263        assert_eq!(plain.accessibility_label, None);
264        assert!(matches!(
265            &plain.label,
266            Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
267        ));
268
269        let named = Switch::new("wifi")
270            .label("Wi-Fi")
271            .accessibility_label("Toggle Wi-Fi");
272        assert_eq!(
273            named.accessibility_label.as_deref(),
274            Some("Toggle Wi-Fi"),
275            "an explicit name must win over the visible label"
276        );
277        assert!(
278            matches!(
279                &named.label,
280                Some(Text::String(label)) if label.as_ref() == "Wi-Fi"
281            ),
282            "and must not change what is drawn"
283        );
284    }
285
286    struct SwitchHarness {
287        disabled: bool,
288        toggles: Rc<Cell<usize>>,
289        parent_clicks: Rc<Cell<usize>>,
290    }
291
292    impl Render for SwitchHarness {
293        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
294            let toggles = self.toggles.clone();
295            let parent_clicks = self.parent_clicks.clone();
296            div()
297                .id("switch-parent")
298                .tab_group()
299                .size(px(100.))
300                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
301                .child(Switch::new("switch").disabled(self.disabled).on_click(
302                    move |checked, _, _| {
303                        assert!(*checked);
304                        toggles.set(toggles.get() + 1);
305                    },
306                ))
307        }
308    }
309
310    fn harness(
311        cx: &mut TestAppContext,
312        disabled: bool,
313    ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
314        cx.update(crate::init);
315        let toggles = Rc::new(Cell::new(0));
316        let parent_clicks = Rc::new(Cell::new(0));
317        let (_, cx) = cx.add_window_view({
318            let toggles = toggles.clone();
319            let parent_clicks = parent_clicks.clone();
320            move |_, _| SwitchHarness {
321                disabled,
322                toggles,
323                parent_clicks,
324            }
325        });
326        cx.update(|window, cx| window.draw(cx).clear(cx));
327        (cx, toggles, parent_clicks)
328    }
329
330    fn activate_key(cx: &mut VisualTestContext, key: &str) {
331        let keystroke = Keystroke::parse(key).unwrap();
332        cx.simulate_event(KeyDownEvent {
333            keystroke: keystroke.clone(),
334            is_held: false,
335            prefer_character_input: false,
336        });
337        cx.simulate_event(KeyUpEvent { keystroke });
338    }
339
340    #[gpui::test]
341    fn canonical_pointer_activation_fires_once_and_focuses(cx: &mut TestAppContext) {
342        let (cx, toggles, _) = harness(cx, false);
343        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
344
345        assert_eq!(toggles.get(), 1);
346        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
347    }
348
349    #[gpui::test]
350    fn canonical_switch_supports_tab_enter_and_space(cx: &mut TestAppContext) {
351        let (cx, toggles, _) = harness(cx, false);
352        cx.update(|window, cx| window.focus_next(cx));
353        cx.update(|window, cx| assert!(window.focused(cx).is_some()));
354
355        activate_key(cx, "enter");
356        activate_key(cx, "space");
357
358        assert_eq!(toggles.get(), 2);
359    }
360
361    #[gpui::test]
362    fn canonical_disabled_switch_is_inert_and_blocks_parent(cx: &mut TestAppContext) {
363        let (cx, toggles, parent_clicks) = harness(cx, true);
364        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
365
366        assert_eq!(toggles.get(), 0);
367        assert_eq!(parent_clicks.get(), 0);
368        cx.update(|window, cx| assert!(window.focused(cx).is_none()));
369    }
370
371    #[gpui::test]
372    fn label_prepaints_with_the_base_switch_content(cx: &mut TestAppContext) {
373        struct LabelHarness;
374
375        impl Render for LabelHarness {
376            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
377                div()
378                    .debug_selector(|| "labeled-switch".into())
379                    .child(Switch::new("switch").label("Airplane mode"))
380            }
381        }
382
383        cx.update(crate::init);
384        let (_, cx) = cx.add_window_view(|_, _| LabelHarness);
385        cx.update(|window, cx| window.draw(cx).clear(cx));
386
387        let bounds = cx
388            .debug_bounds("labeled-switch")
389            .expect("the complete labeled Switch must participate in prepaint");
390        assert!(bounds.size.width > px(36.));
391        let bar = cx
392            .debug_bounds("switch-bar")
393            .expect("the Switch bar must participate in prepaint");
394        let label = cx
395            .debug_bounds("switch-label")
396            .expect("the Switch label must participate in prepaint");
397        assert_eq!(bar.origin.y, label.origin.y);
398    }
399}