1use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
4use frus_layout::{Dimension, Style};
5
6use crate::disabled::{disabled_content, disabled_mark};
7use crate::interaction::{Interaction, Status};
8use crate::theme::{TapTarget, Theme};
9use crate::widget::Widget;
10
11const BOX: f32 = 20.0;
12const MIXED_INSET: f32 = 4.0;
14const MIXED_THICKNESS: f32 = 2.0;
15const GAP: f32 = 10.0;
16
17pub struct Checkbox<Msg = crate::callback::Callback> {
19 value: Option<bool>,
21 tristate: bool,
23 label: Option<String>,
24 size: f32,
25 enabled: bool,
26 fill_color: Option<Color>,
27 check_color: Option<Color>,
28 border_color: Option<Color>,
29 active_border_color: Option<Color>,
30 tap_target: Option<TapTarget>,
31 radius: Option<f32>,
32 label_color: Option<Color>,
33 on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
34 on_change: Option<Box<dyn Fn(Option<bool>) -> Msg>>,
35}
36
37impl<Msg> Checkbox<Msg> {
38 pub fn new(checked: bool) -> Self {
40 Self {
41 value: Some(checked),
42 tristate: false,
43 label: None,
44 size: 18.0,
45 enabled: true,
46 fill_color: None,
47 check_color: None,
48 border_color: None,
49 active_border_color: None,
50 tap_target: None,
51 radius: None,
52 label_color: None,
53 on_toggle: None,
54 on_change: None,
55 }
56 }
57
58 pub fn maybe(value: Option<bool>) -> Self {
81 let mut checkbox = Self::new(false);
82 checkbox.value = value;
83 checkbox.tristate = true;
84 checkbox
85 }
86
87 pub fn fill_color(mut self, color: Color) -> Self {
89 self.fill_color = Some(color);
90 self
91 }
92
93 pub fn check_color(mut self, color: Color) -> Self {
95 self.check_color = Some(color);
96 self
97 }
98
99 pub fn border_color(mut self, color: Color) -> Self {
105 self.border_color = Some(color);
106 self
107 }
108
109 pub fn active_border_color(mut self, color: Color) -> Self {
112 self.active_border_color = Some(color);
113 self
114 }
115
116 pub fn radius(mut self, radius: f32) -> Self {
118 self.radius = Some(radius);
119 self
120 }
121
122 pub fn label_color(mut self, color: Color) -> Self {
124 self.label_color = Some(color);
125 self
126 }
127
128 fn corner(&self, theme: &Theme) -> f32 {
130 self.radius.or(theme.widgets.checkbox.radius).unwrap_or(5.0)
131 }
132
133 pub fn label(mut self, label: impl Into<String>) -> Self {
135 self.label = Some(label.into());
136 self
137 }
138
139 pub fn enabled(mut self, enabled: bool) -> Self {
145 self.enabled = enabled;
146 self
147 }
148
149 pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
151 mut self,
152 on_toggle: impl Fn(bool) -> R + 'static,
153 ) -> Self {
154 self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
155 self
156 }
157
158 pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
162 mut self,
163 on_change: impl Fn(Option<bool>) -> R + 'static,
164 ) -> Self {
165 self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
166 self
167 }
168
169 fn filled(&self) -> bool {
172 self.value != Some(false)
173 }
174
175 fn next(&self) -> Option<bool> {
178 if self.tristate {
179 match self.value {
180 Some(false) => Some(true),
181 Some(true) => None,
182 None => Some(false),
183 }
184 } else {
185 Some(self.value != Some(true))
186 }
187 }
188
189 fn label_width(&self) -> f32 {
190 match &self.label {
191 Some(text) => GAP + frus_text::measure(text, self.size).width,
192 None => 0.0,
193 }
194 }
195}
196
197impl<Msg> Checkbox<Msg> {
198 pub fn tap_target(mut self, target: TapTarget) -> Self {
202 self.tap_target = Some(target);
203 self
204 }
205
206 fn label_style(&self) -> ResolvedTextStyle {
210 TextStyle::new(self.size).resolved()
211 }
212
213 fn reserved(&self, theme: &Theme) -> f32 {
216 self.tap_target
217 .or(theme.widgets.checkbox.tap_target)
218 .unwrap_or(theme.tap_target)
219 .min_side()
220 }
221}
222
223impl<Msg> Widget<Msg> for Checkbox<Msg> {
224 fn style(&self) -> Style {
225 Widget::<Msg>::style_themed(self, &Theme::default())
226 }
227
228 fn style_themed(&self, theme: &Theme) -> Style {
238 let least = self.reserved(theme);
239 let line = self.label_style().line_height().max(BOX);
240 Style {
241 width: Dimension::Length((BOX + self.label_width()).max(least).ceil()),
242 height: Dimension::Length(line.max(least).ceil()),
243 ..Default::default()
244 }
245 }
246
247 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
248 &[]
249 }
250
251 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
252 let o = status.opacity;
253 let box_y = bounds.y + (bounds.height - BOX) * 0.5;
254 let box_x = bounds.x
258 + if self.label.is_none() {
259 (bounds.width - BOX) * 0.5
260 } else {
261 0.0
262 };
263 let box_rect = Rect::new(box_x, box_y, BOX, BOX);
264
265 if self.filled() {
266 let (fill, tick) = if self.enabled {
270 (
271 self.fill_color
272 .or(theme.widgets.checkbox.fill_color)
273 .unwrap_or(theme.primary),
274 self.check_color
275 .or(theme.widgets.checkbox.check_color)
276 .unwrap_or(theme.on_primary),
277 )
278 } else {
279 (disabled_content(theme), disabled_mark(theme))
280 };
281 scene.draw_rect(
282 box_rect,
283 fill.fade(o),
284 self.corner(theme),
285 0.0,
286 Color::TRANSPARENT,
287 );
288 match self.value {
289 Some(_) => scene.text(
293 Point::new(box_rect.x + 3.0, box_rect.y + 1.0),
294 "✓".to_string(),
295 &ResolvedTextStyle::exact(self.size),
296 tick.fade(o),
297 ),
298 None => scene.draw_rect(
303 Rect::new(
304 box_rect.x + MIXED_INSET,
305 box_rect.y + (BOX - MIXED_THICKNESS) * 0.5,
306 BOX - MIXED_INSET * 2.0,
307 MIXED_THICKNESS,
308 ),
309 tick.fade(o),
310 MIXED_THICKNESS * 0.5,
311 0.0,
312 Color::TRANSPARENT,
313 ),
314 }
315 } else {
316 let resting = self.border_color.or(theme.widgets.checkbox.border_color);
329 let border = if !self.enabled {
330 disabled_content(theme)
331 } else if status.interaction != Interaction::None || status.focused {
332 self.active_border_color
333 .or(theme.widgets.checkbox.active_border_color)
334 .or(resting)
335 .unwrap_or(theme.scheme.on_surface)
336 } else {
337 resting.unwrap_or(theme.scheme.on_surface_variant)
338 };
339 scene.draw_rect(
340 box_rect,
341 theme.surface.fade(o),
342 self.corner(theme),
343 2.0,
344 border.fade(o),
345 );
346 }
347
348 if let Some(label) = &self.label {
349 let color = if self.enabled {
350 self.label_color
351 .or(theme.widgets.checkbox.label_color)
352 .unwrap_or(theme.on_surface)
353 } else {
354 disabled_content(theme)
355 };
356 let style = self.label_style();
360 scene.text(
361 Point::new(
362 bounds.x + BOX + GAP,
363 bounds.y + (bounds.height - style.line_height()) * 0.5,
364 ),
365 label.clone(),
366 &style,
367 color.fade(o),
368 );
369 }
370 }
371
372 fn on_click(&self) -> Option<Msg> {
373 if !self.enabled {
374 return None;
375 }
376 let next = self.next();
377 if let Some(make) = self.on_change.as_ref() {
378 return Some(make(next));
379 }
380 self.on_toggle
384 .as_ref()
385 .map(|make| make(next.unwrap_or(true)))
386 }
387
388 fn focusable(&self) -> bool {
389 self.enabled
390 }
391
392 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
393 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::CheckBox)
396 .maybe_toggled(self.value);
397 s = if self.enabled {
398 s.clickable()
399 } else {
400 s.disabled(true)
401 };
402 if let Some(label) = &self.label {
403 s = s.label(label.clone());
404 }
405 Some(s)
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
413
414 #[derive(Clone, Debug, PartialEq)]
415 enum Msg {
416 Set(bool),
417 Maybe(Option<bool>),
418 }
419
420 #[test]
426 fn a_checkbox_reserves_room_for_a_finger() {
427 let theme = Theme::dark();
428 let bare: Checkbox<Msg> = Checkbox::new(false);
429 let square = Widget::<Msg>::style_themed(&bare, &theme);
430 assert_eq!(square.width, Dimension::Length(MIN_TAP_TARGET));
431 assert_eq!(square.height, Dimension::Length(MIN_TAP_TARGET));
432
433 let labelled: Checkbox<Msg> = Checkbox::new(false).label("Remember me");
436 let row = Widget::<Msg>::style_themed(&labelled, &theme);
437 assert_eq!(row.height, Dimension::Length(MIN_TAP_TARGET));
438 match row.width {
439 Dimension::Length(w) => assert!(w > MIN_TAP_TARGET, "width = {w}"),
440 other => panic!("{other:?}"),
441 }
442
443 let dense: Checkbox<Msg> = Checkbox::new(false).tap_target(TapTarget::ShrinkWrap);
444 assert_eq!(
445 Widget::<Msg>::style_themed(&dense, &theme).height,
446 Dimension::Length(SHRUNK_TAP_TARGET)
447 );
448 }
449
450 #[test]
453 fn a_click_below_the_box_still_lands() {
454 use frus_core::Size;
455 let ui = crate::ui::build_ui(
456 &Checkbox::new(false).on_toggle(Msg::Set),
457 Size::new(200.0, 200.0),
458 &crate::runtime::Runtime::default(),
459 &Theme::dark(),
460 );
461 let below = Point::new(24.0, 42.0);
463 let landed = ui.hit(below).and_then(|id| ui.msg_for(id));
464 assert_eq!(landed, Some(Msg::Set(true)), "a finger just low of the box");
465 }
466
467 #[test]
470 fn a_bare_box_is_centred_in_its_room() {
471 let theme = Theme::dark();
472 let box_of = |checkbox: &Checkbox<Msg>, width: f32| {
473 let mut scene = Scene::new();
474 Widget::<Msg>::paint(
475 checkbox,
476 Rect::new(0.0, 0.0, width, MIN_TAP_TARGET),
477 Status::default(),
478 &theme,
479 &mut scene,
480 );
481 scene
482 .primitives()
483 .iter()
484 .find_map(|p| match p {
485 frus_core::Primitive::Rect { rect, .. } => Some(*rect),
486 _ => None,
487 })
488 .expect("a checkbox paints its box")
489 };
490
491 let bare = box_of(&Checkbox::new(true), MIN_TAP_TARGET);
492 assert!(
493 (bare.x - (MIN_TAP_TARGET - BOX) * 0.5).abs() < 0.01,
494 "centred across: {bare:?}"
495 );
496 assert!(
497 (bare.y - (MIN_TAP_TARGET - BOX) * 0.5).abs() < 0.01,
498 "and down: {bare:?}"
499 );
500
501 let labelled = box_of(&Checkbox::new(true).label("Remember me"), 200.0);
502 assert_eq!(
503 labelled.x, 0.0,
504 "the words follow the box, not the other way"
505 );
506 }
507
508 #[test]
509 fn click_toggles() {
510 let unchecked = Checkbox::new(false).on_toggle(Msg::Set);
511 assert_eq!(Widget::on_click(&unchecked), Some(Msg::Set(true)));
512 let checked = Checkbox::new(true).on_toggle(Msg::Set);
513 assert_eq!(Widget::on_click(&checked), Some(Msg::Set(false)));
514 }
515
516 #[test]
517 fn a_disabled_box_is_inert_but_still_says_whether_it_is_ticked() {
518 let dead = Checkbox::new(true).on_toggle(Msg::Set).enabled(false);
519 assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
520 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
521 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
522 assert!(semantics.disabled, "and announced as unavailable");
523 assert_eq!(semantics.toggled, frus_core::Toggled::True);
525 }
526
527 #[test]
532 fn an_unticked_box_is_a_mark_not_a_container_edge() {
533 let theme = Theme::dark();
534 let side = |status: Status| {
535 let mut scene = Scene::new();
536 Widget::<Msg>::paint(
537 &Checkbox::<Msg>::new(false),
538 Rect::new(0.0, 0.0, 20.0, 20.0),
539 status,
540 &theme,
541 &mut scene,
542 );
543 scene
544 .primitives()
545 .iter()
546 .find_map(|p| match p {
547 frus_core::Primitive::Rect { border_color, .. } => Some(*border_color),
548 _ => None,
549 })
550 .expect("the box")
551 };
552 let at = |interaction, focused| Status {
553 opacity: 1.0,
554 interaction,
555 focused,
556 ..Default::default()
557 };
558 assert_eq!(
559 side(at(Interaction::None, false)),
560 theme.scheme.on_surface_variant,
561 "at rest"
562 );
563 for (name, status) in [
564 ("hovered", at(Interaction::Hovered, false)),
565 ("pressed", at(Interaction::Pressed, false)),
566 ("focused", at(Interaction::None, true)),
567 ] {
568 assert_eq!(side(status), theme.scheme.on_surface, "{name}");
569 }
570 assert_ne!(
572 side(at(Interaction::None, false)),
573 theme.scheme.outline,
574 "an unselected box is a mark, not a container's edge"
575 );
576 }
577
578 #[test]
579 fn a_disabled_tick_does_not_disappear_into_its_box() {
580 for theme in [Theme::dark(), Theme::light()] {
584 let mut scene = Scene::new();
585 Widget::<Msg>::paint(
586 &Checkbox::<Msg>::new(true).enabled(false),
587 Rect::new(0.0, 0.0, 20.0, 20.0),
588 Status {
589 opacity: 1.0,
590 ..Default::default()
591 },
592 &theme,
593 &mut scene,
594 );
595 let box_fill = scene
596 .primitives()
597 .iter()
598 .find_map(|p| match p {
599 frus_core::Primitive::Rect { color, .. } => Some(*color),
600 _ => None,
601 })
602 .expect("the box");
603 let tick = scene
604 .primitives()
605 .iter()
606 .find_map(|p| match p {
607 frus_core::Primitive::Text { color, .. } => Some(*color),
608 _ => None,
609 })
610 .expect("the tick");
611 let against = |c: frus_core::Color| {
612 (c.r - theme.scheme.surface.r).abs()
613 + (c.g - theme.scheme.surface.g).abs()
614 + (c.b - theme.scheme.surface.b).abs()
615 };
616 assert!(
621 against(tick) < 0.01,
622 "the tick is the surface punching through: {tick:?}"
623 );
624 assert!(
625 against(box_fill) > 0.1,
626 "and the box it is inside is not: {box_fill:?}"
627 );
628 }
629 }
630
631 #[test]
638 fn a_tristate_box_cycles_through_the_third_answer() {
639 let seen = |value| Widget::on_click(&Checkbox::maybe(value).on_change(Msg::Maybe));
640 assert_eq!(seen(Some(false)), Some(Msg::Maybe(Some(true))));
641 assert_eq!(seen(Some(true)), Some(Msg::Maybe(None)));
642 assert_eq!(seen(None), Some(Msg::Maybe(Some(false))));
643 }
644
645 #[test]
648 fn a_two_state_box_never_reaches_the_third() {
649 assert_eq!(
650 Widget::on_click(&Checkbox::new(true).on_change(Msg::Maybe)),
651 Some(Msg::Maybe(Some(false))),
652 "no `None` in the middle"
653 );
654 }
655
656 #[test]
661 fn a_tristate_box_on_the_old_callback_is_not_left_silent() {
662 assert_eq!(
663 Widget::on_click(&Checkbox::maybe(None).on_toggle(Msg::Set)),
664 Some(Msg::Set(false)),
665 "partly on moves to off, and `bool` can say that"
666 );
667 assert_eq!(
668 Widget::on_click(&Checkbox::maybe(Some(true)).on_toggle(Msg::Set)),
669 Some(Msg::Set(true)),
670 "on moves to partly on, which reads as on"
671 );
672 }
673
674 #[test]
676 fn the_three_state_callback_wins_over_the_two_state_one() {
677 let both = Checkbox::maybe(Some(true))
678 .on_toggle(|_| Msg::Set(false))
679 .on_change(Msg::Maybe);
680 assert_eq!(Widget::on_click(&both), Some(Msg::Maybe(None)));
681 }
682
683 #[test]
686 fn partly_ticked_is_announced_as_mixed() {
687 let announced = |value| {
688 Widget::<Msg>::semantics(&Checkbox::<Msg>::maybe(value))
689 .expect("announced")
690 .toggled
691 };
692 assert_eq!(announced(None), frus_core::Toggled::Mixed);
693 assert_eq!(announced(Some(true)), frus_core::Toggled::True);
694 assert_eq!(announced(Some(false)), frus_core::Toggled::False);
695 }
696
697 #[test]
701 fn both_answers_that_are_not_off_fill_the_box() {
702 let theme = Theme::default();
703 let painted = |value| {
704 let mut scene = Scene::new();
705 Widget::<Msg>::paint(
706 &Checkbox::<Msg>::maybe(value),
707 Rect::new(0.0, 0.0, 20.0, 20.0),
708 Status {
709 opacity: 1.0,
710 ..Default::default()
711 },
712 &theme,
713 &mut scene,
714 );
715 scene.primitives().to_vec()
716 };
717 let filled = |primitives: &[frus_core::Primitive]| {
718 primitives.iter().any(|p| {
719 matches!(p, frus_core::Primitive::Rect { color, .. } if *color == theme.primary)
720 })
721 };
722 assert!(filled(&painted(Some(true))), "ticked fills");
723 assert!(filled(&painted(None)), "partly ticked fills too");
724 assert!(!filled(&painted(Some(false))), "off does not");
725 }
726
727 #[test]
731 fn the_partly_ticked_mark_is_drawn_and_the_tick_is_not() {
732 let has_text = |value| {
733 let mut scene = Scene::new();
734 Widget::<Msg>::paint(
735 &Checkbox::<Msg>::maybe(value),
736 Rect::new(0.0, 0.0, 20.0, 20.0),
737 Status {
738 opacity: 1.0,
739 ..Default::default()
740 },
741 &Theme::default(),
742 &mut scene,
743 );
744 scene
745 .primitives()
746 .iter()
747 .any(|p| matches!(p, frus_core::Primitive::Text { .. }))
748 };
749 assert!(has_text(Some(true)), "the tick is a glyph");
750 assert!(!has_text(None), "the bar is not");
751 }
752}
753
754#[cfg(test)]
755mod color_tests {
756 use super::*;
757 use crate::widget::Widget;
758 use frus_core::Primitive;
759
760 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
761 const MARK: Color = Color::rgb(0.9, 0.9, 0.2);
762
763 fn box_of(cb: &Checkbox<()>, status: Status, theme: &Theme) -> (Color, Color, f32) {
765 let mut scene = Scene::new();
766 Widget::<()>::paint(
767 cb,
768 Rect::new(0.0, 0.0, 200.0, BOX),
769 status,
770 theme,
771 &mut scene,
772 );
773 scene
774 .primitives()
775 .iter()
776 .find_map(|p| match p {
777 Primitive::Rect {
778 color,
779 border_color,
780 radius,
781 ..
782 } => Some((*color, *border_color, radius.top_left)),
783 _ => None,
784 })
785 .expect("the box is painted")
786 }
787
788 fn text_color(cb: &Checkbox<()>, theme: &Theme) -> Vec<Color> {
790 let mut scene = Scene::new();
791 Widget::<()>::paint(
792 cb,
793 Rect::new(0.0, 0.0, 200.0, BOX),
794 Status {
795 opacity: 1.0,
796 ..Default::default()
797 },
798 theme,
799 &mut scene,
800 );
801 scene
802 .primitives()
803 .iter()
804 .filter_map(|p| match p {
805 Primitive::Text { color, .. } => Some(*color),
806 _ => None,
807 })
808 .collect()
809 }
810
811 fn rest() -> Status {
812 Status {
813 opacity: 1.0,
814 ..Default::default()
815 }
816 }
817
818 #[test]
820 fn the_defaults_are_what_they_were() {
821 let theme = Theme::default();
822 let (fill, _, radius) = box_of(&Checkbox::<()>::new(true), rest(), &theme);
823 assert_eq!(fill, theme.primary);
824 assert_eq!(radius, 5.0);
825 let (_, border, _) = box_of(&Checkbox::<()>::new(false), rest(), &theme);
826 assert_eq!(border, theme.scheme.on_surface_variant);
827 }
828
829 #[test]
831 fn a_ticked_box_takes_its_colours() {
832 let theme = Theme::default();
833 let cb = Checkbox::<()>::new(true)
834 .fill_color(BRAND)
835 .check_color(MARK)
836 .radius(2.0);
837 let (fill, _, radius) = box_of(&cb, rest(), &theme);
838 assert_eq!((fill, radius), (BRAND, 2.0));
839 assert_eq!(text_color(&cb, &theme), vec![MARK]);
840 }
841
842 #[test]
846 fn one_outline_colour_covers_both_states() {
847 let theme = Theme::default();
848 let hovered = Status {
849 opacity: 1.0,
850 interaction: Interaction::Hovered,
851 ..Default::default()
852 };
853 let cb = Checkbox::<()>::new(false).border_color(BRAND);
854 assert_eq!(box_of(&cb, rest(), &theme).1, BRAND);
855 assert_eq!(box_of(&cb, hovered, &theme).1, BRAND, "and under a finger");
856
857 let both = Checkbox::<()>::new(false)
858 .border_color(BRAND)
859 .active_border_color(MARK);
860 assert_eq!(box_of(&both, rest(), &theme).1, BRAND);
861 assert_eq!(box_of(&both, hovered, &theme).1, MARK, "unless it is named");
862 }
863
864 #[test]
866 fn the_theme_answers_and_the_instance_overrules_it() {
867 let mut theme = Theme::default();
868 theme.widgets.checkbox.fill_color = Some(MARK);
869 assert_eq!(box_of(&Checkbox::<()>::new(true), rest(), &theme).0, MARK);
870 assert_eq!(
871 box_of(&Checkbox::<()>::new(true).fill_color(BRAND), rest(), &theme).0,
872 BRAND
873 );
874 }
875
876 #[test]
878 fn the_label_takes_its_colour() {
879 let theme = Theme::default();
880 let cb = Checkbox::<()>::new(false).label("Ready").label_color(BRAND);
881 assert_eq!(text_color(&cb, &theme), vec![BRAND]);
882 }
883}