Skip to main content

gpui_base/
switch.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
5    IntoElement, MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6    Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Toggled, Window, div,
7    prelude::FluentBuilder as _,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _};
12
13type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
14
15/// An unstyled binary control that owns switch interaction and semantics.
16///
17/// The checked value is controlled by the application. Activation reports the
18/// next value through [`Switch::on_change`]; the application must render that
19/// value back through [`Switch::checked`]. Children and all visual states remain
20/// application-owned.
21#[derive(IntoElement)]
22pub struct Switch {
23    id: ElementId,
24    base: Stateful<Div>,
25    style: StyleRefinement,
26    semantic_styles: SwitchStyles,
27    checked: bool,
28    disabled: bool,
29    children: SmallVec<[AnyElement; 2]>,
30    on_change: Option<ChangeHandler>,
31    accessibility_label: Option<SharedString>,
32    tab_index: isize,
33    tab_stop: bool,
34}
35
36/// Semantic root styles supported by [`Switch`].
37#[derive(Default)]
38pub struct SwitchStyles {
39    checked: StyleRefinement,
40    disabled: StyleRefinement,
41}
42
43impl SwitchStyles {
44    pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
45        self.checked
46            .refine(&build(StateStyle::default()).into_refinement());
47        self
48    }
49
50    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
51        self.disabled
52            .refine(&build(StateStyle::default()).into_refinement());
53        self
54    }
55}
56
57/// An unstyled switch track with typed checked and disabled state projection.
58#[derive(IntoElement)]
59pub struct SwitchTrack {
60    base: Stateful<Div>,
61    style: StyleRefinement,
62    semantic_styles: SwitchTrackStyles,
63    checked: bool,
64    disabled: bool,
65    children: SmallVec<[AnyElement; 1]>,
66}
67
68/// Semantic styles supported by [`SwitchTrack`].
69#[derive(Default)]
70pub struct SwitchTrackStyles {
71    checked: StyleRefinement,
72    disabled: StyleRefinement,
73}
74
75impl SwitchTrackStyles {
76    pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
77        self.checked
78            .refine(&build(StateStyle::default()).into_refinement());
79        self
80    }
81
82    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
83        self.disabled
84            .refine(&build(StateStyle::default()).into_refinement());
85        self
86    }
87}
88
89impl SwitchTrack {
90    pub fn new(id: impl Into<ElementId>) -> Self {
91        Self {
92            base: div().id(id),
93            style: StyleRefinement::default(),
94            semantic_styles: SwitchTrackStyles::default(),
95            checked: false,
96            disabled: false,
97            children: SmallVec::new(),
98        }
99    }
100
101    pub fn checked(mut self, checked: bool) -> Self {
102        self.checked = checked;
103        self
104    }
105
106    pub fn disabled(mut self, disabled: bool) -> Self {
107        self.disabled = disabled;
108        self
109    }
110
111    pub fn styles(mut self, build: impl FnOnce(SwitchTrackStyles) -> SwitchTrackStyles) -> Self {
112        self.semantic_styles = build(self.semantic_styles);
113        self
114    }
115
116    fn resolved_style(&self) -> StyleRefinement {
117        crate::state_style::resolve_style(
118            &self.style,
119            [
120                self.checked.then_some(&self.semantic_styles.checked),
121                self.disabled.then_some(&self.semantic_styles.disabled),
122            ]
123            .into_iter()
124            .flatten(),
125        )
126    }
127}
128
129impl Styled for SwitchTrack {
130    fn style(&mut self) -> &mut StyleRefinement {
131        &mut self.style
132    }
133}
134
135impl ParentElement for SwitchTrack {
136    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
137        self.children.extend(elements);
138    }
139}
140
141impl InteractiveElement for SwitchTrack {
142    fn interactivity(&mut self) -> &mut Interactivity {
143        self.base.interactivity()
144    }
145}
146
147impl StatefulInteractiveElement for SwitchTrack {}
148
149impl RenderOnce for SwitchTrack {
150    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
151        let style = self.resolved_style();
152        self.base.children(self.children).refine_style(&style)
153    }
154}
155
156/// An unstyled switch thumb with typed checked and disabled state projection.
157#[derive(IntoElement)]
158pub struct SwitchThumb {
159    base: Div,
160    style: StyleRefinement,
161    semantic_styles: SwitchThumbStyles,
162    checked: bool,
163    disabled: bool,
164    children: SmallVec<[AnyElement; 1]>,
165}
166
167/// Semantic styles supported by [`SwitchThumb`].
168#[derive(Default)]
169pub struct SwitchThumbStyles {
170    checked: StyleRefinement,
171    disabled: StyleRefinement,
172}
173
174impl SwitchThumbStyles {
175    pub fn checked(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
176        self.checked
177            .refine(&build(StateStyle::default()).into_refinement());
178        self
179    }
180
181    pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
182        self.disabled
183            .refine(&build(StateStyle::default()).into_refinement());
184        self
185    }
186}
187
188impl SwitchThumb {
189    pub fn new(checked: bool) -> Self {
190        Self {
191            base: div(),
192            style: StyleRefinement::default(),
193            semantic_styles: SwitchThumbStyles::default(),
194            checked,
195            disabled: false,
196            children: SmallVec::new(),
197        }
198    }
199
200    pub fn disabled(mut self, disabled: bool) -> Self {
201        self.disabled = disabled;
202        self
203    }
204
205    pub fn styles(mut self, build: impl FnOnce(SwitchThumbStyles) -> SwitchThumbStyles) -> Self {
206        self.semantic_styles = build(self.semantic_styles);
207        self
208    }
209
210    fn resolved_style(&self) -> StyleRefinement {
211        crate::state_style::resolve_style(
212            &self.style,
213            [
214                self.checked.then_some(&self.semantic_styles.checked),
215                self.disabled.then_some(&self.semantic_styles.disabled),
216            ]
217            .into_iter()
218            .flatten(),
219        )
220    }
221}
222
223impl Styled for SwitchThumb {
224    fn style(&mut self) -> &mut StyleRefinement {
225        &mut self.style
226    }
227}
228
229impl ParentElement for SwitchThumb {
230    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
231        self.children.extend(elements);
232    }
233}
234
235impl RenderOnce for SwitchThumb {
236    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
237        let style = self.resolved_style();
238        self.base.children(self.children).refine_style(&style)
239    }
240}
241
242impl Switch {
243    pub fn new(id: impl Into<ElementId>) -> Self {
244        let id = id.into();
245        Self {
246            base: div().id(id.clone()),
247            id,
248            style: StyleRefinement::default(),
249            semantic_styles: SwitchStyles::default(),
250            checked: false,
251            disabled: false,
252            children: SmallVec::new(),
253            on_change: None,
254            accessibility_label: None,
255            tab_index: 0,
256            tab_stop: true,
257        }
258    }
259
260    /// Sets the application-controlled checked value.
261    pub fn checked(mut self, checked: bool) -> Self {
262        self.checked = checked;
263        self
264    }
265
266    /// Sets whether pointer and keyboard activation are ignored.
267    pub fn disabled(mut self, disabled: bool) -> Self {
268        self.disabled = disabled;
269        self
270    }
271
272    /// Configures application-owned styles for the switch's semantic states.
273    pub fn styles(mut self, build: impl FnOnce(SwitchStyles) -> SwitchStyles) -> Self {
274        self.semantic_styles = build(self.semantic_styles);
275        self
276    }
277
278    fn resolved_style(&self) -> StyleRefinement {
279        crate::state_style::resolve_style(
280            &self.style,
281            [
282                self.checked.then_some(&self.semantic_styles.checked),
283                self.disabled.then_some(&self.semantic_styles.disabled),
284            ]
285            .into_iter()
286            .flatten(),
287        )
288    }
289
290    /// Handles activation with the next checked value and its input event.
291    pub fn on_change(
292        mut self,
293        handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
294    ) -> Self {
295        self.on_change = Some(Rc::new(handler));
296        self
297    }
298
299    /// Sets the name exposed to accessibility clients.
300    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
301        self.accessibility_label = Some(label.into());
302        self
303    }
304
305    /// Sets the focus traversal index. Use this within a GPUI tab group.
306    pub fn tab_index(mut self, tab_index: isize) -> Self {
307        self.tab_index = tab_index;
308        self
309    }
310
311    /// Sets whether the switch participates in keyboard focus traversal.
312    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
313        self.tab_stop = tab_stop;
314        self
315    }
316
317    fn focus_handle(&self, window: &mut Window, cx: &mut App) -> FocusHandle {
318        window
319            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
320            .read(cx)
321            .clone()
322    }
323}
324
325impl Styled for Switch {
326    fn style(&mut self) -> &mut StyleRefinement {
327        &mut self.style
328    }
329}
330
331impl ParentElement for Switch {
332    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
333        self.children.extend(elements);
334    }
335}
336
337impl InteractiveElement for Switch {
338    fn interactivity(&mut self) -> &mut Interactivity {
339        self.base.interactivity()
340    }
341}
342
343impl StatefulInteractiveElement for Switch {}
344
345impl RenderOnce for Switch {
346    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
347        let focus_handle = self.focus_handle(window, cx);
348        let checked = self.checked;
349        let disabled = self.disabled;
350        let style = self.resolved_style();
351
352        self.base
353            .role(Role::Switch)
354            .aria_toggled(if checked {
355                Toggled::True
356            } else {
357                Toggled::False
358            })
359            .when_some(self.accessibility_label, |this, label| {
360                this.aria_label(label)
361            })
362            .when(!disabled, |this| {
363                this.track_focus(
364                    &focus_handle
365                        .tab_index(self.tab_index)
366                        .tab_stop(self.tab_stop),
367                )
368            })
369            .when(disabled, |this| {
370                this.on_mouse_down(MouseButton::Left, |_, _, cx| {
371                    cx.stop_propagation();
372                })
373            })
374            .when_some(
375                (!disabled).then_some(self.on_change).flatten(),
376                |this, on_change| {
377                    this.on_click(move |event, window, cx| {
378                        on_change(!checked, event, window, cx);
379                    })
380                },
381            )
382            .children(self.children)
383            .refine_style(&style)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use std::{
391        cell::Cell,
392        rc::Rc,
393        sync::{Arc, Mutex},
394    };
395
396    use gpui::{
397        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
398        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
399    };
400
401    #[test]
402    fn track_projects_checked_disabled_and_instance_style_priority() {
403        let normal_color = gpui::hsla(0.0, 0.0, 0.2, 1.0);
404        let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
405        let disabled_color = gpui::hsla(0.6, 0.7, 0.5, 0.5);
406        let track = |checked, disabled| {
407            SwitchTrack::new(("track", usize::from(checked) * 2 + usize::from(disabled)))
408                .checked(checked)
409                .disabled(disabled)
410                .when(!checked, |this| this.bg(normal_color))
411                .styles(|styles| {
412                    styles
413                        .checked(|style| style.bg(checked_color))
414                        .disabled(|style| style.when(checked, |style| style.bg(disabled_color)))
415                })
416        };
417
418        assert_eq!(
419            track(false, false).resolved_style().background,
420            Some(normal_color.into())
421        );
422        assert_eq!(
423            track(false, true).resolved_style().background,
424            Some(normal_color.into())
425        );
426        assert_eq!(
427            track(true, false).resolved_style().background,
428            Some(checked_color.into())
429        );
430        assert_eq!(
431            track(true, true).resolved_style().background,
432            Some(disabled_color.into())
433        );
434        // Semantic states layer over the instance chain, so an instance
435        // background does not defeat the disabled state.
436        assert_eq!(
437            track(true, true)
438                .bg(normal_color)
439                .resolved_style()
440                .background,
441            Some(disabled_color.into())
442        );
443    }
444
445    #[test]
446    fn track_identity_is_scoped_to_its_switch() {
447        fn track(switch_id: &'static str) -> SwitchTrack {
448            SwitchTrack::new((ElementId::from(switch_id), "track"))
449        }
450
451        let first = track("first-switch");
452        let second = track("second-switch");
453
454        assert_eq!(
455            gpui::Element::id(&first.base),
456            Some((ElementId::from("first-switch"), "track").into())
457        );
458        assert_eq!(
459            gpui::Element::id(&second.base),
460            Some((ElementId::from("second-switch"), "track").into())
461        );
462        assert_ne!(
463            gpui::Element::id(&first.base),
464            gpui::Element::id(&second.base)
465        );
466    }
467
468    #[test]
469    fn thumb_projects_checked_disabled_and_instance_style_priority() {
470        let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
471        let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5);
472        let thumb = |checked, disabled| {
473            SwitchThumb::new(checked)
474                .disabled(disabled)
475                .styles(|styles| {
476                    styles
477                        .checked(|style| style.bg(checked_color))
478                        .disabled(|style| style.bg(disabled_color))
479                })
480        };
481
482        assert_eq!(
483            thumb(true, false).resolved_style().background,
484            Some(checked_color.into())
485        );
486        assert_eq!(
487            thumb(true, true).resolved_style().background,
488            Some(disabled_color.into())
489        );
490        assert_eq!(
491            thumb(true, true)
492                .bg(checked_color)
493                .resolved_style()
494                .background,
495            Some(disabled_color.into())
496        );
497    }
498
499    struct SwitchHarness {
500        checked: bool,
501        disabled: bool,
502        toggles: Rc<Cell<usize>>,
503        keyboard_events: Rc<Cell<usize>>,
504        last_value: Rc<Cell<bool>>,
505        parent_clicks: Rc<Cell<usize>>,
506    }
507
508    impl Render for SwitchHarness {
509        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
510            let toggles = self.toggles.clone();
511            let keyboard_events = self.keyboard_events.clone();
512            let last_value = self.last_value.clone();
513            let parent_clicks = self.parent_clicks.clone();
514
515            div()
516                .id("switch-parent")
517                .tab_group()
518                .size(px(100.))
519                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
520                .child(
521                    Switch::new("switch")
522                        .checked(self.checked)
523                        .disabled(self.disabled)
524                        .size_full()
525                        .on_change(move |value, event, _, _| {
526                            toggles.set(toggles.get() + 1);
527                            last_value.set(value);
528                            if matches!(event, ClickEvent::Keyboard(_)) {
529                                keyboard_events.set(keyboard_events.get() + 1);
530                            }
531                        }),
532                )
533        }
534    }
535
536    fn harness(
537        cx: &mut TestAppContext,
538        checked: bool,
539        disabled: bool,
540    ) -> (
541        &mut VisualTestContext,
542        Rc<Cell<usize>>,
543        Rc<Cell<usize>>,
544        Rc<Cell<bool>>,
545        Rc<Cell<usize>>,
546    ) {
547        let toggles = Rc::new(Cell::new(0));
548        let keyboard_events = Rc::new(Cell::new(0));
549        let last_value = Rc::new(Cell::new(checked));
550        let parent_clicks = Rc::new(Cell::new(0));
551        let (_, cx) = cx.add_window_view({
552            let toggles = toggles.clone();
553            let keyboard_events = keyboard_events.clone();
554            let last_value = last_value.clone();
555            let parent_clicks = parent_clicks.clone();
556            move |_, _| SwitchHarness {
557                checked,
558                disabled,
559                toggles,
560                keyboard_events,
561                last_value,
562                parent_clicks,
563            }
564        });
565        cx.update(|window, cx| window.draw(cx).clear(cx));
566        (cx, toggles, keyboard_events, last_value, parent_clicks)
567    }
568
569    fn activate_key(cx: &mut VisualTestContext, key: &str) {
570        let keystroke = Keystroke::parse(key).unwrap();
571        cx.simulate_event(KeyDownEvent {
572            keystroke: keystroke.clone(),
573            is_held: false,
574            prefer_character_input: false,
575        });
576        cx.simulate_event(KeyUpEvent { keystroke });
577    }
578
579    #[gpui::test]
580    fn pointer_reports_the_next_value_once(cx: &mut TestAppContext) {
581        let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
582
583        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
584
585        assert_eq!(toggles.get(), 1);
586        assert_eq!(keyboard_events.get(), 0);
587        assert!(last_value.get());
588    }
589
590    #[gpui::test]
591    fn enter_and_space_report_one_native_keyboard_activation_each(cx: &mut TestAppContext) {
592        let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
593        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
594        toggles.set(0);
595        cx.update(|window, cx| {
596            assert!(window.focused(cx).is_some());
597            window.draw(cx).clear(cx);
598        });
599
600        activate_key(cx, "enter");
601        activate_key(cx, "space");
602
603        assert_eq!(toggles.get(), 2);
604        assert_eq!(keyboard_events.get(), 2);
605        assert!(last_value.get());
606    }
607
608    #[gpui::test]
609    fn disabled_switch_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
610        let (cx, toggles, _, _, parent_clicks) = harness(cx, false, true);
611
612        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
613        activate_key(cx, "enter");
614        activate_key(cx, "space");
615
616        assert_eq!(toggles.get(), 0);
617        assert_eq!(parent_clicks.get(), 0);
618    }
619
620    #[test]
621    fn application_owned_state_styles_are_available() {
622        let _ = Switch::new("states")
623            .styles(|styles| {
624                styles
625                    .checked(|style| style.opacity(0.8))
626                    .disabled(|style| style.opacity(0.5))
627            })
628            .hover(|style| style.opacity(0.9))
629            .active(|style| style.opacity(0.8))
630            .focus_visible(|style| style.opacity(0.7));
631    }
632
633    #[test]
634    fn semantic_root_styles_follow_switch_priority() {
635        let styled = |switch: Switch| {
636            switch.styles(|styles| {
637                styles
638                    .checked(|style| style.opacity(0.8))
639                    .disabled(|style| style.opacity(0.5))
640            })
641        };
642
643        assert_eq!(styled(Switch::new("normal")).resolved_style().opacity, None);
644        assert_eq!(
645            styled(Switch::new("checked").checked(true))
646                .resolved_style()
647                .opacity,
648            Some(0.8)
649        );
650        assert_eq!(
651            styled(Switch::new("checked-disabled").checked(true).disabled(true))
652                .resolved_style()
653                .opacity,
654            Some(0.5)
655        );
656        assert_eq!(
657            styled(
658                Switch::new("state-over-instance")
659                    .checked(true)
660                    .disabled(true)
661                    .opacity(0.9),
662            )
663            .resolved_style()
664            .opacity,
665            Some(0.5)
666        );
667    }
668
669    #[gpui::test]
670    fn accessibility_exposes_switch_role_label_and_toggled_state(cx: &mut TestAppContext) {
671        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
672
673        struct A11yProbe {
674            captured: Captured,
675        }
676
677        impl Render for A11yProbe {
678            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
679                let captured = self.captured.clone();
680                canvas(
681                    move |_, window, cx| {
682                        let mut info = |switch: Switch| {
683                            let mut node = accesskit::Node::new(Role::Switch);
684                            switch
685                                .render(window, cx)
686                                .into_element()
687                                .write_a11y_info(&mut node);
688                            node
689                        };
690                        let enabled = info(
691                            Switch::new("enabled")
692                                .checked(true)
693                                .accessibility_label("Airplane mode")
694                                .on_change(|_, _, _, _| {}),
695                        );
696                        let disabled = info(
697                            Switch::new("disabled")
698                                .checked(false)
699                                .disabled(true)
700                                .accessibility_label("Airplane mode")
701                                .on_change(|_, _, _, _| {}),
702                        );
703                        *captured.lock().unwrap() = Some((enabled, disabled));
704                    },
705                    |_, _, _, _| {},
706                )
707            }
708        }
709
710        let captured: Captured = Arc::new(Mutex::new(None));
711        let result = captured.clone();
712        let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
713        cx.update(|window, cx| window.draw(cx).clear(cx));
714        let (enabled, disabled) = result.lock().unwrap().take().unwrap();
715
716        assert_eq!(enabled.role(), Role::Switch);
717        assert_eq!(enabled.label(), Some("Airplane mode"));
718        assert_eq!(enabled.toggled(), Some(Toggled::True));
719        assert!(enabled.supports_action(accesskit::Action::Click));
720
721        assert_eq!(disabled.role(), Role::Switch);
722        assert_eq!(disabled.toggled(), Some(Toggled::False));
723        assert!(!disabled.supports_action(accesskit::Action::Click));
724        // GPUI currently has no aria-disabled setter. Keep the limitation
725        // explicit instead of claiming an AccessKit disabled state.
726        assert!(!disabled.is_disabled());
727    }
728}