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