Skip to main content

gpui_component/
checkbox.rs

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/// 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    /// Set the label for the checkbox.
72    pub fn label(mut self, label: impl Into<Text>) -> Self {
73        self.label = Some(label.into());
74        self
75    }
76
77    /// Set the name a screen reader announces, overriding the visible label.
78    ///
79    /// This does not change the label drawn on screen.
80    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
81        self.accessibility_label = Some(label.into());
82        self
83    }
84
85    /// Set the checked state for the checkbox.
86    pub fn checked(mut self, checked: bool) -> Self {
87        self.checked = checked;
88        self
89    }
90
91    /// Alias for [`Self::on_change`]. The last callback registered with either name wins.
92    pub fn on_click(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
93        self.on_change(handler)
94    }
95
96    /// Handle a requested checked value from pointer or keyboard activation.
97    ///
98    /// This is a controlled value: the owner must write the requested value and
99    /// call `cx.notify()` to render it. Disabled controls do not call the handler.
100    /// This and [`Self::on_click`] share one callback; chaining them replaces
101    /// the previous handler instead of calling both.
102    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    /// Set the tab stop for the checkbox, default is true.
108    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
109        self.tab_stop = tab_stop;
110        self
111    }
112
113    /// Set the tab index for the checkbox, default is 0.
114    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    // The mark keeps its path while the spring is still fading it out. Guarding
183    // the path on `checked` alone unmounted the glyph the moment the box was
184    // cleared, so only the fade-in was ever visible.
185    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                    // Center on the first 1.25em line, including when the label wraps.
294                    .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                // Preserve the legacy Checkbox behavior: pointer presses do
351                // not move focus, including while disabled.
352                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}