1use std::rc::Rc;
11
12use frust_core::accesskit::{Action, Role, Toggled};
13use frust_core::{
14 BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
15 PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
16};
17use frust_theme::Theme;
18use kurbo::{Point, Size};
19use peniko::Color;
20
21use crate::authoring::presses;
22use crate::text;
23
24const BOX: f64 = 20.0;
26const GAP: f64 = 8.0;
28const RADIUS: f64 = 4.0;
30const CHECK_WIDTH: f64 = 2.5;
32const FILL_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
35const FILL_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
38const CHECK: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
41
42fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
46 match theme {
47 Some(theme) => {
48 let scheme = theme.scheme();
49 (scheme.outline, scheme.primary, scheme.on_primary)
50 }
51 None => (FILL_OFF, FILL_ON, CHECK),
52 }
53}
54
55type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
57
58pub struct CheckboxView<State: 'static> {
60 checked: bool,
61 label: String,
62 on_toggle: OnToggle<State>,
63}
64
65pub fn checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
68 checked: bool,
69 label: impl Into<String>,
70 on_toggle: F,
71) -> CheckboxView<State> {
72 CheckboxView {
73 checked,
74 label: label.into(),
75 on_toggle: Rc::new(on_toggle),
76 }
77}
78
79#[allow(non_snake_case)]
81pub fn Checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
82 checked: bool,
83 label: impl Into<String>,
84 on_toggle: F,
85) -> CheckboxView<State> {
86 checkbox(checked, label, on_toggle)
87}
88
89pub struct CheckboxWidget {
91 checked: bool,
92 label: ChildPod,
93 label_text: String,
97 pressed: bool,
99 captured: bool,
103 on_toggle: crate::authoring::ErasedArgCallback<bool>,
104}
105
106fn inside(pos: Point, size: Size) -> bool {
107 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
108}
109
110impl<State: 'static> View<State> for CheckboxView<State> {
111 type Element = CheckboxWidget;
112
113 fn build(&self, ctx: &mut BuildCtx<'_>) -> CheckboxWidget {
114 let label_view = any::<State, _>(text(self.label.clone()));
115 CheckboxWidget {
116 checked: self.checked,
117 label: crate::authoring::build_child(&label_view, ctx),
118 label_text: self.label.clone(),
119 pressed: false,
120 captured: false,
121 on_toggle: crate::authoring::erase_callback_arg(&self.on_toggle),
122 }
123 }
124
125 fn rebuild(
126 &self,
127 prev: &Self,
128 element: &mut CheckboxWidget,
129 ctx: &mut BuildCtx<'_>,
130 ) -> ChangeFlags {
131 element.on_toggle = crate::authoring::erase_callback_arg(&self.on_toggle);
132 let mut flags = ChangeFlags::NONE;
133 if prev.checked != self.checked {
134 element.checked = self.checked;
136 flags |= ChangeFlags::PAINT;
137 }
138 if prev.label != self.label {
139 element.label_text = self.label.clone();
140 let prev_view = any::<State, _>(text(prev.label.clone()));
141 let next_view = any::<State, _>(text(self.label.clone()));
142 flags |=
143 crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
144 }
145 flags
146 }
147
148 fn teardown(&self, element: &mut CheckboxWidget, ctx: &mut BuildCtx<'_>) {
149 let label_view = any::<State, _>(text(self.label.clone()));
150 crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
151 }
152}
153
154impl Widget for CheckboxWidget {
155 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
156 let label_max = Size::new((bc.max().width - BOX - GAP).max(0.0), bc.max().height);
157 let label_size = self
158 .label
159 .layout_child(ctx, &BoxConstraints::loose(label_max));
160 let height = label_size.height.max(BOX);
161 self.label
163 .set_origin(Point::new(BOX + GAP, (height - label_size.height) / 2.0));
164 bc.constrain(Size::new(BOX + GAP + label_size.width, height))
165 }
166
167 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
168 let (off_fill, on_fill, check) = resolve_colors(Theme::from_paint_ctx(ctx));
169 let box_y = ctx.origin().y + (ctx.size().height - BOX) / 2.0;
170 let box_origin = Point::new(ctx.origin().x, box_y);
171 let fill = if self.checked { on_fill } else { off_fill };
172 scene.fill_rounded_rect(box_origin, Size::new(BOX, BOX), RADIUS, fill);
173 if self.checked {
174 let p0 = Point::new(box_origin.x + BOX * 0.24, box_origin.y + BOX * 0.52);
176 let p1 = Point::new(box_origin.x + BOX * 0.42, box_origin.y + BOX * 0.70);
177 let p2 = Point::new(box_origin.x + BOX * 0.76, box_origin.y + BOX * 0.30);
178 scene.stroke_line(p0, p1, CHECK_WIDTH, check);
179 scene.stroke_line(p1, p2, CHECK_WIDTH, check);
180 }
181 self.label.paint_child(ctx, scene);
182 }
183
184 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
185 let InputEvent::Pointer(p) = event else {
186 return EventResult::Ignored;
187 };
188 match p.phase {
189 PointerPhase::Down => {
190 if !presses(p) {
191 return EventResult::Ignored;
192 }
193 self.pressed = true;
194 self.captured = true;
195 ctx.capture_pointer();
196 ctx.request_redraw();
197 EventResult::Handled
198 }
199 PointerPhase::Move => {
200 if !self.captured {
203 return EventResult::Ignored;
204 }
205 self.pressed = inside(p.position, ctx.size());
206 ctx.request_redraw();
207 EventResult::Handled
208 }
209 PointerPhase::Up => {
210 if !self.captured {
211 return EventResult::Ignored;
212 }
213 if inside(p.position, ctx.size()) {
214 let requested = !self.checked;
216 (self.on_toggle)(ctx, requested);
217 }
218 self.pressed = false;
219 self.captured = false;
220 ctx.request_redraw();
221 EventResult::Handled
222 }
223 PointerPhase::Cancel => {
224 if !self.captured {
225 return EventResult::Ignored;
226 }
227 self.pressed = false;
228 self.captured = false;
229 ctx.request_redraw();
230 EventResult::Handled
231 }
232 }
233 }
234
235 fn semantics(&self, ctx: &mut SemanticsCtx) {
236 ctx.push_node(Role::CheckBox, |node| {
239 node.set_label(self.label_text.as_str());
240 node.set_toggled(Toggled::from(self.checked));
241 node.add_action(Action::Click);
242 });
243 }
244
245 crate::authoring::visit_children!(label);
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use std::any::Any;
252
253 #[derive(Default)]
254 struct ToggleState {
255 last: Option<bool>,
256 toggles: u32,
257 }
258
259 fn widget(checked: bool) -> CheckboxWidget {
260 let view = checkbox::<ToggleState, _>(checked, "on", |s: &mut ToggleState, v: bool| {
261 s.last = Some(v);
262 s.toggles += 1;
263 });
264 let mut counter = 0u64;
265 View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter))
266 }
267
268 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
269 InputEvent::Pointer(frust_core::PointerEvent {
270 phase,
271 position: Point::new(x, y),
272 button: frust_core::PointerButton::Primary,
273 })
274 }
275
276 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
278 InputEvent::Pointer(frust_core::PointerEvent {
279 phase,
280 position: Point::new(x, y),
281 button: frust_core::PointerButton::Secondary,
282 })
283 }
284
285 fn dispatch(w: &mut CheckboxWidget, state: &mut ToggleState, event: &InputEvent) {
286 let state_any: &mut dyn Any = state;
287 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
288 w.event(&mut ctx, event);
289 }
290
291 #[test]
292 fn a_secondary_press_never_captures_or_toggles() {
293 let mut w = widget(false);
294 let mut state = ToggleState::default();
295 dispatch(
296 &mut w,
297 &mut state,
298 &secondary_ev(PointerPhase::Down, 5.0, 12.0),
299 );
300 assert!(!w.pressed);
301 assert!(!w.captured, "no capture for the shell to get stuck on");
302 dispatch(
303 &mut w,
304 &mut state,
305 &secondary_ev(PointerPhase::Up, 5.0, 12.0),
306 );
307 assert_eq!(state.toggles, 0);
308
309 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
311 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
312 assert_eq!(state.toggles, 1);
313 }
314
315 #[test]
316 fn unchecked_fires_true_and_does_not_self_toggle() {
317 let mut w = widget(false);
318 let mut state = ToggleState::default();
319 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
320 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
321 assert_eq!(state.last, Some(true));
322 assert_eq!(state.toggles, 1);
323 assert!(!w.checked, "checkbox must not mutate its own checked flag");
324 }
325
326 #[test]
327 fn checked_fires_false() {
328 let mut w = widget(true);
329 let mut state = ToggleState::default();
330 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
331 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
332 assert_eq!(state.last, Some(false));
333 assert!(w.checked, "still checked until the app rebuilds it");
334 }
335
336 #[test]
337 fn up_outside_does_not_fire() {
338 let mut w = widget(false);
339 let mut state = ToggleState::default();
340 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
341 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
342 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
343 assert_eq!(state.toggles, 0);
344 }
345
346 #[test]
347 fn hover_move_without_down_is_ignored_noop() {
348 let mut w = widget(false);
349 let mut state = ToggleState::default();
350 let state_any: &mut dyn Any = &mut state;
351 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
352 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
353 assert!(matches!(result, EventResult::Ignored));
354 assert!(!w.pressed, "hover must not press");
355 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
356 assert_eq!(state.toggles, 0);
357 }
358
359 #[test]
360 fn up_without_down_does_not_fire() {
361 let mut w = widget(false);
362 let mut state = ToggleState::default();
363 let state_any: &mut dyn Any = &mut state;
364 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
365 let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
366 assert!(matches!(result, EventResult::Ignored));
367 assert_eq!(state.toggles, 0);
368 }
369
370 #[test]
371 fn cancel_clears_armed_state() {
372 let mut w = widget(false);
373 let mut state = ToggleState::default();
374 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
375 assert!(w.captured);
376 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
377 assert!(!w.captured, "Cancel disarms the press");
378 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
380 assert_eq!(state.toggles, 0);
381 }
382
383 #[derive(Default)]
385 struct BoxRecorder {
386 rrects: Vec<Color>,
387 strokes: Vec<Color>,
388 }
389
390 impl PaintScene for BoxRecorder {
391 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
392 fn draw_text(&mut self, _o: Point, _t: &str) {}
393 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
394 self.rrects.push(color);
395 }
396 fn stroke_line(&mut self, _a: Point, _b: Point, _w: f64, color: Color) {
397 self.strokes.push(color);
398 }
399 }
400
401 fn paint_rec(w: &mut CheckboxWidget, theme: Option<&Theme>) -> BoxRecorder {
402 let mut rec = BoxRecorder::default();
403 let mut ctx = match theme {
404 Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
405 None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
406 };
407 w.paint(&mut ctx, &mut rec);
408 rec
409 }
410
411 #[test]
412 fn unthemed_paint_uses_fallback_constants() {
413 let mut off = widget(false);
414 assert_eq!(paint_rec(&mut off, None).rrects, vec![FILL_OFF]);
415 let mut on = widget(true);
416 let rec = paint_rec(&mut on, None);
417 assert_eq!(rec.rrects, vec![FILL_ON]);
418 assert_eq!(rec.strokes, vec![CHECK, CHECK]);
419 }
420
421 #[test]
422 fn themed_paint_resolves_roles() {
423 let theme = Theme::neutral();
424 let scheme = theme.scheme();
425 let mut off = widget(false);
426 assert_eq!(
427 paint_rec(&mut off, Some(&theme)).rrects,
428 vec![scheme.outline],
429 "unchecked box uses the outline role"
430 );
431 let mut on = widget(true);
432 let rec = paint_rec(&mut on, Some(&theme));
433 assert_eq!(rec.rrects, vec![scheme.primary], "checked box uses primary");
434 assert_eq!(
435 rec.strokes,
436 vec![scheme.on_primary, scheme.on_primary],
437 "check mark uses on_primary"
438 );
439 }
440
441 #[test]
442 fn rebuild_adopts_new_checked_value() {
443 let mut counter = 0u64;
444 let prev = checkbox::<ToggleState, _>(false, "on", |_s, _v| {});
445 let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
446 assert!(!w.checked);
447 let next = checkbox::<ToggleState, _>(true, "on", |_s, _v| {});
448 let flags =
449 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
450 assert!(w.checked);
451 assert!(flags.needs_paint());
452 }
453}