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#[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#[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 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 pub fn state(mut self, state: CheckboxState) -> Self {
89 self.state = state;
90 self
91 }
92
93 pub fn checked(self, checked: bool) -> Self {
95 self.state(if checked {
96 CheckboxState::Checked
97 } else {
98 CheckboxState::Unchecked
99 })
100 }
101
102 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 pub fn disabled(mut self, disabled: bool) -> Self {
118 self.disabled = disabled;
119 self
120 }
121
122 pub fn styles(mut self, build: impl FnOnce(CheckboxStyles) -> CheckboxStyles) -> Self {
124 self.semantic_styles = build(self.semantic_styles);
125 self
126 }
127
128 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
130 self.accessibility_label = Some(label.into());
131 self
132 }
133
134 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 pub fn tab_index(mut self, tab_index: isize) -> Self {
149 self.tab_index = tab_index;
150 self
151 }
152
153 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
155 self.tab_stop = tab_stop;
156 self
157 }
158
159 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#[derive(Default)]
193pub struct CheckboxStyles {
194 checked: StyleRefinement,
195 indeterminate: StyleRefinement,
196 disabled: StyleRefinement,
197}
198
199impl CheckboxStyles {
200 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 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 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#[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#[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}