Skip to main content

gpui_component/
checkbox.rs

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/// A Checkbox element.
16#[derive(IntoElement)]
17pub struct Checkbox {
18    id: ElementId,
19    base: gpui_base::Checkbox,
20    style: StyleRefinement,
21    // `Text` is legacy presentation state. During render it is composed into
22    // the Base Checkbox child seam together with application children.
23    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    /// Create a new Checkbox with the given id.
39    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    /// Set tooltip text for the checkbox.
66    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
67        self.tooltip.text = Some((tooltip.into(), None));
68        self
69    }
70
71    /// Overrides how long the pointer must rest before the tooltip shows.
72    ///
73    /// Defaults to [`gpui_base::TooltipDefaults::show_delay`].
74    pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
75        self.tooltip.show_delay = Some(delay);
76        self
77    }
78
79    /// Set the label for the checkbox.
80    pub fn label(mut self, label: impl Into<Text>) -> Self {
81        self.label = Some(label.into());
82        self
83    }
84
85    /// Set the name a screen reader announces, overriding the visible label.
86    ///
87    /// This does not change the label drawn on screen.
88    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
89        self.accessibility_label = Some(label.into());
90        self
91    }
92
93    /// Set the checked state for the checkbox.
94    pub fn checked(mut self, checked: bool) -> Self {
95        self.checked = checked;
96        self
97    }
98
99    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
100    pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
101        self.on_change(handler)
102    }
103
104    /// Handle a requested checked value from pointer or keyboard activation.
105    ///
106    /// This is a controlled value: the owner must write the requested value and
107    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
108    /// This and [`Self::on_click`] share one callback; chaining them replaces
109    /// the previous handler instead of calling both.
110    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    /// Set the tab stop for the checkbox, default is true.
116    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
117        self.tab_stop = tab_stop;
118        self
119    }
120
121    /// Set the tab index for the checkbox, default is 0.
122    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    // The mark keeps its path while the spring is still fading it out. Guarding
191    // the path on `checked` alone unmounted the glyph the moment the box was
192    // cleared, so only the fade-in was ever visible.
193    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                    // Center on the first 1.25em line, including when the label wraps.
299                    .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                // Preserve the legacy Checkbox behavior: pointer presses do
356                // not move focus, including while disabled.
357                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}