Skip to main content

gpui_kit_material/components/
checkbox.rs

1//! M3 checkbox over GPUI Base's tri-state checkbox behavior.
2//!
3//! An 18dp container with 2dp corners and a 2dp outline inside a 40dp state
4//! layer and a 48dp target. Selecting fills the container on the fast
5//! effects spring while the check mark draws along its path on the fast
6//! spatial spring; switching between checked and indeterminate morphs the
7//! mark between the check and the dash. Reduced motion lands the mark at
8//! once and keeps the short fill fade. Base owns activation (pointer, Space,
9//! Enter), focus and the checkbox role with its mixed state.
10use super::InteractionPreview;
11use super::button::ButtonFocusRing;
12use super::radio::selection_paint;
13use crate::{
14    color::ColorRole,
15    motion::{self, MotionToken},
16    theme::color,
17    tokens::{state, typography::rem},
18};
19use gpui::{prelude::*, *};
20pub use gpui_base::CheckboxState;
21use std::rc::Rc;
22
23/// Checkbox geometry, in dp.
24const CONTAINER: f32 = 18.;
25const CORNER: f32 = 2.;
26const OUTLINE: f32 = 2.;
27const MARK_STROKE: f32 = 2.;
28const STATE_LAYER: f32 = 40.;
29const TARGET: f32 = 48.;
30
31/// The mark's three points in the 18dp container: the `check_small` and
32/// `check_indeterminate_small` symbols' center lines (their 24dp boxes hold
33/// the container 3dp in).
34const CHECK: [(f32, f32); 3] = [(4., 9.), (7., 12.), (14.3, 4.7)];
35const DASH: [(f32, f32); 3] = [(4., 9.), (9., 9.), (14., 9.)];
36
37/// The mark's points at `morph` (0 check, 1 dash).
38fn mark_points(morph: f32) -> [(f32, f32); 3] {
39    let t = morph.clamp(0., 1.);
40    std::array::from_fn(|i| {
41        let (a, b) = (CHECK[i], DASH[i]);
42        (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
43    })
44}
45
46/// The polyline through `points` cut to the `progress` fraction of its
47/// length (0 nothing, 1 all of it).
48fn partial_polyline(points: [(f32, f32); 3], progress: f32) -> Vec<(f32, f32)> {
49    let progress = if progress.is_finite() {
50        progress.clamp(0., 1.)
51    } else {
52        0.
53    };
54    let segment = |a: (f32, f32), b: (f32, f32)| ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
55    let lengths = [segment(points[0], points[1]), segment(points[1], points[2])];
56    let mut remaining = (lengths[0] + lengths[1]) * progress;
57    let mut out = vec![points[0]];
58    for (i, length) in lengths.into_iter().enumerate() {
59        if remaining <= 0. {
60            break;
61        }
62        let (a, b) = (points[i], points[i + 1]);
63        if remaining >= length {
64            out.push(b);
65            remaining -= length;
66        } else {
67            let f = if length > 0. { remaining / length } else { 0. };
68            out.push((a.0 + (b.0 - a.0) * f, a.1 + (b.1 - a.1) * f));
69            remaining = 0.;
70        }
71    }
72    out
73}
74
75/// The colors of a checkbox in one state.
76#[derive(Clone, Copy, Debug, PartialEq)]
77struct CheckboxPaint {
78    /// The selected container.
79    container: Rgba,
80    mark: Rgba,
81    /// The unselected outline at rest and while active.
82    outline: Rgba,
83    outline_active: Rgba,
84    hover: Rgba,
85    focus: Rgba,
86    press: Rgba,
87}
88
89/// M3 checkbox colors: primary (error) container with an on-primary
90/// (on-error) mark; an on-surface-variant (error) outline; disabled
91/// containers and outlines at on-surface 38% with a surface mark. State
92/// layers follow the shared selection-control policy.
93fn checkbox_paint(selected: bool, enabled: bool, error: bool) -> CheckboxPaint {
94    let accent = if error {
95        ColorRole::ERROR
96    } else {
97        ColorRole::PRIMARY
98    };
99    let layers = selection_paint(selected, enabled, accent, error);
100    let outline = selection_paint(false, enabled, accent, error);
101    if !enabled {
102        let disabled = state::disabled(color(ColorRole::ON_SURFACE));
103        return CheckboxPaint {
104            container: disabled,
105            mark: color(ColorRole::SURFACE),
106            outline: disabled,
107            outline_active: disabled,
108            hover: layers.hover,
109            focus: layers.focus,
110            press: layers.press,
111        };
112    }
113    CheckboxPaint {
114        container: color(accent),
115        mark: color(if error {
116            ColorRole::ON_ERROR
117        } else {
118            ColorRole::ON_PRIMARY
119        }),
120        outline: outline.ink,
121        outline_active: outline.ink_active,
122        hover: layers.hover,
123        focus: layers.focus,
124        press: layers.press,
125    }
126}
127
128type Change = Rc<dyn Fn(&CheckboxState, &mut Window, &mut App)>;
129
130/// Controlled tri-state M3 checkbox. Activation reports the next state:
131/// unchecked and indeterminate become checked, checked becomes unchecked.
132#[derive(gpui::IntoElement)]
133pub struct MaterialCheckbox {
134    id: ElementId,
135    label: SharedString,
136    state: CheckboxState,
137    error: bool,
138    disabled: bool,
139    preview: Option<InteractionPreview>,
140    focus: Option<FocusHandle>,
141    tab_stop: bool,
142    on_change: Option<Change>,
143}
144
145impl MaterialCheckbox {
146    /// `label` is the checkbox's accessible name.
147    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
148        Self {
149            id: id.into(),
150            label: label.into(),
151            state: CheckboxState::Unchecked,
152            error: false,
153            disabled: false,
154            preview: None,
155            focus: None,
156            tab_stop: true,
157            on_change: None,
158        }
159    }
160    pub fn state(mut self, state: CheckboxState) -> Self {
161        self.state = state;
162        self
163    }
164    pub fn checked(self, checked: bool) -> Self {
165        self.state(if checked {
166            CheckboxState::Checked
167        } else {
168            CheckboxState::Unchecked
169        })
170    }
171    /// Paint the error colors, e.g. for a required choice left unchecked.
172    pub fn error(mut self, error: bool) -> Self {
173        self.error = error;
174        self
175    }
176    pub fn disabled(mut self, disabled: bool) -> Self {
177        self.disabled = disabled;
178        self
179    }
180    /// Render a static specimen in one forced interaction state. See
181    /// [`InteractionPreview`].
182    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
183        self.preview = Some(state);
184        self
185    }
186    /// Use a caller-owned focus handle.
187    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
188        self.focus = Some(focus.clone());
189        self
190    }
191    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
192        self.tab_stop = tab_stop;
193        self
194    }
195    /// Called with the next state when the checkbox is activated.
196    pub fn on_change(
197        mut self,
198        f: impl Fn(&CheckboxState, &mut Window, &mut App) + 'static,
199    ) -> Self {
200        self.on_change = Some(Rc::new(f));
201        self
202    }
203}
204
205const CHECKBOX_GROUP: &str = "material-checkbox";
206
207/// The mark's last shape (0 check … 1 dash) and which of two morph
208/// channels animates it; flipping the channel restarts the morph at its
209/// target.
210#[derive(Clone, Copy, PartialEq)]
211struct MarkShape {
212    shape: f32,
213    channel: bool,
214}
215
216#[cfg(test)]
217thread_local! {
218    /// The last rendered (draw, morph) amounts.
219    static MARK: std::cell::Cell<(f32, f32)> = const { std::cell::Cell::new((0., 0.)) };
220}
221
222impl RenderOnce for MaterialCheckbox {
223    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
224        let focus = self.focus.clone().unwrap_or_else(|| {
225            window
226                .use_keyed_state((self.id.clone(), "focus"), cx, |_, cx| cx.focus_handle())
227                .read(cx)
228                .clone()
229        });
230        let preview = self.preview;
231        let disabled = self.disabled || preview.is_some_and(|p| p.is_disabled());
232        let enabled = !disabled;
233        let live = enabled && preview.is_none();
234        let forced = |state| enabled && preview == Some(state);
235        let keyboard_focus = forced(InteractionPreview::Focused)
236            || (live && focus.is_focused(window) && window.last_input_was_keyboard());
237        let selected = self.state != CheckboxState::Unchecked;
238        // An unchecking mark keeps the shape it had, so it retracts rather
239        // than morphing on its way out.
240        let shape = window.use_keyed_state((self.id.clone(), "shape"), cx, |_, _| MarkShape {
241            shape: 0.,
242            channel: false,
243        });
244        let previous = *shape.read(cx);
245        let shape_target = match self.state {
246            CheckboxState::Checked => 0.,
247            CheckboxState::Indeterminate => 1.,
248            CheckboxState::Unchecked => previous.shape,
249        };
250        let target = if selected { 1. } else { 0. };
251        let (fill, draw, morph) = if preview.is_some() {
252            (target, target, shape_target)
253        } else {
254            let draw = motion::selection_value((self.id.clone(), "mark"), target, window, cx);
255            // A mark drawing in from nothing takes its new shape at once:
256            // only a visible mark morphs between the check and the dash.
257            // Switching to the other morph channel starts it at its target.
258            let channel = if selected && previous.shape != shape_target && draw <= 1e-3 {
259                !previous.channel
260            } else {
261                previous.channel
262            };
263            let next = MarkShape {
264                shape: shape_target,
265                channel,
266            };
267            if next != previous {
268                shape.update(cx, |shape, _| *shape = next);
269            }
270            let morph_key = if channel { "morph-b" } else { "morph-a" };
271            (
272                motion::token_value(
273                    (self.id.clone(), "fill"),
274                    target,
275                    MotionToken::FastEffects,
276                    window,
277                    cx,
278                ),
279                draw,
280                motion::selection_value((self.id.clone(), morph_key), shape_target, window, cx),
281            )
282        };
283        #[cfg(test)]
284        MARK.with(|mark| mark.set((draw, morph)));
285        let fill = fill.clamp(0., 1.);
286        let paint = checkbox_paint(selected, enabled, self.error);
287        let active = keyboard_focus
288            || forced(InteractionPreview::Hovered)
289            || forced(InteractionPreview::Pressed);
290        let outline = if active {
291            paint.outline_active
292        } else {
293            paint.outline
294        };
295        // The fill paints under the outline too, so the outline fades out as
296        // the fill arrives instead of doubling a translucent container.
297        let border = Rgba {
298            a: outline.a * (1. - fill),
299            ..outline
300        };
301        let background = Rgba {
302            a: paint.container.a * fill,
303            ..paint.container
304        };
305        let points = mark_points(morph);
306        let mark_color = paint.mark;
307        let mark = canvas(
308            |_, _, _| {},
309            move |bounds, _, window, _| {
310                let line = partial_polyline(points, draw);
311                if line.len() < 2 {
312                    return;
313                }
314                let unit = bounds.size.width / CONTAINER;
315                let at = |(x, y): (f32, f32)| bounds.origin + point(unit * x, unit * y);
316                let mut path = PathBuilder::stroke(unit * MARK_STROKE);
317                path.move_to(at(line[0]));
318                for &p in &line[1..] {
319                    path.line_to(at(p));
320                }
321                if let Ok(path) = path.build() {
322                    window.paint_path(path, mark_color);
323                }
324            },
325        )
326        .absolute()
327        .inset_0();
328        let container = div()
329            .when(cfg!(test), |c| {
330                c.debug_selector(|| "checkbox-container".into())
331            })
332            .relative()
333            .flex_shrink_0()
334            .size(rem(CONTAINER))
335            .rounded(rem(CORNER))
336            .border(rem(OUTLINE))
337            .border_color(border)
338            .bg(background)
339            .when(live && !selected && !self.error, |c| {
340                let ink = paint.outline_active;
341                c.group_hover(CHECKBOX_GROUP, move |style| style.border_color(ink))
342            })
343            .child(mark);
344        let layer = div()
345            .id("state-layer")
346            .flex()
347            .flex_shrink_0()
348            .items_center()
349            .justify_center()
350            .size(rem(STATE_LAYER))
351            .rounded_full()
352            .when(keyboard_focus, |layer| layer.bg(paint.focus))
353            .when(forced(InteractionPreview::Hovered), |layer| {
354                layer.bg(paint.hover)
355            })
356            .when(forced(InteractionPreview::Pressed), |layer| {
357                layer.bg(paint.press)
358            })
359            .when(live && !keyboard_focus, |layer| {
360                let (hover, press) = (paint.hover, paint.press);
361                layer
362                    .group_hover(CHECKBOX_GROUP, move |style| style.bg(hover))
363                    .group_active(CHECKBOX_GROUP, move |style| style.bg(press))
364            })
365            .child(container);
366        let ringed = ButtonFocusRing::new(layer, focus.clone(), enabled, rem(STATE_LAYER / 2.))
367            .forced(forced(InteractionPreview::Focused));
368        let on_change = self.on_change;
369        gpui_base::Checkbox::new(self.id)
370            .track_focus(&focus)
371            .accessibility_label(self.label)
372            .state(self.state)
373            .disabled(disabled)
374            .tab_stop(self.tab_stop && preview.is_none())
375            .when_some(on_change, |checkbox, on_change| {
376                checkbox.on_change(move |next, _, window, cx| on_change(&next, window, cx))
377            })
378            .group(CHECKBOX_GROUP)
379            .flex()
380            .flex_shrink_0()
381            .items_center()
382            .justify_center()
383            .size(rem(TARGET))
384            .when(live, |checkbox| checkbox.cursor_pointer())
385            .child(ringed)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use core::prelude::v1::test;
393
394    #[test]
395    fn the_mark_draws_along_its_path_and_morphs_to_a_dash() {
396        assert_eq!(partial_polyline(CHECK, 0.), vec![CHECK[0]]);
397        assert_eq!(partial_polyline(CHECK, 1.), CHECK.to_vec());
398        let half = partial_polyline(CHECK, 0.2);
399        assert_eq!(half.len(), 2, "the short leg draws first");
400        assert!(half[1].0 < CHECK[1].0);
401        assert_eq!(partial_polyline(CHECK, f32::NAN), vec![CHECK[0]]);
402        assert_eq!(partial_polyline(CHECK, 1.2), CHECK.to_vec());
403        assert_eq!(mark_points(0.), CHECK);
404        assert_eq!(mark_points(1.), DASH);
405        assert_eq!(mark_points(2.), DASH, "a morph overshoot stays a dash");
406    }
407
408    #[test]
409    fn checkbox_uses_m3_roles() {
410        let c = color;
411        let on = checkbox_paint(true, true, false);
412        assert_eq!(on.container, c(ColorRole::PRIMARY));
413        assert_eq!(on.mark, c(ColorRole::ON_PRIMARY));
414        let off = checkbox_paint(false, true, false);
415        assert_eq!(off.outline, c(ColorRole::ON_SURFACE_VARIANT));
416        assert_eq!(off.outline_active, c(ColorRole::ON_SURFACE));
417        let error = checkbox_paint(true, true, true);
418        assert_eq!(error.container, c(ColorRole::ERROR));
419        assert_eq!(error.mark, c(ColorRole::ON_ERROR));
420        assert_eq!(
421            checkbox_paint(false, true, true).outline,
422            c(ColorRole::ERROR)
423        );
424        let disabled = checkbox_paint(true, false, true);
425        assert_eq!(
426            disabled.container,
427            state::disabled(c(ColorRole::ON_SURFACE))
428        );
429        assert_eq!(disabled.mark, c(ColorRole::SURFACE));
430        assert_eq!(disabled.hover.a, 0.);
431        assert_eq!(
432            (CONTAINER, CORNER, OUTLINE, STATE_LAYER, TARGET),
433            (18., 2., 2., 40., 48.)
434        );
435    }
436
437    struct Harness {
438        state: CheckboxState,
439        changes: Vec<CheckboxState>,
440    }
441    impl Render for Harness {
442        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
443            window.set_rem_size(px(14.));
444            div().flex().items_start().child(
445                div().debug_selector(|| "checkbox".into()).child(
446                    MaterialCheckbox::new("box", "Accept")
447                        .state(self.state)
448                        .on_change(cx.listener(|this, next: &CheckboxState, _, cx| {
449                            this.state = *next;
450                            this.changes.push(*next);
451                            cx.notify();
452                        })),
453                ),
454            )
455        }
456    }
457
458    fn draw(cx: &mut VisualTestContext) {
459        cx.update(|window, cx| {
460            window.refresh();
461            window.draw(cx).clear(cx);
462        });
463    }
464
465    #[gpui::test]
466    fn space_and_clicks_cycle_through_the_tri_state(cx: &mut TestAppContext) {
467        cx.update(|cx| {
468            gpui_base::init(cx);
469            cx.set_reduce_motion(true);
470        });
471        let (view, cx) = cx.add_window_view(|_, _| Harness {
472            state: CheckboxState::Indeterminate,
473            changes: Vec::new(),
474        });
475        draw(cx);
476        let target = cx.debug_bounds("checkbox").unwrap();
477        assert_eq!(target.size.height, px(48.));
478        assert_eq!(
479            cx.debug_bounds("checkbox-container").unwrap().size,
480            size(px(18.), px(18.))
481        );
482        cx.simulate_click(target.center(), Modifiers::default());
483        draw(cx);
484        view.read_with(cx, |h, _| {
485            assert_eq!(h.changes, [CheckboxState::Checked], "mixed becomes checked")
486        });
487        // The click focused the checkbox; Space toggles it.
488        let space = Keystroke::parse("space").unwrap();
489        cx.simulate_event(KeyDownEvent {
490            keystroke: space.clone(),
491            is_held: false,
492            prefer_character_input: false,
493        });
494        cx.simulate_event(KeyUpEvent { keystroke: space });
495        draw(cx);
496        view.read_with(cx, |h, _| {
497            assert_eq!(
498                h.changes,
499                [CheckboxState::Checked, CheckboxState::Unchecked]
500            )
501        });
502    }
503
504    #[gpui::test]
505    fn a_mark_drawing_in_from_nothing_does_not_morph(cx: &mut TestAppContext) {
506        cx.update(|cx| {
507            gpui_base::init(cx);
508            cx.set_reduce_motion(false);
509        });
510        let (view, cx) = cx.add_window_view(|_, _| Harness {
511            state: CheckboxState::Indeterminate,
512            changes: Vec::new(),
513        });
514        let settle = |cx: &mut VisualTestContext| {
515            draw(cx);
516            cx.executor()
517                .advance_clock(std::time::Duration::from_secs(2));
518            draw(cx);
519            draw(cx);
520        };
521        let set = |state, cx: &mut VisualTestContext| {
522            view.update(cx, |view, cx| {
523                view.state = state;
524                cx.notify();
525            })
526        };
527        settle(cx);
528        set(CheckboxState::Unchecked, cx);
529        settle(cx);
530        assert_eq!(MARK.with(|mark| mark.get()).0, 0., "retracted");
531        set(CheckboxState::Checked, cx);
532        draw(cx);
533        cx.executor()
534            .advance_clock(std::time::Duration::from_millis(40));
535        draw(cx);
536        let (drawn, morph) = MARK.with(|mark| mark.get());
537        assert!(drawn > 0. && drawn < 1., "mid-draw at {drawn}");
538        assert_eq!(morph, 0., "the check draws in as a check");
539        // A visible mark still morphs from the check to the dash.
540        settle(cx);
541        set(CheckboxState::Indeterminate, cx);
542        draw(cx);
543        cx.executor()
544            .advance_clock(std::time::Duration::from_millis(40));
545        draw(cx);
546        let (_, morph) = MARK.with(|mark| mark.get());
547        assert!(morph > 0. && morph < 1., "morphing at {morph}");
548    }
549}