1use std::{rc::Rc, time::Duration};
2
3use crate::{
4 ActiveTheme, Disableable, IconName, RoleOverride, Selectable, Sizable, Size, icon::IconNamed,
5 text::Text, tooltip::ComponentTooltip, v_flex,
6};
7use crate::{StyleSized as _, StyledExt as _, ThemeStyled as _};
8use gpui::{
9 AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
10 RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
11 prelude::FluentBuilder as _, px, relative, rems, svg,
12};
13use gpui_base::{CheckboxIndicator, spring};
14
15#[derive(IntoElement)]
17pub struct Checkbox {
18 id: ElementId,
19 base: gpui_base::Checkbox,
20 style: StyleRefinement,
21 label: Option<Text>,
24 accessibility_label: Option<SharedString>,
25 children: Vec<AnyElement>,
26 checked: bool,
27 disabled: bool,
28 size: Size,
29 tab_stop: bool,
30 tab_index: isize,
31 on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
32 tooltip: ComponentTooltip,
33 role: RoleOverride,
34 focus_ring_enabled: bool,
35}
36
37impl Checkbox {
38 pub fn new(id: impl Into<ElementId>) -> Self {
40 let id = id.into();
41 Self {
42 id: id.clone(),
43 base: gpui_base::Checkbox::new(id),
44 style: StyleRefinement::default(),
45 label: None,
46 accessibility_label: None,
47 children: Vec::new(),
48 checked: false,
49 disabled: false,
50 size: Size::default(),
51 on_click: None,
52 tab_stop: true,
53 tab_index: 0,
54 tooltip: ComponentTooltip::default(),
55 role: RoleOverride::default(),
56 focus_ring_enabled: true,
57 }
58 }
59
60 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
61 self.role = role.into();
62 self
63 }
64
65 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
67 self.tooltip.text = Some((tooltip.into(), None));
68 self
69 }
70
71 pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
75 self.tooltip.show_delay = Some(delay);
76 self
77 }
78
79 pub fn label(mut self, label: impl Into<Text>) -> Self {
81 self.label = Some(label.into());
82 self
83 }
84
85 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
91 self.accessibility_label = Some(label.into());
92 self
93 }
94
95 pub fn checked(mut self, checked: bool) -> Self {
97 self.checked = checked;
98 self
99 }
100
101 pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
103 self.on_change(handler)
104 }
105
106 pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
113 self.on_click = Some(Rc::new(handler));
114 self
115 }
116
117 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
119 self.tab_stop = tab_stop;
120 self
121 }
122
123 pub fn tab_index(mut self, tab_index: isize) -> Self {
125 self.tab_index = tab_index;
126 self
127 }
128}
129
130impl InteractiveElement for Checkbox {
131 fn interactivity(&mut self) -> &mut gpui::Interactivity {
132 self.base.interactivity()
133 }
134}
135impl StatefulInteractiveElement for Checkbox {}
136
137impl Styled for Checkbox {
138 fn style(&mut self) -> &mut gpui::StyleRefinement {
139 &mut self.style
140 }
141}
142
143impl Disableable for Checkbox {
144 fn disabled(mut self, disabled: bool) -> Self {
145 self.disabled = disabled;
146 self
147 }
148}
149
150impl crate::FocusableExt for Checkbox {
151 fn focus_ring(mut self, enabled: bool) -> Self {
152 self.focus_ring_enabled = enabled;
153 self
154 }
155
156 fn is_focus_ring_enabled(&self) -> bool {
157 self.focus_ring_enabled
158 }
159}
160
161impl Selectable for Checkbox {
162 fn selected(self, selected: bool) -> Self {
163 self.checked(selected)
164 }
165
166 fn is_selected(&self) -> bool {
167 self.checked
168 }
169}
170
171impl ParentElement for Checkbox {
172 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
173 self.children.extend(elements);
174 }
175}
176
177impl Sizable for Checkbox {
178 fn with_size(mut self, size: impl Into<Size>) -> Self {
179 self.size = size.into();
180 self
181 }
182}
183
184pub(crate) fn checkbox_check_icon(
185 id: ElementId,
186 size: Size,
187 checked: bool,
188 disabled: bool,
189 window: &mut Window,
190 cx: &mut App,
191) -> impl IntoElement {
192 let opacity = spring(
196 (id, "mark"),
197 if checked { 1. } else { 0. },
198 cx.theme().motion_tokens().spring_control,
199 window,
200 cx,
201 );
202 let color = if disabled {
203 cx.theme().primary_foreground.opacity(0.5)
204 } else {
205 cx.theme().primary_foreground
206 };
207
208 svg()
209 .absolute()
210 .top_px()
211 .left_px()
212 .map(|this| match size {
213 Size::XSmall => this.size_2(),
214 Size::Small => this.size_2p5(),
215 Size::Medium => this.size_3(),
216 Size::Large => this.size_3p5(),
217 _ => this.size_3(),
218 })
219 .text_color(color)
220 .when(opacity > 0., |this| {
221 this.path(crate::resolve_icon_path(IconName::Check.path(), cx))
222 .opacity(opacity)
223 })
224}
225
226impl RenderOnce for Checkbox {
227 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
228 let checked = self.checked;
229 let has_content = self.label.is_some() || !self.children.is_empty();
230 let indicator_size = rems(match self.size {
231 Size::XSmall => 0.75,
232 Size::Small => 0.875,
233 Size::Large => 1.125,
234 _ => 1.,
235 });
236
237 let base = self.base;
238 let children = self.children;
239 let accessibility_label = self
240 .accessibility_label
241 .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
242 let on_click = self.on_click.clone();
243 let focus_handle = window
244 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
245 .read(cx)
246 .clone();
247 let is_focused = focus_handle.is_focused(window);
248
249 let unchecked_border = cx.theme().input;
250 let checked_color = cx.theme().primary;
251 let disabled_indicator_color = if checked {
252 checked_color.opacity(0.5)
253 } else {
254 unchecked_border.opacity(0.5)
255 };
256 let radius = cx.theme().radius.min(px(4.));
257 let disabled_text_color = cx.theme().muted_foreground;
258 let instance_style = self.style.clone();
259 base.role(self.role)
260 .checked(checked)
261 .disabled(self.disabled)
262 .styles(|styles| {
263 styles.disabled(|style| {
264 style
265 .text_color(disabled_text_color)
266 .refine_style(&instance_style)
267 })
268 })
269 .tab_stop(self.tab_stop)
270 .tab_index(self.tab_index)
271 .track_focus(&focus_handle)
272 .when_some(accessibility_label, |this, label| {
273 this.accessibility_label(label)
274 })
275 .when_some(on_click, |this, on_click| {
276 this.on_change(move |_, _, window, cx| {
277 window.prevent_default();
278 on_click(&!checked, window, cx);
279 })
280 })
281 .h_flex()
282 .gap_2()
283 .items_start()
284 .line_height(relative(1.))
285 .text_color(cx.theme().foreground)
286 .map(|this| match self.size {
287 Size::Size(_) => this,
288 size => this.input_text_size(size),
289 })
290 .rounded(cx.theme().radius * 0.5)
291 .when(is_focused && self.focus_ring_enabled, |this| {
292 this.focus_ring_style(window, cx)
293 })
294 .refine_style(&self.style)
295 .child(
296 CheckboxIndicator::new()
297 .checked(checked)
298 .disabled(self.disabled)
299 .relative()
300 .size(indicator_size)
301 .when(has_content, |this| this.mt(indicator_size * 0.125))
303 .flex_shrink_0()
304 .border_1()
305 .rounded(radius)
306 .when(!checked, |this| {
307 this.bg(cx.theme().input_background())
308 .when(!self.disabled, |this| this.border_color(unchecked_border))
309 })
310 .styles(|styles| {
311 styles
312 .checked(|style| {
313 style
314 .border_color(checked_color)
315 .bg(cx.theme().tokens.primary)
316 })
317 .disabled(|style| {
318 style
319 .border_color(disabled_indicator_color)
320 .when(checked, |style| style.bg(disabled_indicator_color))
321 })
322 })
323 .child(checkbox_check_icon(
324 self.id,
325 self.size,
326 checked,
327 self.disabled,
328 window,
329 cx,
330 )),
331 )
332 .when(self.label.is_some() || !children.is_empty(), |this| {
333 this.child(
334 v_flex()
335 .flex_1()
336 .overflow_hidden()
337 .line_height(relative(1.25))
338 .gap_1()
339 .map(|this| {
340 if let Some(label) = self.label {
341 this.child(
342 div()
343 .size_full()
344 .text_color(cx.theme().foreground)
345 .when(self.disabled, |this| {
346 this.text_color(cx.theme().muted_foreground)
347 })
348 .child(label),
349 )
350 } else {
351 this
352 }
353 })
354 .children(children),
355 )
356 })
357 .on_mouse_down(MouseButton::Left, |_, window, _| {
358 window.prevent_default();
361 })
362 .map(|this| self.tooltip.apply(this))
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use std::{cell::Cell, rc::Rc};
369
370 use gpui::{
371 Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext,
372 VisualTestContext, point,
373 };
374
375 use super::*;
376
377 #[test]
378 fn an_explicit_accessibility_label_replaces_the_visible_one() {
379 let plain = Checkbox::new("remember").label("Remember me");
380 assert_eq!(plain.accessibility_label, None);
381
382 let named = Checkbox::new("remember")
383 .label("Remember me")
384 .accessibility_label("Remember this account");
385 assert_eq!(
386 named.accessibility_label.as_deref(),
387 Some("Remember this account"),
388 "an explicit name must win over the visible label"
389 );
390 assert!(
391 matches!(named.label.as_ref(), Some(Text::String(label)) if label.as_ref() == "Remember me"),
392 "and must not change what is drawn"
393 );
394 }
395
396 struct CheckboxHarness {
397 disabled: bool,
398 clicks: Rc<Cell<usize>>,
399 parent_clicks: Rc<Cell<usize>>,
400 }
401
402 impl Render for CheckboxHarness {
403 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
404 let clicks = self.clicks.clone();
405 let parent_clicks = self.parent_clicks.clone();
406 div()
407 .id("checkbox-parent")
408 .tab_group()
409 .size(px(100.))
410 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
411 .child(
412 Checkbox::new("checkbox")
413 .disabled(self.disabled)
414 .size_full()
415 .on_click(move |checked, _, _| {
416 assert!(*checked);
417 clicks.set(clicks.get() + 1);
418 }),
419 )
420 }
421 }
422
423 fn harness(
424 cx: &mut TestAppContext,
425 disabled: bool,
426 ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
427 cx.update(crate::init);
428 let clicks = Rc::new(Cell::new(0));
429 let parent_clicks = Rc::new(Cell::new(0));
430 let (_, cx) = cx.add_window_view({
431 let clicks = clicks.clone();
432 let parent_clicks = parent_clicks.clone();
433 move |_, _| CheckboxHarness {
434 disabled,
435 clicks,
436 parent_clicks,
437 }
438 });
439 cx.update(|window, cx| window.draw(cx).clear(cx));
440 (cx, clicks, parent_clicks)
441 }
442
443 fn activate_key(cx: &mut VisualTestContext, key: &str) {
444 let keystroke =
445 cx.update(|_, cx| Keystroke::parse_with_os(key, cx.platform_info().os).unwrap());
446 cx.simulate_event(KeyDownEvent {
447 keystroke: keystroke.clone(),
448 is_held: false,
449 prefer_character_input: false,
450 });
451 cx.simulate_event(KeyUpEvent { keystroke });
452 }
453
454 #[gpui::test]
455 fn facade_pointer_activation_fires_once_without_moving_focus(cx: &mut TestAppContext) {
456 let (cx, clicks, _) = harness(cx, false);
457 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
458
459 assert_eq!(clicks.get(), 1);
460 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
461 }
462
463 #[gpui::test]
464 fn facade_supports_tab_enter_and_space(cx: &mut TestAppContext) {
465 let (cx, clicks, _) = harness(cx, false);
466 cx.update(|window, cx| window.focus_next(cx));
467 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
468
469 activate_key(cx, "enter");
470 activate_key(cx, "space");
471
472 assert_eq!(clicks.get(), 2);
473 }
474
475 #[gpui::test]
476 fn facade_disabled_is_inert_and_pointer_activation_bubbles(cx: &mut TestAppContext) {
477 let (cx, clicks, parent_clicks) = harness(cx, true);
478 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
479
480 assert_eq!(clicks.get(), 0);
481 assert_eq!(parent_clicks.get(), 1);
482 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
483 }
484
485 #[gpui::test]
486 fn facade_prepaints_label_and_custom_content_through_the_base_slot(cx: &mut TestAppContext) {
487 struct ContentHarness;
488
489 impl Render for ContentHarness {
490 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
491 Checkbox::new("content-checkbox")
492 .label("Remember me")
493 .child(
494 div()
495 .debug_selector(|| "checkbox-custom-content".into())
496 .child("Additional detail"),
497 )
498 }
499 }
500
501 cx.update(crate::init);
502 let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
503 cx.update(|window, cx| window.draw(cx).clear(cx));
504
505 let bounds = cx
506 .debug_bounds("checkbox-custom-content")
507 .expect("custom content must prepaint through the Base child seam");
508 assert!(bounds.size.width > px(0.));
509 assert!(bounds.size.height > px(0.));
510 }
511
512 #[gpui::test]
513 fn the_check_mark_asks_the_icon_resolver_for_its_path(cx: &mut TestAppContext) {
514 struct CheckedHarness;
515
516 impl Render for CheckedHarness {
517 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
518 Checkbox::new("checked").checked(true)
519 }
520 }
521
522 let asked = Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
523 cx.update(|cx| {
524 crate::init(cx);
525 cx.set_reduce_motion(true);
526 let asked = asked.clone();
527 crate::set_icon_resolver(cx, move |path, _| {
528 asked.borrow_mut().push(path.to_string());
529 None
530 });
531 });
532 let (_, cx) = cx.add_window_view(|_, _| CheckedHarness);
533 cx.update(|window, cx| window.draw(cx).clear(cx));
534
535 assert!(
536 asked.borrow().contains(&IconName::Check.path().to_string()),
537 "the check mark must be drawn from the resolved path"
538 );
539 }
540}