Skip to main content

gpui_base/
checkbox.rs

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