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    /// Set the click handler for the checkbox.
92    ///
93    /// The `&bool` parameter indicates the new checked state after the click.
94    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    /// Set the tab stop for the checkbox, default is true.
100    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
101        self.tab_stop = tab_stop;
102        self
103    }
104
105    /// Set the tab index for the checkbox, default is 0.
106    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    // The mark keeps its path while the spring is still fading it out. Guarding
175    // the path on `checked` alone unmounted the glyph the moment the box was
176    // cleared, so only the fade-in was ever visible.
177    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                // Preserve the legacy Checkbox behavior: pointer presses do
341                // not move focus, including while disabled.
342                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}