Skip to main content

glassy_ui/
checkbox.rs

1use std::rc::Rc;
2
3use crate::motion::{Motion, StyledSlot};
4use crate::theme::ActiveTheme;
5use gpui::{
6    div, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight, IntoElement, KeyDownEvent,
7    RenderOnce, SharedString, StyleRefinement, Styled, Window,
8};
9
10use crate::compat::{AccessibilityExt, Role, Toggled};
11
12use crate::button::ButtonVariant;
13use crate::chrome::{box_shadow, button_chrome, focus_ring};
14use crate::icon::{Icon, IconName};
15
16type CheckboxClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
17type CheckboxChangeHandler = Rc<dyn Fn(CheckState, &mut Window, &mut App) + 'static>;
18
19struct CheckboxState {
20    focus_handle: FocusHandle,
21    state: CheckState,
22    last_prop: CheckState,
23}
24
25/// Unchecked / checked / mixed, from Paper Checkboxes.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum CheckState {
28    #[default]
29    Off,
30    On,
31    Mixed,
32}
33
34impl CheckState {
35    fn next(self) -> Self {
36        match self {
37            Self::Off => Self::On,
38            Self::On => Self::Off,
39            Self::Mixed => Self::On,
40        }
41    }
42
43    fn debug_name(self) -> &'static str {
44        match self {
45            Self::Off => "off",
46            Self::On => "on",
47            Self::Mixed => "mixed",
48        }
49    }
50
51    fn toggled(self) -> Toggled {
52        match self {
53            Self::Off => Toggled::False,
54            Self::On => Toggled::True,
55            Self::Mixed => Toggled::Mixed,
56        }
57    }
58}
59
60/// 16×16 radius-6 mark matching Paper `Glassy UI` → Checkboxes.
61///
62/// Without [`Checkbox::on_change`] / [`Checkbox::on_click`] the mark keeps its
63/// own state (gallery specimens). With a listener, [`Checkbox::state`] /
64/// [`Checkbox::checked`] is the source of truth each render.
65#[derive(IntoElement)]
66pub struct Checkbox {
67    id: SharedString,
68    state: CheckState,
69    disabled: bool,
70    label: Option<SharedString>,
71    style: StyleRefinement,
72    on_click: Option<CheckboxClickHandler>,
73    on_change: Option<CheckboxChangeHandler>,
74}
75
76impl Checkbox {
77    pub fn new(id: impl Into<SharedString>) -> Self {
78        Self {
79            id: id.into(),
80            state: CheckState::Off,
81            disabled: false,
82            label: None,
83            style: StyleRefinement::default(),
84            on_click: None,
85            on_change: None,
86        }
87    }
88
89    pub fn checked(mut self, checked: bool) -> Self {
90        self.state = if checked {
91            CheckState::On
92        } else {
93            CheckState::Off
94        };
95        self
96    }
97
98    pub fn state(mut self, state: CheckState) -> Self {
99        self.state = state;
100        self
101    }
102
103    pub fn disabled(mut self, disabled: bool) -> Self {
104        self.disabled = disabled;
105        self
106    }
107
108    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
109        self.label = Some(label.into());
110        self
111    }
112
113    pub fn on_click(
114        mut self,
115        listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
116    ) -> Self {
117        self.on_click = Some(Rc::new(listener));
118        self
119    }
120
121    /// New mark after a pointer or keyboard activation.
122    pub fn on_change(
123        mut self,
124        listener: impl Fn(CheckState, &mut Window, &mut App) + 'static,
125    ) -> Self {
126        self.on_change = Some(Rc::new(listener));
127        self
128    }
129}
130
131impl Styled for Checkbox {
132    fn style(&mut self) -> &mut StyleRefinement {
133        &mut self.style
134    }
135}
136
137impl RenderOnce for Checkbox {
138    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
139        let motion_id = self.id.clone();
140        let initial = self.state;
141        let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| CheckboxState {
142            focus_handle: cx.focus_handle(),
143            state: initial,
144            last_prop: initial,
145        });
146        let controlled = self.on_change.is_some() || self.on_click.is_some();
147        if controlled {
148            if state.read(cx).state != self.state {
149                state.update(cx, |checkbox, _| checkbox.state = self.state);
150            }
151        } else if state.read(cx).last_prop != self.state {
152            state.update(cx, |checkbox, _| {
153                checkbox.state = self.state;
154                checkbox.last_prop = self.state;
155            });
156        }
157
158        let check = if controlled {
159            self.state
160        } else {
161            state.read(cx).state
162        };
163        let theme = cx.theme();
164        let filled = matches!(check, CheckState::On | CheckState::Mixed);
165        let variant = if self.disabled {
166            ButtonVariant::Ghost
167        } else if filled {
168            ButtonVariant::Primary
169        } else {
170            ButtonVariant::Outline
171        };
172        let chrome = button_chrome(theme, variant);
173        let mark = if self.disabled && filled {
174            theme.muted_fg()
175        } else {
176            theme.on_solid
177        };
178
179        let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
180        if chrome.shadow_blur > 0.0 {
181            shadows.push(box_shadow(
182                0.,
183                chrome.shadow_y,
184                chrome.shadow,
185                chrome.shadow_blur,
186                0.,
187            ));
188        }
189
190        let interactive = !self.disabled;
191        let focus_handle = state.read(cx).focus_handle.clone().tab_stop(interactive);
192        let focused = focus_handle.is_focused(window);
193        if focused {
194            shadows.push(focus_ring(theme));
195        }
196
197        let box_el = div()
198            .flex()
199            .items_center()
200            .justify_center()
201            .size(px(16.))
202            .flex_shrink_0()
203            .rounded(px(6.))
204            .border_1()
205            .border_color(chrome.border)
206            .bg(chrome.bg)
207            .shadow(shadows)
208            .when(check == CheckState::On, |el| {
209                el.child(
210                    Motion::new()
211                        .id(format!("{motion_id}-check-on"))
212                        .selection_in()
213                        .child(Icon::new(IconName::Check).px(px(10.)).color(mark)),
214                )
215            })
216            .when(check == CheckState::Mixed, |el| {
217                el.child(
218                    Motion::new()
219                        .id(format!("{motion_id}-check-mixed"))
220                        .selection_in()
221                        .child(
222                            div()
223                                .w(px(8.))
224                                .h(px(1.5))
225                                .flex_shrink_0()
226                                .rounded(px(1.))
227                                .bg(mark),
228                        ),
229                )
230            });
231
232        let label_color = if self.disabled {
233            theme.muted_fg()
234        } else {
235            theme.ink
236        };
237        let aria_label = self.label.clone();
238        let debug_selector = format!("{}-{}", self.id, check.debug_name());
239
240        let el = div()
241            .id(self.id)
242            .debug_selector(move || debug_selector.clone())
243            .role(Role::CheckBox)
244            .aria_toggled(check.toggled())
245            .when_some(aria_label, |el, label| el.aria_label(label))
246            .track_focus(&focus_handle)
247            .tab_stop(interactive)
248            .flex()
249            .items_center()
250            .gap(px(8.))
251            .refine_style(&self.style)
252            .when(interactive, |el| el.cursor_pointer())
253            .when(!interactive, |el| el.cursor_default())
254            .child(box_el)
255            .when_some(self.label, |el, label| {
256                el.child(
257                    div()
258                        .font_family(theme.font_family)
259                        .font_weight(FontWeight::NORMAL)
260                        .text_size(px(14.))
261                        .line_height(px(20.))
262                        .text_color(label_color)
263                        .child(label),
264                )
265            });
266
267        if interactive {
268            let on_click = self.on_click;
269            let on_change = self.on_change;
270            let activate = Rc::new(
271                move |event: &ClickEvent, window: &mut Window, cx: &mut App| {
272                    let next = state.read(cx).state.next();
273                    if !controlled {
274                        state.update(cx, |checkbox, cx| {
275                            checkbox.state = next;
276                            cx.notify();
277                        });
278                    }
279                    if let Some(on_change) = &on_change {
280                        on_change(next, window, cx);
281                    }
282                    if let Some(on_click) = &on_click {
283                        on_click(event, window, cx);
284                    }
285                },
286            );
287            let keyboard = activate.clone();
288            let click_focus = focus_handle.clone();
289            el.on_key_down(move |event: &KeyDownEvent, window, cx| {
290                if event.keystroke.modifiers.modified() {
291                    return;
292                }
293                if matches!(event.keystroke.key.as_str(), "enter" | "space") {
294                    keyboard(&ClickEvent::default(), window, cx);
295                    cx.stop_propagation();
296                }
297            })
298            .on_click(move |event, window, cx| {
299                click_focus.focus(window);
300                activate(event, window, cx);
301            })
302        } else {
303            el
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn checked_false_clears_the_mark() {
314        assert_eq!(Checkbox::new("x").checked(true).state, CheckState::On);
315        assert_eq!(
316            Checkbox::new("x").checked(true).checked(false).state,
317            CheckState::Off
318        );
319    }
320
321    #[test]
322    fn mixed_activates_to_on() {
323        assert_eq!(CheckState::Mixed.next(), CheckState::On);
324        assert_eq!(CheckState::On.next(), CheckState::Off);
325        assert_eq!(CheckState::Off.next(), CheckState::On);
326    }
327}