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