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