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 _, TestSupportExt 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            .test_support()
354            .role(Role::Switch)
355            .aria_toggled(if checked {
356                Toggled::True
357            } else {
358                Toggled::False
359            })
360            .when_some(self.accessibility_label, |this, label| {
361                this.aria_label(label)
362            })
363            .when(!disabled, |this| {
364                this.track_focus(
365                    &focus_handle
366                        .tab_index(self.tab_index)
367                        .tab_stop(self.tab_stop),
368                )
369            })
370            .when(disabled, |this| {
371                this.on_mouse_down(MouseButton::Left, |_, _, cx| {
372                    cx.stop_propagation();
373                })
374            })
375            .when_some(
376                (!disabled).then_some(self.on_change).flatten(),
377                |this, on_change| {
378                    this.on_click(move |event, window, cx| {
379                        on_change(!checked, event, window, cx);
380                    })
381                },
382            )
383            .children(self.children)
384            .refine_style(&style)
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::{
392        cell::Cell,
393        rc::Rc,
394        sync::{Arc, Mutex},
395    };
396
397    use gpui::{
398        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
399        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
400    };
401
402    #[test]
403    fn track_projects_checked_disabled_and_instance_style_priority() {
404        let normal_color = gpui::hsla(0.0, 0.0, 0.2, 1.0);
405        let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
406        let disabled_color = gpui::hsla(0.6, 0.7, 0.5, 0.5);
407        let track = |checked, disabled| {
408            SwitchTrack::new(("track", usize::from(checked) * 2 + usize::from(disabled)))
409                .checked(checked)
410                .disabled(disabled)
411                .when(!checked, |this| this.bg(normal_color))
412                .styles(|styles| {
413                    styles
414                        .checked(|style| style.bg(checked_color))
415                        .disabled(|style| style.when(checked, |style| style.bg(disabled_color)))
416                })
417        };
418
419        assert_eq!(
420            track(false, false).resolved_style().background,
421            Some(normal_color.into())
422        );
423        assert_eq!(
424            track(false, true).resolved_style().background,
425            Some(normal_color.into())
426        );
427        assert_eq!(
428            track(true, false).resolved_style().background,
429            Some(checked_color.into())
430        );
431        assert_eq!(
432            track(true, true).resolved_style().background,
433            Some(disabled_color.into())
434        );
435        // Semantic states layer over the instance chain, so an instance
436        // background does not defeat the disabled state.
437        assert_eq!(
438            track(true, true)
439                .bg(normal_color)
440                .resolved_style()
441                .background,
442            Some(disabled_color.into())
443        );
444    }
445
446    #[test]
447    fn track_identity_is_scoped_to_its_switch() {
448        fn track(switch_id: &'static str) -> SwitchTrack {
449            SwitchTrack::new((ElementId::from(switch_id), "track"))
450        }
451
452        let first = track("first-switch");
453        let second = track("second-switch");
454
455        assert_eq!(
456            gpui::Element::id(&first.base),
457            Some((ElementId::from("first-switch"), "track").into())
458        );
459        assert_eq!(
460            gpui::Element::id(&second.base),
461            Some((ElementId::from("second-switch"), "track").into())
462        );
463        assert_ne!(
464            gpui::Element::id(&first.base),
465            gpui::Element::id(&second.base)
466        );
467    }
468
469    #[test]
470    fn thumb_projects_checked_disabled_and_instance_style_priority() {
471        let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
472        let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5);
473        let thumb = |checked, disabled| {
474            SwitchThumb::new(checked)
475                .disabled(disabled)
476                .styles(|styles| {
477                    styles
478                        .checked(|style| style.bg(checked_color))
479                        .disabled(|style| style.bg(disabled_color))
480                })
481        };
482
483        assert_eq!(
484            thumb(true, false).resolved_style().background,
485            Some(checked_color.into())
486        );
487        assert_eq!(
488            thumb(true, true).resolved_style().background,
489            Some(disabled_color.into())
490        );
491        assert_eq!(
492            thumb(true, true)
493                .bg(checked_color)
494                .resolved_style()
495                .background,
496            Some(disabled_color.into())
497        );
498    }
499
500    struct SwitchHarness {
501        checked: bool,
502        disabled: bool,
503        toggles: Rc<Cell<usize>>,
504        keyboard_events: Rc<Cell<usize>>,
505        last_value: Rc<Cell<bool>>,
506        parent_clicks: Rc<Cell<usize>>,
507    }
508
509    impl Render for SwitchHarness {
510        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
511            let toggles = self.toggles.clone();
512            let keyboard_events = self.keyboard_events.clone();
513            let last_value = self.last_value.clone();
514            let parent_clicks = self.parent_clicks.clone();
515
516            div()
517                .id("switch-parent")
518                .tab_group()
519                .size(px(100.))
520                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
521                .child(
522                    Switch::new("switch")
523                        .checked(self.checked)
524                        .disabled(self.disabled)
525                        .size_full()
526                        .on_change(move |value, event, _, _| {
527                            toggles.set(toggles.get() + 1);
528                            last_value.set(value);
529                            if matches!(event, ClickEvent::Keyboard(_)) {
530                                keyboard_events.set(keyboard_events.get() + 1);
531                            }
532                        }),
533                )
534        }
535    }
536
537    fn harness(
538        cx: &mut TestAppContext,
539        checked: bool,
540        disabled: bool,
541    ) -> (
542        &mut VisualTestContext,
543        Rc<Cell<usize>>,
544        Rc<Cell<usize>>,
545        Rc<Cell<bool>>,
546        Rc<Cell<usize>>,
547    ) {
548        let toggles = Rc::new(Cell::new(0));
549        let keyboard_events = Rc::new(Cell::new(0));
550        let last_value = Rc::new(Cell::new(checked));
551        let parent_clicks = Rc::new(Cell::new(0));
552        let (_, cx) = cx.add_window_view({
553            let toggles = toggles.clone();
554            let keyboard_events = keyboard_events.clone();
555            let last_value = last_value.clone();
556            let parent_clicks = parent_clicks.clone();
557            move |_, _| SwitchHarness {
558                checked,
559                disabled,
560                toggles,
561                keyboard_events,
562                last_value,
563                parent_clicks,
564            }
565        });
566        cx.update(|window, cx| window.draw(cx).clear(cx));
567        (cx, toggles, keyboard_events, last_value, parent_clicks)
568    }
569
570    fn activate_key(cx: &mut VisualTestContext, key: &str) {
571        let keystroke = Keystroke::parse(key).unwrap();
572        cx.simulate_event(KeyDownEvent {
573            keystroke: keystroke.clone(),
574            is_held: false,
575            prefer_character_input: false,
576        });
577        cx.simulate_event(KeyUpEvent { keystroke });
578    }
579
580    #[gpui::test]
581    fn pointer_reports_the_next_value_once(cx: &mut TestAppContext) {
582        let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
583
584        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
585
586        assert_eq!(toggles.get(), 1);
587        assert_eq!(keyboard_events.get(), 0);
588        assert!(last_value.get());
589    }
590
591    #[gpui::test]
592    fn enter_and_space_report_one_native_keyboard_activation_each(cx: &mut TestAppContext) {
593        let (cx, toggles, keyboard_events, last_value, _) = harness(cx, false, false);
594        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
595        toggles.set(0);
596        cx.update(|window, cx| {
597            assert!(window.focused(cx).is_some());
598            window.draw(cx).clear(cx);
599        });
600
601        activate_key(cx, "enter");
602        activate_key(cx, "space");
603
604        assert_eq!(toggles.get(), 2);
605        assert_eq!(keyboard_events.get(), 2);
606        assert!(last_value.get());
607    }
608
609    #[gpui::test]
610    fn disabled_switch_is_inert_and_blocks_parent_activation(cx: &mut TestAppContext) {
611        let (cx, toggles, _, _, parent_clicks) = harness(cx, false, true);
612
613        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
614        activate_key(cx, "enter");
615        activate_key(cx, "space");
616
617        assert_eq!(toggles.get(), 0);
618        assert_eq!(parent_clicks.get(), 0);
619    }
620
621    #[test]
622    fn application_owned_state_styles_are_available() {
623        let _ = Switch::new("states")
624            .styles(|styles| {
625                styles
626                    .checked(|style| style.opacity(0.8))
627                    .disabled(|style| style.opacity(0.5))
628            })
629            .hover(|style| style.opacity(0.9))
630            .active(|style| style.opacity(0.8))
631            .focus_visible(|style| style.opacity(0.7));
632    }
633
634    #[test]
635    fn semantic_root_styles_follow_switch_priority() {
636        let styled = |switch: Switch| {
637            switch.styles(|styles| {
638                styles
639                    .checked(|style| style.opacity(0.8))
640                    .disabled(|style| style.opacity(0.5))
641            })
642        };
643
644        assert_eq!(styled(Switch::new("normal")).resolved_style().opacity, None);
645        assert_eq!(
646            styled(Switch::new("checked").checked(true))
647                .resolved_style()
648                .opacity,
649            Some(0.8)
650        );
651        assert_eq!(
652            styled(Switch::new("checked-disabled").checked(true).disabled(true))
653                .resolved_style()
654                .opacity,
655            Some(0.5)
656        );
657        assert_eq!(
658            styled(
659                Switch::new("state-over-instance")
660                    .checked(true)
661                    .disabled(true)
662                    .opacity(0.9),
663            )
664            .resolved_style()
665            .opacity,
666            Some(0.5)
667        );
668    }
669
670    #[gpui::test]
671    fn accessibility_exposes_switch_role_label_and_toggled_state(cx: &mut TestAppContext) {
672        type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
673
674        struct A11yProbe {
675            captured: Captured,
676        }
677
678        impl Render for A11yProbe {
679            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
680                let captured = self.captured.clone();
681                canvas(
682                    move |_, window, cx| {
683                        let mut info = |switch: Switch| {
684                            let mut node = accesskit::Node::new(Role::Switch);
685                            switch
686                                .render(window, cx)
687                                .into_element()
688                                .write_a11y_info(&mut node);
689                            node
690                        };
691                        let enabled = info(
692                            Switch::new("enabled")
693                                .checked(true)
694                                .accessibility_label("Airplane mode")
695                                .on_change(|_, _, _, _| {}),
696                        );
697                        let disabled = info(
698                            Switch::new("disabled")
699                                .checked(false)
700                                .disabled(true)
701                                .accessibility_label("Airplane mode")
702                                .on_change(|_, _, _, _| {}),
703                        );
704                        *captured.lock().unwrap() = Some((enabled, disabled));
705                    },
706                    |_, _, _, _| {},
707                )
708            }
709        }
710
711        let captured: Captured = Arc::new(Mutex::new(None));
712        let result = captured.clone();
713        let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
714        cx.update(|window, cx| window.draw(cx).clear(cx));
715        let (enabled, disabled) = result.lock().unwrap().take().unwrap();
716
717        assert_eq!(enabled.role(), Role::Switch);
718        assert_eq!(enabled.label(), Some("Airplane mode"));
719        assert_eq!(enabled.toggled(), Some(Toggled::True));
720        assert!(enabled.supports_action(accesskit::Action::Click));
721
722        assert_eq!(disabled.role(), Role::Switch);
723        assert_eq!(disabled.toggled(), Some(Toggled::False));
724        assert!(!disabled.supports_action(accesskit::Action::Click));
725        // GPUI currently has no aria-disabled setter. Keep the limitation
726        // explicit instead of claiming an AccessKit disabled state.
727        assert!(!disabled.is_disabled());
728    }
729}