1use std::rc::Rc;
15
16use frust_core::accesskit::{Action, Role, Toggled};
17use frust_core::{
18 BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
19 PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
20};
21use frust_theme::Theme;
22use kurbo::{Point, Size};
23use peniko::Color;
24
25use crate::authoring::presses;
26use crate::shortcuts::{activation, press_claims_focus};
27use crate::text;
28
29const BOX: f64 = 20.0;
31const GAP: f64 = 8.0;
33const RADIUS: f64 = 4.0;
35const CHECK_WIDTH: f64 = 2.5;
37const FILL_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
40const FILL_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
43const CHECK: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
46
47fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
51 match theme {
52 Some(theme) => {
53 let scheme = theme.scheme();
54 (scheme.outline, scheme.primary, scheme.on_primary)
55 }
56 None => (FILL_OFF, FILL_ON, CHECK),
57 }
58}
59
60type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
62
63pub struct CheckboxView<State: 'static> {
65 checked: bool,
66 label: String,
67 on_toggle: OnToggle<State>,
68}
69
70pub fn checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
73 checked: bool,
74 label: impl Into<String>,
75 on_toggle: F,
76) -> CheckboxView<State> {
77 CheckboxView {
78 checked,
79 label: label.into(),
80 on_toggle: Rc::new(on_toggle),
81 }
82}
83
84#[allow(non_snake_case)]
86pub fn Checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
87 checked: bool,
88 label: impl Into<String>,
89 on_toggle: F,
90) -> CheckboxView<State> {
91 checkbox(checked, label, on_toggle)
92}
93
94pub struct CheckboxWidget {
96 checked: bool,
97 label: ChildPod,
98 label_text: String,
102 pressed: bool,
104 captured: bool,
108 on_toggle: crate::authoring::ErasedArgCallback<bool>,
109}
110
111fn inside(pos: Point, size: Size) -> bool {
112 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
113}
114
115impl<State: 'static> View<State> for CheckboxView<State> {
116 type Element = CheckboxWidget;
117
118 fn build(&self, ctx: &mut BuildCtx<'_>) -> CheckboxWidget {
119 let label_view = any::<State, _>(text(self.label.clone()));
120 CheckboxWidget {
121 checked: self.checked,
122 label: crate::authoring::build_child(&label_view, ctx),
123 label_text: self.label.clone(),
124 pressed: false,
125 captured: false,
126 on_toggle: crate::authoring::erase_callback_arg(&self.on_toggle),
127 }
128 }
129
130 fn rebuild(
131 &self,
132 prev: &Self,
133 element: &mut CheckboxWidget,
134 ctx: &mut BuildCtx<'_>,
135 ) -> ChangeFlags {
136 element.on_toggle = crate::authoring::erase_callback_arg(&self.on_toggle);
137 let mut flags = ChangeFlags::NONE;
138 if prev.checked != self.checked {
139 element.checked = self.checked;
141 flags |= ChangeFlags::PAINT;
142 }
143 if prev.label != self.label {
144 element.label_text = self.label.clone();
145 let prev_view = any::<State, _>(text(prev.label.clone()));
146 let next_view = any::<State, _>(text(self.label.clone()));
147 flags |=
148 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
149 }
150 flags
151 }
152
153 fn teardown(&self, element: &mut CheckboxWidget, ctx: &mut BuildCtx<'_>) {
154 let label_view = any::<State, _>(text(self.label.clone()));
155 crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
156 }
157}
158
159impl Widget for CheckboxWidget {
160 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
161 let label_max = Size::new((bc.max().width - BOX - GAP).max(0.0), bc.max().height);
162 let label_size = self
163 .label
164 .layout_child(ctx, &BoxConstraints::loose(label_max));
165 let height = label_size.height.max(BOX);
166 self.label
168 .set_origin(Point::new(BOX + GAP, (height - label_size.height) / 2.0));
169 bc.constrain(Size::new(BOX + GAP + label_size.width, height))
170 }
171
172 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
173 let (off_fill, on_fill, check) = resolve_colors(Theme::from_paint_ctx(ctx));
174 let box_y = ctx.origin().y + (ctx.size().height - BOX) / 2.0;
175 let box_origin = Point::new(ctx.origin().x, box_y);
176 let fill = if self.checked { on_fill } else { off_fill };
177 scene.fill_rounded_rect(box_origin, Size::new(BOX, BOX), RADIUS, fill);
178 if self.checked {
179 let p0 = Point::new(box_origin.x + BOX * 0.24, box_origin.y + BOX * 0.52);
181 let p1 = Point::new(box_origin.x + BOX * 0.42, box_origin.y + BOX * 0.70);
182 let p2 = Point::new(box_origin.x + BOX * 0.76, box_origin.y + BOX * 0.30);
183 scene.stroke_line(p0, p1, CHECK_WIDTH, check);
184 scene.stroke_line(p1, p2, CHECK_WIDTH, check);
185 }
186 self.label.paint_child(ctx, scene);
187 }
188
189 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
190 if activation(ctx, event) {
191 (self.on_toggle)(ctx, !self.checked);
192 ctx.request_redraw();
193 return EventResult::Handled;
194 }
195 let InputEvent::Pointer(p) = event else {
196 return EventResult::Ignored;
197 };
198 match p.phase {
199 PointerPhase::Down => {
200 if !presses(p) {
201 return EventResult::Ignored;
202 }
203 if press_claims_focus(ctx) {
204 ctx.request_focus();
205 }
206 self.pressed = true;
207 self.captured = true;
208 ctx.capture_pointer();
209 ctx.request_redraw();
210 EventResult::Handled
211 }
212 PointerPhase::Move => {
213 if !self.captured {
216 return EventResult::Ignored;
217 }
218 self.pressed = inside(p.position, ctx.size());
219 ctx.request_redraw();
220 EventResult::Handled
221 }
222 PointerPhase::Up => {
223 if !self.captured {
224 return EventResult::Ignored;
225 }
226 if inside(p.position, ctx.size()) {
227 let requested = !self.checked;
229 (self.on_toggle)(ctx, requested);
230 }
231 self.pressed = false;
232 self.captured = false;
233 ctx.request_redraw();
234 EventResult::Handled
235 }
236 PointerPhase::Cancel => {
237 if !self.captured {
238 return EventResult::Ignored;
239 }
240 self.pressed = false;
241 self.captured = false;
242 ctx.request_redraw();
243 EventResult::Handled
244 }
245 }
246 }
247
248 fn semantics(&self, ctx: &mut SemanticsCtx) {
249 ctx.push_node(Role::CheckBox, |node| {
252 node.set_label(self.label_text.as_str());
253 node.set_toggled(Toggled::from(self.checked));
254 node.add_action(Action::Click);
255 });
256 }
257
258 fn is_focusable(&self) -> bool {
260 true
261 }
262
263 crate::authoring::visit_children!(label);
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use std::any::Any;
270
271 #[derive(Default)]
272 struct ToggleState {
273 last: Option<bool>,
274 toggles: u32,
275 }
276
277 fn widget(checked: bool) -> CheckboxWidget {
278 let view = checkbox::<ToggleState, _>(checked, "on", |s: &mut ToggleState, v: bool| {
279 s.last = Some(v);
280 s.toggles += 1;
281 });
282 let mut counter = 0u64;
283 View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter))
284 }
285
286 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
287 InputEvent::Pointer(frust_core::PointerEvent {
288 phase,
289 position: Point::new(x, y),
290 button: frust_core::PointerButton::Primary,
291 })
292 }
293
294 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
296 InputEvent::Pointer(frust_core::PointerEvent {
297 phase,
298 position: Point::new(x, y),
299 button: frust_core::PointerButton::Secondary,
300 })
301 }
302
303 fn dispatch(w: &mut CheckboxWidget, state: &mut ToggleState, event: &InputEvent) {
304 let state_any: &mut dyn Any = state;
305 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
306 w.event(&mut ctx, event);
307 }
308
309 #[test]
310 fn a_secondary_press_never_captures_or_toggles() {
311 let mut w = widget(false);
312 let mut state = ToggleState::default();
313 dispatch(
314 &mut w,
315 &mut state,
316 &secondary_ev(PointerPhase::Down, 5.0, 12.0),
317 );
318 assert!(!w.pressed);
319 assert!(!w.captured, "no capture for the shell to get stuck on");
320 dispatch(
321 &mut w,
322 &mut state,
323 &secondary_ev(PointerPhase::Up, 5.0, 12.0),
324 );
325 assert_eq!(state.toggles, 0);
326
327 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
329 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
330 assert_eq!(state.toggles, 1);
331 }
332
333 #[test]
334 fn unchecked_fires_true_and_does_not_self_toggle() {
335 let mut w = widget(false);
336 let mut state = ToggleState::default();
337 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
338 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
339 assert_eq!(state.last, Some(true));
340 assert_eq!(state.toggles, 1);
341 assert!(!w.checked, "checkbox must not mutate its own checked flag");
342 }
343
344 #[test]
345 fn checked_fires_false() {
346 let mut w = widget(true);
347 let mut state = ToggleState::default();
348 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
349 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
350 assert_eq!(state.last, Some(false));
351 assert!(w.checked, "still checked until the app rebuilds it");
352 }
353
354 #[test]
355 fn up_outside_does_not_fire() {
356 let mut w = widget(false);
357 let mut state = ToggleState::default();
358 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
359 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
360 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
361 assert_eq!(state.toggles, 0);
362 }
363
364 #[test]
365 fn hover_move_without_down_is_ignored_noop() {
366 let mut w = widget(false);
367 let mut state = ToggleState::default();
368 let state_any: &mut dyn Any = &mut state;
369 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
370 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
371 assert!(matches!(result, EventResult::Ignored));
372 assert!(!w.pressed, "hover must not press");
373 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
374 assert_eq!(state.toggles, 0);
375 }
376
377 #[test]
378 fn up_without_down_does_not_fire() {
379 let mut w = widget(false);
380 let mut state = ToggleState::default();
381 let state_any: &mut dyn Any = &mut state;
382 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
383 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
384 assert!(matches!(result, EventResult::Ignored));
385 assert_eq!(state.toggles, 0);
386 }
387
388 #[test]
389 fn cancel_clears_armed_state() {
390 let mut w = widget(false);
391 let mut state = ToggleState::default();
392 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
393 assert!(w.captured);
394 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
395 assert!(!w.captured, "Cancel disarms the press");
396 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
398 assert_eq!(state.toggles, 0);
399 }
400
401 #[derive(Default)]
403 struct BoxRecorder {
404 rrects: Vec<Color>,
405 strokes: Vec<Color>,
406 }
407
408 impl PaintScene for BoxRecorder {
409 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
410 fn draw_text(&mut self, _o: Point, _t: &str) {}
411 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
412 self.rrects.push(color);
413 }
414 fn stroke_line(&mut self, _a: Point, _b: Point, _w: f64, color: Color) {
415 self.strokes.push(color);
416 }
417 }
418
419 fn paint_rec(w: &mut CheckboxWidget, theme: Option<&Theme>) -> BoxRecorder {
420 let mut rec = BoxRecorder::default();
421 let mut ctx = match theme {
422 Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
423 None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
424 };
425 w.paint(&mut ctx, &mut rec);
426 rec
427 }
428
429 #[test]
430 fn unthemed_paint_uses_fallback_constants() {
431 let mut off = widget(false);
432 assert_eq!(paint_rec(&mut off, None).rrects, vec![FILL_OFF]);
433 let mut on = widget(true);
434 let rec = paint_rec(&mut on, None);
435 assert_eq!(rec.rrects, vec![FILL_ON]);
436 assert_eq!(rec.strokes, vec![CHECK, CHECK]);
437 }
438
439 #[test]
440 fn themed_paint_resolves_roles() {
441 let theme = Theme::neutral();
442 let scheme = theme.scheme();
443 let mut off = widget(false);
444 assert_eq!(
445 paint_rec(&mut off, Some(&theme)).rrects,
446 vec![scheme.outline],
447 "unchecked box uses the outline role"
448 );
449 let mut on = widget(true);
450 let rec = paint_rec(&mut on, Some(&theme));
451 assert_eq!(rec.rrects, vec![scheme.primary], "checked box uses primary");
452 assert_eq!(
453 rec.strokes,
454 vec![scheme.on_primary, scheme.on_primary],
455 "check mark uses on_primary"
456 );
457 }
458
459 #[test]
460 fn rebuild_adopts_new_checked_value() {
461 let mut counter = 0u64;
462 let prev = checkbox::<ToggleState, _>(false, "on", |_s, _v| {});
463 let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
464 assert!(!w.checked);
465 let next = checkbox::<ToggleState, _>(true, "on", |_s, _v| {});
466 let flags =
467 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
468 assert!(w.checked);
469 assert!(flags.needs_paint());
470 }
471
472 mod keyboard {
473 use super::*;
474 use frust_core::{DiscardScene, FrameTime, Key, KeyEvent, Modifiers, NamedKey, RenderRoot};
475
476 fn key(key: Key) -> InputEvent {
477 InputEvent::Key(KeyEvent {
478 key,
479 modifiers: Modifiers::default(),
480 repeat: false,
481 })
482 }
483
484 #[test]
485 fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
486 let mut logic = |_: &mut ToggleState| {
487 crate::column()
488 .child(checkbox(false, "a", |s: &mut ToggleState, _| {
489 s.toggles += 1
490 }))
491 .child(checkbox(false, "b", |s: &mut ToggleState, _| {
492 s.toggles += 10
493 }))
494 };
495 let mut root: RenderRoot<ToggleState, crate::FlexView<ToggleState>> = RenderRoot::new();
496 let mut state = ToggleState::default();
497 let mut tcx = frust_text::TextContext::new();
498 root.rebuild(&mut logic, &mut state);
499 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
500 root.paint(&mut DiscardScene, FrameTime::ZERO);
501 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 10.0));
502 root.event(&mut state, &ev(PointerPhase::Up, 5.0, 10.0));
503 root.event(&mut state, &key(Key::Named(NamedKey::Tab)));
504 root.event(&mut state, &key(Key::Character(" ".to_string())));
505 assert_eq!(state.toggles, 11, "Tab went to the second checkbox");
506 }
507
508 #[test]
509 fn a_touch_tap_toggles_without_claiming_focus() {
510 let mut logic = |_: &mut ToggleState| {
511 crate::column().child(checkbox(false, "a", |s: &mut ToggleState, _| {
512 s.toggles += 1
513 }))
514 };
515 let mut root: RenderRoot<ToggleState, crate::FlexView<ToggleState>> = RenderRoot::new();
516 let mut state = ToggleState::default();
517 let mut tcx = frust_text::TextContext::new();
518 root.rebuild(&mut logic, &mut state);
519 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
520 root.paint(&mut DiscardScene, FrameTime::ZERO);
521 for phase in [PointerPhase::Down, PointerPhase::Up] {
522 let InputEvent::Pointer(event) = ev(phase, 5.0, 10.0) else {
523 unreachable!()
524 };
525 let contact = InputEvent::PointerContact {
526 pointer_id: frust_core::event::PointerId::touch(0),
527 event,
528 };
529 root.event(&mut state, &contact);
530 }
531 assert_eq!(state.toggles, 1);
532 assert!(!root.is_focus_active(), "a tap leaves focus alone");
533 }
534
535 #[test]
536 fn tab_focuses_the_checkbox_then_space_or_enter_requests_the_toggle() {
537 assert!(widget(false).is_focusable());
538 let mut logic = |s: &mut ToggleState| {
539 let checked = s.last.unwrap_or(false);
540 crate::column().child(checkbox(checked, "on", |s: &mut ToggleState, v| {
541 s.last = Some(v);
542 s.toggles += 1;
543 }))
544 };
545 let mut root: RenderRoot<ToggleState, crate::FlexView<ToggleState>> = RenderRoot::new();
546 let mut state = ToggleState::default();
547 let mut tcx = frust_text::TextContext::new();
548 root.rebuild(&mut logic, &mut state);
549 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
550 root.paint(&mut DiscardScene, FrameTime::ZERO);
551 root.event(&mut state, &key(Key::Character(" ".to_string())));
552 assert_eq!(state.toggles, 0, "an unfocused checkbox takes no keys");
553 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 10.0));
554 root.event(&mut state, &ev(PointerPhase::Up, 5.0, 10.0));
555 assert_eq!(state.toggles, 1);
556 assert!(root.is_focus_active(), "a click claims focus");
557 root.rebuild(&mut logic, &mut state);
558
559 root.event(&mut state, &key(Key::Named(NamedKey::Tab)));
560 assert!(root.is_focus_active());
561 assert!(
562 root.event(&mut state, &key(Key::Character(" ".to_string())))
563 .handled
564 );
565 assert_eq!(
566 (state.last, state.toggles),
567 (Some(false), 2),
568 "requests !checked"
569 );
570 root.rebuild(&mut logic, &mut state);
571 root.event(&mut state, &key(Key::Named(NamedKey::Enter)));
572 assert_eq!((state.last, state.toggles), (Some(true), 3));
573 }
574 }
575}