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 _};
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            .when_some(self.role.resolve(|| Role::CheckBox), |this, role| {
374                this.role(role)
375            })
376            .aria_toggled(self.state.toggled())
377            .when_some(self.accessibility_label, |this, label| {
378                this.aria_label(label)
379            })
380            .when(!disabled, |this| {
381                this.track_focus(
382                    &focus_handle
383                        .tab_index(self.tab_index)
384                        .tab_stop(self.tab_stop),
385                )
386            })
387            .when_some(
388                (!disabled).then_some(on_change).flatten(),
389                |this, on_change| {
390                    this.on_click(move |event, window, cx| {
391                        on_change(next_state, event, window, cx);
392                    })
393                },
394            )
395            .children(self.children)
396            .refine_style(&style)
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use std::{
404        cell::{Cell, RefCell},
405        rc::Rc,
406        sync::{Arc, Mutex},
407    };
408
409    use gpui::{
410        Context, Element as _, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render,
411        TestAppContext, VisualTestContext, accesskit, canvas, point, px,
412    };
413
414    #[test]
415    fn indicator_projects_state_styles_over_the_instance_layer() {
416        let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
417        let disabled_color = gpui::hsla(0.1, 0.2, 0.3, 0.5);
418
419        let indicator = |state, disabled| {
420            CheckboxIndicator::new()
421                .state(state)
422                .disabled(disabled)
423                .styles(|styles| {
424                    styles
425                        .checked(|style| style.border_color(checked_color))
426                        .indeterminate(|style| style.opacity(0.7))
427                        .disabled(|style| style.border_color(disabled_color))
428                })
429        };
430
431        assert_eq!(
432            indicator(CheckboxState::Checked, false)
433                .resolved_style()
434                .border_color,
435            Some(checked_color)
436        );
437        assert_eq!(
438            indicator(CheckboxState::Checked, true)
439                .resolved_style()
440                .border_color,
441            Some(disabled_color)
442        );
443        assert_eq!(
444            indicator(CheckboxState::Indeterminate, false)
445                .resolved_style()
446                .opacity,
447            Some(0.7)
448        );
449        assert_eq!(
450            indicator(CheckboxState::Checked, true)
451                .border_color(checked_color)
452                .resolved_style()
453                .border_color,
454            Some(disabled_color)
455        );
456    }
457
458    struct CheckboxHarness {
459        state: CheckboxState,
460        disabled: bool,
461        changes: Rc<RefCell<Vec<CheckboxState>>>,
462        parent_clicks: Rc<Cell<usize>>,
463    }
464
465    impl Render for CheckboxHarness {
466        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
467            let changes = self.changes.clone();
468            let parent_clicks = self.parent_clicks.clone();
469            div()
470                .id("checkbox-parent")
471                .tab_group()
472                .size(px(100.))
473                .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
474                .child(
475                    Checkbox::new("checkbox")
476                        .state(self.state)
477                        .disabled(self.disabled)
478                        .size_full()
479                        .on_change(move |state, _, _, _| changes.borrow_mut().push(state)),
480                )
481        }
482    }
483
484    fn harness(
485        cx: &mut TestAppContext,
486        state: CheckboxState,
487        disabled: bool,
488    ) -> (
489        &mut VisualTestContext,
490        Rc<RefCell<Vec<CheckboxState>>>,
491        Rc<Cell<usize>>,
492    ) {
493        let changes = Rc::new(RefCell::new(Vec::new()));
494        let parent_clicks = Rc::new(Cell::new(0));
495        let (_, cx) = cx.add_window_view({
496            let changes = changes.clone();
497            let parent_clicks = parent_clicks.clone();
498            move |_, _| CheckboxHarness {
499                state,
500                disabled,
501                changes,
502                parent_clicks,
503            }
504        });
505        cx.update(|window, cx| window.draw(cx).clear(cx));
506        (cx, changes, parent_clicks)
507    }
508
509    #[gpui::test]
510    fn pointer_activation_emits_the_next_state_once(cx: &mut TestAppContext) {
511        let (cx, changes, _) = harness(cx, CheckboxState::Unchecked, false);
512        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
513        assert_eq!(&*changes.borrow(), &[CheckboxState::Checked]);
514    }
515
516    #[gpui::test]
517    fn indeterminate_activation_becomes_checked(cx: &mut TestAppContext) {
518        let (cx, changes, _) = harness(cx, CheckboxState::Indeterminate, false);
519        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
520        assert_eq!(&*changes.borrow(), &[CheckboxState::Checked]);
521    }
522
523    #[gpui::test]
524    fn enter_and_space_each_emit_once(cx: &mut TestAppContext) {
525        let (cx, changes, _) = harness(cx, CheckboxState::Checked, false);
526        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
527        changes.borrow_mut().clear();
528        cx.update(|window, cx| {
529            assert!(window.focused(cx).is_some());
530            window.draw(cx).clear(cx);
531        });
532
533        for key in ["enter", "space"] {
534            let keystroke = Keystroke::parse(key).unwrap();
535            cx.simulate_event(KeyDownEvent {
536                keystroke: keystroke.clone(),
537                is_held: false,
538                prefer_character_input: false,
539            });
540            cx.simulate_event(KeyUpEvent { keystroke });
541        }
542
543        assert_eq!(
544            &*changes.borrow(),
545            &[CheckboxState::Unchecked, CheckboxState::Unchecked]
546        );
547    }
548
549    #[gpui::test]
550    fn disabled_checkbox_is_inert_and_allows_pointer_events_to_bubble(cx: &mut TestAppContext) {
551        let (cx, changes, parent_clicks) = harness(cx, CheckboxState::Unchecked, true);
552        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
553        cx.update(|window, cx| window.focus_next(cx));
554        cx.simulate_keystrokes("enter space");
555        assert!(changes.borrow().is_empty());
556        assert_eq!(parent_clicks.get(), 1);
557    }
558
559    #[test]
560    fn controlled_state_and_styling_are_application_owned() {
561        let _ = Checkbox::new("states")
562            .checked(true)
563            .indeterminate(true)
564            .styles(|styles| {
565                styles
566                    .checked(|style| style.opacity(0.8))
567                    .indeterminate(|style| style.opacity(0.7))
568                    .disabled(|style| style.when(true, |style| style.opacity(0.5)))
569            })
570            .hover(|style| style.opacity(0.9))
571            .active(|style| style.opacity(0.8))
572            .focus_visible(|style| style.opacity(0.7));
573    }
574
575    #[test]
576    fn semantic_root_styles_follow_checkbox_priority() {
577        let styles = |checkbox: Checkbox| {
578            checkbox.styles(|styles| {
579                styles
580                    .checked(|style| style.opacity(0.8))
581                    .indeterminate(|style| style.opacity(0.7))
582                    .disabled(|style| style.opacity(0.5))
583            })
584        };
585
586        assert_eq!(
587            styles(Checkbox::new("normal")).resolved_style().opacity,
588            None
589        );
590        assert_eq!(
591            styles(Checkbox::new("checked").checked(true))
592                .resolved_style()
593                .opacity,
594            Some(0.8)
595        );
596        assert_eq!(
597            styles(Checkbox::new("indeterminate").indeterminate(true))
598                .resolved_style()
599                .opacity,
600            Some(0.7)
601        );
602        assert_eq!(
603            styles(Checkbox::new("disabled").disabled(true))
604                .resolved_style()
605                .opacity,
606            Some(0.5)
607        );
608        assert_eq!(
609            styles(
610                Checkbox::new("checked-disabled")
611                    .checked(true)
612                    .disabled(true),
613            )
614            .resolved_style()
615            .opacity,
616            Some(0.5)
617        );
618
619        let checked_color = gpui::hsla(0.6, 0.7, 0.5, 1.0);
620        let combined = Checkbox::new("combined")
621            .checked(true)
622            .disabled(true)
623            .styles(|styles| {
624                styles
625                    .checked(|style| style.border_color(checked_color))
626                    .disabled(|style| style.opacity(0.5))
627            })
628            .resolved_style();
629        assert_eq!(combined.border_color, Some(checked_color));
630        assert_eq!(combined.opacity, Some(0.5));
631
632        let state_over_instance = styles(
633            Checkbox::new("state-over-instance")
634                .checked(true)
635                .disabled(true)
636                .opacity(0.9),
637        );
638        assert_eq!(state_over_instance.resolved_style().opacity, Some(0.5));
639    }
640
641    #[gpui::test]
642    fn accessibility_exposes_role_label_and_all_toggle_states(cx: &mut TestAppContext) {
643        type Captured = Arc<Mutex<Option<[accesskit::Node; 4]>>>;
644
645        struct A11yProbe {
646            captured: Captured,
647        }
648
649        impl Render for A11yProbe {
650            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
651                let captured = self.captured.clone();
652                canvas(
653                    move |_, window, cx| {
654                        let mut info = |checkbox: Checkbox| {
655                            let mut node = accesskit::Node::new(Role::CheckBox);
656                            checkbox
657                                .render(window, cx)
658                                .into_element()
659                                .write_a11y_info(&mut node);
660                            node
661                        };
662                        *captured.lock().unwrap() = Some([
663                            info(
664                                Checkbox::new("unchecked")
665                                    .accessibility_label("Remember me")
666                                    .on_change(|_, _, _, _| {}),
667                            ),
668                            info(
669                                Checkbox::new("checked")
670                                    .checked(true)
671                                    .on_change(|_, _, _, _| {}),
672                            ),
673                            info(
674                                Checkbox::new("mixed")
675                                    .indeterminate(true)
676                                    .on_change(|_, _, _, _| {}),
677                            ),
678                            info(
679                                Checkbox::new("disabled")
680                                    .disabled(true)
681                                    .on_change(|_, _, _, _| {}),
682                            ),
683                        ]);
684                    },
685                    |_, _, _, _| {},
686                )
687            }
688        }
689
690        let captured: Captured = Arc::new(Mutex::new(None));
691        let result = captured.clone();
692        let (_, cx) = cx.add_window_view(move |_, _| A11yProbe { captured });
693        cx.update(|window, cx| window.draw(cx).clear(cx));
694        let [unchecked, checked, mixed, disabled] = result.lock().unwrap().take().unwrap();
695
696        assert_eq!(unchecked.role(), Role::CheckBox);
697        assert_eq!(unchecked.label(), Some("Remember me"));
698        assert_eq!(unchecked.toggled(), Some(Toggled::False));
699        assert_eq!(checked.toggled(), Some(Toggled::True));
700        assert_eq!(mixed.toggled(), Some(Toggled::Mixed));
701        assert!(unchecked.supports_action(accesskit::Action::Click));
702        assert!(!disabled.supports_action(accesskit::Action::Click));
703    }
704}