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#[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 .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}