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