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#[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#[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 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 pub fn state(mut self, state: CheckboxState) -> Self {
92 self.state = state;
93 self
94 }
95
96 pub fn checked(self, checked: bool) -> Self {
98 self.state(if checked {
99 CheckboxState::Checked
100 } else {
101 CheckboxState::Unchecked
102 })
103 }
104
105 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 pub fn disabled(mut self, disabled: bool) -> Self {
121 self.disabled = disabled;
122 self
123 }
124
125 pub fn styles(mut self, build: impl FnOnce(CheckboxStyles) -> CheckboxStyles) -> Self {
127 self.semantic_styles = build(self.semantic_styles);
128 self
129 }
130
131 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
136 self.accessibility_label = Some(label.into());
137 self
138 }
139
140 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 pub fn tab_index(mut self, tab_index: isize) -> Self {
155 self.tab_index = tab_index;
156 self
157 }
158
159 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
161 self.tab_stop = tab_stop;
162 self
163 }
164
165 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#[derive(Default)]
199pub struct CheckboxStyles {
200 checked: StyleRefinement,
201 indeterminate: StyleRefinement,
202 disabled: StyleRefinement,
203}
204
205impl CheckboxStyles {
206 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 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 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#[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#[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}