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 {
89 self.accessibility_label = Some(label.into());
90 self
91 }
92
93 pub fn checked(mut self, checked: bool) -> Self {
95 self.checked = checked;
96 self
97 }
98
99 pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
101 self.on_change(handler)
102 }
103
104 pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
111 self.on_click = Some(Rc::new(handler));
112 self
113 }
114
115 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
117 self.tab_stop = tab_stop;
118 self
119 }
120
121 pub fn tab_index(mut self, tab_index: isize) -> Self {
123 self.tab_index = tab_index;
124 self
125 }
126}
127
128impl InteractiveElement for Checkbox {
129 fn interactivity(&mut self) -> &mut gpui::Interactivity {
130 self.base.interactivity()
131 }
132}
133impl StatefulInteractiveElement for Checkbox {}
134
135impl Styled for Checkbox {
136 fn style(&mut self) -> &mut gpui::StyleRefinement {
137 &mut self.style
138 }
139}
140
141impl Disableable for Checkbox {
142 fn disabled(mut self, disabled: bool) -> Self {
143 self.disabled = disabled;
144 self
145 }
146}
147
148impl crate::FocusableExt for Checkbox {
149 fn focus_ring(mut self, enabled: bool) -> Self {
150 self.focus_ring_enabled = enabled;
151 self
152 }
153
154 fn is_focus_ring_enabled(&self) -> bool {
155 self.focus_ring_enabled
156 }
157}
158
159impl Selectable for Checkbox {
160 fn selected(self, selected: bool) -> Self {
161 self.checked(selected)
162 }
163
164 fn is_selected(&self) -> bool {
165 self.checked
166 }
167}
168
169impl ParentElement for Checkbox {
170 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
171 self.children.extend(elements);
172 }
173}
174
175impl Sizable for Checkbox {
176 fn with_size(mut self, size: impl Into<Size>) -> Self {
177 self.size = size.into();
178 self
179 }
180}
181
182pub(crate) fn checkbox_check_icon(
183 id: ElementId,
184 size: Size,
185 checked: bool,
186 disabled: bool,
187 window: &mut Window,
188 cx: &mut App,
189) -> impl IntoElement {
190 let opacity = spring(
194 (id, "mark"),
195 if checked { 1. } else { 0. },
196 cx.theme().motion_tokens().spring_control,
197 window,
198 cx,
199 );
200 let color = if disabled {
201 cx.theme().primary_foreground.opacity(0.5)
202 } else {
203 cx.theme().primary_foreground
204 };
205
206 svg()
207 .absolute()
208 .top_px()
209 .left_px()
210 .map(|this| match size {
211 Size::XSmall => this.size_2(),
212 Size::Small => this.size_2p5(),
213 Size::Medium => this.size_3(),
214 Size::Large => this.size_3p5(),
215 _ => this.size_3(),
216 })
217 .text_color(color)
218 .when(opacity > 0., |this| {
219 this.path(IconName::Check.path()).opacity(opacity)
220 })
221}
222
223impl RenderOnce for Checkbox {
224 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
225 let checked = self.checked;
226 let has_content = self.label.is_some() || !self.children.is_empty();
227 let indicator_size = rems(match self.size {
228 Size::XSmall => 0.75,
229 Size::Small => 0.875,
230 Size::Large => 1.125,
231 _ => 1.,
232 });
233
234 let base = self.base;
235 let children = self.children;
236 let accessibility_label = self
237 .accessibility_label
238 .or_else(|| self.label.as_ref().map(|label| label.get_text(cx)));
239 let on_click = self.on_click.clone();
240 let focus_handle = window
241 .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
242 .read(cx)
243 .clone();
244 let is_focused = focus_handle.is_focused(window);
245
246 let unchecked_border = cx.theme().input;
247 let checked_color = cx.theme().primary;
248 let disabled_indicator_color = if checked {
249 checked_color.opacity(0.5)
250 } else {
251 unchecked_border.opacity(0.5)
252 };
253 let radius = cx.theme().radius.min(px(4.));
254 let disabled_text_color = cx.theme().muted_foreground;
255 let instance_style = self.style.clone();
256 base.role(self.role)
257 .checked(checked)
258 .disabled(self.disabled)
259 .styles(|styles| {
260 styles.disabled(|style| {
261 style
262 .text_color(disabled_text_color)
263 .refine_style(&instance_style)
264 })
265 })
266 .tab_stop(self.tab_stop)
267 .tab_index(self.tab_index)
268 .track_focus(&focus_handle)
269 .when_some(accessibility_label, |this, label| {
270 this.accessibility_label(label)
271 })
272 .when_some(on_click, |this, on_click| {
273 this.on_change(move |_, _, window, cx| {
274 window.prevent_default();
275 on_click(&!checked, window, cx);
276 })
277 })
278 .h_flex()
279 .gap_2()
280 .items_start()
281 .line_height(relative(1.))
282 .text_color(cx.theme().foreground)
283 .map(|this| match self.size {
284 Size::Size(_) => this,
285 size => this.input_text_size(size),
286 })
287 .rounded(cx.theme().radius * 0.5)
288 .when(is_focused && self.focus_ring_enabled, |this| {
289 this.focus_ring_style(window, cx)
290 })
291 .refine_style(&self.style)
292 .child(
293 CheckboxIndicator::new()
294 .checked(checked)
295 .disabled(self.disabled)
296 .relative()
297 .size(indicator_size)
298 .when(has_content, |this| this.mt(indicator_size * 0.125))
300 .flex_shrink_0()
301 .border_1()
302 .rounded(radius)
303 .when(!checked, |this| {
304 this.bg(cx.theme().input_background())
305 .when(!self.disabled, |this| this.border_color(unchecked_border))
306 })
307 .styles(|styles| {
308 styles
309 .checked(|style| {
310 style
311 .border_color(checked_color)
312 .bg(cx.theme().tokens.primary)
313 })
314 .disabled(|style| {
315 style
316 .border_color(disabled_indicator_color)
317 .when(checked, |style| style.bg(disabled_indicator_color))
318 })
319 })
320 .child(checkbox_check_icon(
321 self.id,
322 self.size,
323 checked,
324 self.disabled,
325 window,
326 cx,
327 )),
328 )
329 .when(self.label.is_some() || !children.is_empty(), |this| {
330 this.child(
331 v_flex()
332 .flex_1()
333 .overflow_hidden()
334 .line_height(relative(1.25))
335 .gap_1()
336 .map(|this| {
337 if let Some(label) = self.label {
338 this.child(
339 div()
340 .size_full()
341 .text_color(cx.theme().foreground)
342 .when(self.disabled, |this| {
343 this.text_color(cx.theme().muted_foreground)
344 })
345 .child(label),
346 )
347 } else {
348 this
349 }
350 })
351 .children(children),
352 )
353 })
354 .on_mouse_down(MouseButton::Left, |_, window, _| {
355 window.prevent_default();
358 })
359 .map(|this| self.tooltip.apply(this))
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use std::{cell::Cell, rc::Rc};
366
367 use gpui::{
368 Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, Render, TestAppContext,
369 VisualTestContext, point,
370 };
371
372 use super::*;
373
374 #[test]
375 fn an_explicit_accessibility_label_replaces_the_visible_one() {
376 let plain = Checkbox::new("remember").label("Remember me");
377 assert_eq!(plain.accessibility_label, None);
378
379 let named = Checkbox::new("remember")
380 .label("Remember me")
381 .accessibility_label("Remember this account");
382 assert_eq!(
383 named.accessibility_label.as_deref(),
384 Some("Remember this account"),
385 "an explicit name must win over the visible label"
386 );
387 assert!(
388 matches!(named.label.as_ref(), Some(Text::String(label)) if label.as_ref() == "Remember me"),
389 "and must not change what is drawn"
390 );
391 }
392
393 struct CheckboxHarness {
394 disabled: bool,
395 clicks: Rc<Cell<usize>>,
396 parent_clicks: Rc<Cell<usize>>,
397 }
398
399 impl Render for CheckboxHarness {
400 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
401 let clicks = self.clicks.clone();
402 let parent_clicks = self.parent_clicks.clone();
403 div()
404 .id("checkbox-parent")
405 .tab_group()
406 .size(px(100.))
407 .on_click(move |_, _, _| parent_clicks.set(parent_clicks.get() + 1))
408 .child(
409 Checkbox::new("checkbox")
410 .disabled(self.disabled)
411 .size_full()
412 .on_click(move |checked, _, _| {
413 assert!(*checked);
414 clicks.set(clicks.get() + 1);
415 }),
416 )
417 }
418 }
419
420 fn harness(
421 cx: &mut TestAppContext,
422 disabled: bool,
423 ) -> (&mut VisualTestContext, Rc<Cell<usize>>, Rc<Cell<usize>>) {
424 cx.update(crate::init);
425 let clicks = Rc::new(Cell::new(0));
426 let parent_clicks = Rc::new(Cell::new(0));
427 let (_, cx) = cx.add_window_view({
428 let clicks = clicks.clone();
429 let parent_clicks = parent_clicks.clone();
430 move |_, _| CheckboxHarness {
431 disabled,
432 clicks,
433 parent_clicks,
434 }
435 });
436 cx.update(|window, cx| window.draw(cx).clear(cx));
437 (cx, clicks, parent_clicks)
438 }
439
440 fn activate_key(cx: &mut VisualTestContext, key: &str) {
441 let keystroke = Keystroke::parse(key).unwrap();
442 cx.simulate_event(KeyDownEvent {
443 keystroke: keystroke.clone(),
444 is_held: false,
445 prefer_character_input: false,
446 });
447 cx.simulate_event(KeyUpEvent { keystroke });
448 }
449
450 #[gpui::test]
451 fn facade_pointer_activation_fires_once_without_moving_focus(cx: &mut TestAppContext) {
452 let (cx, clicks, _) = harness(cx, false);
453 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
454
455 assert_eq!(clicks.get(), 1);
456 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
457 }
458
459 #[gpui::test]
460 fn facade_supports_tab_enter_and_space(cx: &mut TestAppContext) {
461 let (cx, clicks, _) = harness(cx, false);
462 cx.update(|window, cx| window.focus_next(cx));
463 cx.update(|window, cx| assert!(window.focused(cx).is_some()));
464
465 activate_key(cx, "enter");
466 activate_key(cx, "space");
467
468 assert_eq!(clicks.get(), 2);
469 }
470
471 #[gpui::test]
472 fn facade_disabled_is_inert_and_pointer_activation_bubbles(cx: &mut TestAppContext) {
473 let (cx, clicks, parent_clicks) = harness(cx, true);
474 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
475
476 assert_eq!(clicks.get(), 0);
477 assert_eq!(parent_clicks.get(), 1);
478 cx.update(|window, cx| assert!(window.focused(cx).is_none()));
479 }
480
481 #[gpui::test]
482 fn facade_prepaints_label_and_custom_content_through_the_base_slot(cx: &mut TestAppContext) {
483 struct ContentHarness;
484
485 impl Render for ContentHarness {
486 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
487 Checkbox::new("content-checkbox")
488 .label("Remember me")
489 .child(
490 div()
491 .debug_selector(|| "checkbox-custom-content".into())
492 .child("Additional detail"),
493 )
494 }
495 }
496
497 cx.update(crate::init);
498 let (_, cx) = cx.add_window_view(|_, _| ContentHarness);
499 cx.update(|window, cx| window.draw(cx).clear(cx));
500
501 let bounds = cx
502 .debug_bounds("checkbox-custom-content")
503 .expect("custom content must prepaint through the Base child seam");
504 assert!(bounds.size.width > px(0.));
505 assert!(bounds.size.height > px(0.));
506 }
507}