1use 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
23const 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
31const CHECK: [(f32, f32); 3] = [(4., 9.), (7., 12.), (14.3, 4.7)];
35const DASH: [(f32, f32); 3] = [(4., 9.), (9., 9.), (14., 9.)];
36
37fn 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
46fn 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#[derive(Clone, Copy, Debug, PartialEq)]
77struct CheckboxPaint {
78 container: Rgba,
80 mark: Rgba,
81 outline: Rgba,
83 outline_active: Rgba,
84 hover: Rgba,
85 focus: Rgba,
86 press: Rgba,
87}
88
89fn 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#[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 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 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 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
183 self.preview = Some(state);
184 self
185 }
186 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 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#[derive(Clone, Copy, PartialEq)]
211struct MarkShape {
212 shape: f32,
213 channel: bool,
214}
215
216#[cfg(test)]
217thread_local! {
218 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 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 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 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 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 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}