1use std::cell::Cell;
60use std::rc::Rc;
61
62use teksilo_canvas::{Rect, Size, SizeProposal};
63use teksilo_core::accessibility::AccessNodeBuilder;
64use teksilo_core::binding::BindingLevel;
65use teksilo_core::build_context::BuildContext;
66use teksilo_core::event::{EventResponse, Key, WidgetEvent};
67use teksilo_core::signal::{Prop, Signal};
68use teksilo_core::styles::{
69 CheckboxState, CheckboxStyleConfig, CheckboxVariant, SharedCheckboxStyle,
70};
71use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
72use teksilo_core::widget_builder::HandlerSet;
73use teksilo_core::widget_id::WidgetId;
74use teksilo_data::CheckState;
75use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
76
77use crate::button::InteractionState;
78use crate::primitives::{HStack, MinSize, TextWidget, VStack};
79use teksilo_i18n::LocalizedString;
80
81#[derive(Clone)]
87enum CheckKind {
88 TwoState(Signal<bool>),
89 TriState(Signal<CheckState>),
90}
91
92impl CheckKind {
93 fn check_state(&self) -> CheckState {
94 match self {
95 CheckKind::TwoState(s) => CheckState::from(s.get()),
96 CheckKind::TriState(s) => s.get(),
97 }
98 }
99
100 fn check_state_signal(&self) -> Signal<CheckState> {
106 match self {
107 CheckKind::TwoState(s) => s.map(|b| CheckState::from(*b)),
108 CheckKind::TriState(s) => s.clone(),
109 }
110 }
111
112 fn toggle(&self) -> bool {
116 match self {
117 CheckKind::TwoState(s) => {
118 let current = s.get();
119 s.set(!current);
120 !current
121 }
122 CheckKind::TriState(s) => {
123 let current = s.get();
131 let next = if matches!(current, CheckState::Checked) {
132 CheckState::Unchecked
133 } else {
134 CheckState::Checked
135 };
136 s.set(next);
137 matches!(next, CheckState::Checked)
138 }
139 }
140 }
141}
142
143impl std::fmt::Debug for CheckKind {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 CheckKind::TwoState(_) => write!(f, "TwoState"),
147 CheckKind::TriState(_) => write!(f, "TriState"),
148 }
149 }
150}
151
152pub struct Checkbox {
158 label: Option<LocalizedString>,
159 caption: Option<LocalizedString>,
160 kind: CheckKind,
161 enabled: Prop<bool>,
166 labelled_externally: bool,
173 label_style: teksilo_core::color_prop::TextStyleProp,
174 label_color: teksilo_core::color_prop::ColorProp,
175 caption_style: teksilo_core::color_prop::TextStyleProp,
176 caption_color: teksilo_core::color_prop::ColorProp,
177 tooltip_text: Option<LocalizedString>,
178 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
179 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
180 variant: CheckboxVariant,
181 style_override: Option<SharedCheckboxStyle>,
182 on_change: Option<Rc<dyn Fn(bool, &mut EventContext)>>,
183 root_child_id: Option<WidgetId>,
184}
185
186impl Checkbox {
187 pub fn new(checked: Signal<bool>) -> Self {
189 Self {
190 label: None,
191 caption: None,
192 kind: CheckKind::TwoState(checked),
193 enabled: Prop::Static(true),
194 labelled_externally: false,
195 label_style: TextStyleRole::Body.into(),
196 label_color: TextRole::Primary.into(),
197 caption_style: TextStyleRole::Small.into(),
198 caption_color: TextRole::Secondary.into(),
199 tooltip_text: None,
200 rich_tooltip_source: None,
201 composite_tooltip_content: None,
202 variant: CheckboxVariant::default(),
203 style_override: None,
204 on_change: None,
205 root_child_id: None,
206 }
207 }
208
209 pub fn tristate(state: Signal<CheckState>) -> Self {
217 Self {
218 label: None,
219 caption: None,
220 kind: CheckKind::TriState(state),
221 enabled: Prop::Static(true),
222 labelled_externally: false,
223 label_style: TextStyleRole::Body.into(),
224 label_color: TextRole::Primary.into(),
225 caption_style: TextStyleRole::Small.into(),
226 caption_color: TextRole::Secondary.into(),
227 tooltip_text: None,
228 rich_tooltip_source: None,
229 composite_tooltip_content: None,
230 variant: CheckboxVariant::default(),
231 style_override: None,
232 on_change: None,
233 root_child_id: None,
234 }
235 }
236
237 pub fn on_change(mut self, f: impl Fn(bool, &mut EventContext) + 'static) -> Self {
252 self.on_change = Some(Rc::new(f));
253 self
254 }
255
256 pub fn labelled_externally(mut self) -> Self {
275 self.labelled_externally = true;
276 self
277 }
278
279 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
282 let ls: LocalizedString = label.into();
283 self.label = Some(ls);
284 self
285 }
286
287 pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
291 let ls: LocalizedString = text.into();
292 self.caption = Some(ls);
293 self
294 }
295
296 pub fn label_style(
300 mut self,
301 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
302 ) -> Self {
303 self.label_style = style.into();
304 self
305 }
306
307 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
312 self.label_color = color.into();
313 self
314 }
315
316 pub fn caption_style(
318 mut self,
319 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
320 ) -> Self {
321 self.caption_style = style.into();
322 self
323 }
324
325 pub fn caption_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
330 self.caption_color = color.into();
331 self
332 }
333
334 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
338 self.enabled = enabled.into();
339 self
340 }
341
342 pub fn variant(mut self, variant: CheckboxVariant) -> Self {
347 self.variant = variant;
348 self
349 }
350
351 pub fn style(mut self, style: impl teksilo_core::styles::CheckboxStyle) -> Self {
355 self.style_override = Some(Rc::new(style));
356 self
357 }
358
359 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
362 self.tooltip_text = Some(text.into());
363 self.rich_tooltip_source = None;
364 self.composite_tooltip_content = None;
365 self
366 }
367
368 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
371 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
372 self.tooltip_text = None;
373 self.composite_tooltip_content = None;
374 self
375 }
376
377 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
379 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
380 self.tooltip_text = None;
381 self.composite_tooltip_content = None;
382 self
383 }
384
385 pub fn composite_tooltip(
388 mut self,
389 content: impl teksilo_core::widget::Widget + 'static,
390 ) -> Self {
391 self.composite_tooltip_content = Some(Box::new(content));
392 self.tooltip_text = None;
393 self.rich_tooltip_source = None;
394 self
395 }
396
397 fn check_state(&self) -> CheckState {
398 self.kind.check_state()
399 }
400}
401
402impl std::fmt::Debug for Checkbox {
403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404 f.debug_struct("Checkbox")
405 .field("label", &self.label)
406 .field("caption", &self.caption)
407 .field("kind", &self.kind)
408 .field("enabled", &self.enabled.get())
409 .finish()
410 }
411}
412
413impl Widget for Checkbox {
421 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
422 use crate::styles::recipe_checkbox_style as cb_dims;
423 let kind = self.kind.clone();
424 let variant = self.variant;
425 let self_id = ctx.self_id();
426
427 ctx.enabled_when(self_id, self.enabled.clone());
432 let effective_enabled = ctx.effective_enabled_signal(self_id);
433
434 let interaction = ctx.signal(InteractionState::Idle);
437
438 let style_state = kind.check_state_signal().map(|cs| match *cs {
443 CheckState::Unchecked => CheckboxState::Unchecked,
444 CheckState::Checked => CheckboxState::Checked,
445 CheckState::Indeterminate => CheckboxState::Indeterminate,
446 });
447 kind.check_state_signal().bind_to(
451 self_id,
452 ctx.binding_registry(),
453 BindingLevel::AccessibilityOnly,
454 );
455
456 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
457 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
458 let is_focused = interaction
462 .map(|s| matches!(s, InteractionState::Focused))
463 .and(&ctx.focus_visible());
464 let is_disabled = effective_enabled.map(|on| !*on);
466
467 let style: SharedCheckboxStyle = self
468 .style_override
469 .clone()
470 .or_else(|| ctx.theme().style_slots.checkbox.clone())
471 .unwrap_or_else(|| {
472 Rc::new(crate::styles::RecipeCheckboxStyle::for_tokens(
473 &ctx.theme().input,
474 ))
475 });
476 let cfg = CheckboxStyleConfig {
477 state: style_state,
478 is_hovered,
479 is_pressed,
480 is_focused,
481 is_disabled,
482 variant,
483 };
484 let body_id = style.make_body(&cfg, ctx);
485
486 let mut row = HStack::new()
487 .spacing(cb_dims::CHECKBOX_LABEL_GAP)
488 .child(body_id);
489 if !self.labelled_externally
490 && let Some(ref label) = self.label
491 {
492 let label_widget = TextWidget::new(label.clone())
493 .style(self.label_style.clone())
494 .color(self.label_color.clone())
495 .single_line()
496 .a11y_hidden();
497 let label_id = ctx.add(label_widget);
498
499 let label_column_id = if let Some(ref caption) = self.caption {
500 let caption_widget = TextWidget::new(caption.clone())
501 .style(self.caption_style.clone())
502 .color(self.caption_color.clone())
503 .a11y_hidden();
504 let caption_id = ctx.add(caption_widget);
505 ctx.add(VStack::new().spacing(2.0).child(label_id).child(caption_id))
506 } else {
507 label_id
508 };
509 row = row.child(label_column_id);
510 }
511 if self.caption.is_some() && self.label.is_some() {
514 row = row.alignment(VAlignment::Top);
515 }
516
517 let style_recipe = crate::styles::CheckboxRecipe::for_tokens(&ctx.theme().input);
520 let row_id = ctx.add(row);
521 let root_id = ctx
522 .add(MinSize::new(style_recipe.box_hit_area, style_recipe.box_hit_area).child(row_id));
523
524 if let Some(content) = self.composite_tooltip_content.take() {
525 let delay = ctx.theme().motion.tooltip_delay_heavy;
526 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
527 } else if let Some(source) = self.rich_tooltip_source.take() {
528 let delay = ctx.theme().motion.tooltip_delay;
529 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
530 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
531 let delay = ctx.theme().motion.tooltip_delay;
532 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
533 }
534
535 self.root_child_id = Some(root_id);
536
537 let kind_tap = self.kind.clone();
539 let kind_key = self.kind.clone();
540 let kind_access = self.kind.clone();
541 let changed_tap = self.on_change.clone();
542 let changed_key = self.on_change.clone();
543 let changed_access = self.on_change.clone();
544 let int_tap = interaction.clone();
545 let int_hover = interaction.clone();
546 let int_key = interaction.clone();
547 let int_focus = interaction.clone();
548
549 let pointer_over = Rc::new(Cell::new(false));
557 crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
558
559 let handler_set = HandlerSet::new()
564 .on_tap({
565 let hovering = pointer_over.clone();
566 move |_pos, ctx: &mut EventContext| {
567 let now = kind_tap.toggle();
568 if let Some(ref f) = changed_tap {
569 f(now, ctx);
570 }
571 int_tap.set(if ctx.pointer_kind().hovers() {
575 hovering.set(true);
576 InteractionState::Hovered
577 } else {
578 InteractionState::Idle
579 });
580 }
581 })
582 .on_hover({
583 let hovering = pointer_over.clone();
584 move |entered: bool, _ctx: &mut EventContext| {
585 hovering.set(entered);
586 if entered {
587 int_hover.set(InteractionState::Hovered);
588 } else {
589 int_hover.set(InteractionState::Idle);
590 }
591 }
592 })
593 .on_key({
594 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
595 match event {
596 WidgetEvent::KeyDown {
597 key: Key::Space, ..
598 } => {
599 int_key.set(InteractionState::Pressed);
600 EventResponse::Handled
601 }
602 WidgetEvent::KeyUp {
603 key: Key::Space, ..
604 } => {
605 if int_key.get() != InteractionState::Pressed {
610 return EventResponse::Ignored;
611 }
612 let now = kind_key.toggle();
613 if let Some(ref f) = changed_key {
614 f(now, ctx);
615 }
616 int_key.set(InteractionState::Focused);
617 EventResponse::Handled
618 }
619 _ => EventResponse::Ignored,
620 }
621 }
622 })
623 .on_focus({
624 move |gained: bool, _ctx: &mut EventContext| {
625 if gained {
626 if int_focus.get() == InteractionState::Idle {
627 int_focus.set(InteractionState::Focused);
628 }
629 } else {
630 int_focus.set(InteractionState::Idle);
631 }
632 }
633 })
634 .on_access_action({
635 move |action: teksilo_core::accesskit::Action,
636 ctx: &mut EventContext|
637 -> EventResponse {
638 if action == teksilo_core::accesskit::Action::Click {
639 let now = kind_access.toggle();
640 if let Some(ref f) = changed_access {
641 f(now, ctx);
642 }
643 EventResponse::Handled
644 } else {
645 EventResponse::Ignored
646 }
647 }
648 })
649 .focusable(true)
651 .cursor(CursorIcon::Pointer);
652
653 ctx.apply_self_handlers(handler_set);
654
655 {
669 let kind_space = self.kind.clone();
670 let changed_space = self.on_change.clone();
671 ctx.set_keyboard_toggle(
672 ctx.self_id(),
673 std::rc::Rc::new(move |ctx: &mut EventContext| {
674 let now = kind_space.toggle();
675 if let Some(ref f) = changed_space {
676 f(now, ctx);
677 }
678 }),
679 );
680 }
681
682 vec![root_id]
683 }
684
685 fn layout_response(
686 &self,
687 proposal: SizeProposal,
688 ctx: &LayoutContext,
689 ) -> teksilo_core::widget::LayoutResponse {
690 if let Some(root) = self.root_child_id
691 && let Some(size) = ctx.child_size(root, proposal)
692 {
693 return (size).into();
694 }
695 proposal.resolve(0.0, 0.0).into()
696 }
697
698 fn place_children(
699 &self,
700 bounds: Rect,
701 _proposal: SizeProposal,
702 children: &mut [WidgetPlacement],
703 _ctx: &LayoutContext,
704 ) {
705 for child in children.iter_mut() {
706 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
707 child.size = Size::new(bounds.width, bounds.height);
708 }
709 }
710
711 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
712 debug_assert!(
713 self.label.is_some() || self.labelled_externally,
714 "Checkbox is missing an accessible label — \
715 screen readers will announce \"checkbox\" with no context. \
716 Call .label(...) when constructing the widget, or \
717 .labelled_externally() when embedded in a composite that \
718 owns the AT name."
719 );
720 builder.set_role(teksilo_core::accesskit::Role::CheckBox);
721 if let Some(ref label) = self.label {
722 builder.set_name(label.resolve_now());
723 }
724 if let Some(ref caption) = self.caption {
725 builder.set_description(caption.resolve_now());
726 }
727 match self.check_state() {
728 CheckState::Checked => builder.set_toggled(true),
729 CheckState::Unchecked => builder.set_toggled(false),
730 CheckState::Indeterminate => {
731 builder
733 .inner_mut()
734 .set_toggled(teksilo_core::accesskit::Toggled::Mixed);
735 }
736 }
737 builder.add_action(teksilo_core::accesskit::Action::Click);
740 builder.add_action(teksilo_core::accesskit::Action::Focus);
741 }
742
743 fn children(&self) -> Vec<WidgetId> {
744 self.root_child_id.into_iter().collect()
745 }
746}
747
748#[cfg(test)]
753mod tests {
754 use super::*;
755 use teksilo_core::event::Modifiers;
756 use teksilo_core::widget_tree::WidgetTree;
757 use teksilo_i18n::lit;
758
759 struct StyleSpy {
763 inner: teksilo_canvas::MockTextBackend,
764 shaped: Rc<std::cell::RefCell<Vec<(String, f32)>>>,
765 }
766
767 impl teksilo_canvas::TextBackend for StyleSpy {
768 fn layout_single_line(
769 &mut self,
770 text: &str,
771 style: &teksilo_tokens::TextStyle,
772 max_width: Option<f32>,
773 ) -> teksilo_canvas::TextLayout {
774 self.shaped
775 .borrow_mut()
776 .push((text.to_string(), style.size));
777 self.inner.layout_single_line(text, style, max_width)
778 }
779
780 fn ensure_glyphs(
781 &mut self,
782 layout: &teksilo_canvas::TextLayout,
783 ) -> Vec<teksilo_canvas::GlyphQuad> {
784 self.inner.ensure_glyphs(layout)
785 }
786 }
787
788 fn spy_tree() -> (WidgetTree, Rc<std::cell::RefCell<Vec<(String, f32)>>>) {
789 let shaped = Rc::new(std::cell::RefCell::new(Vec::new()));
790 let backend = StyleSpy {
791 inner: teksilo_canvas::MockTextBackend::new(),
792 shaped: shaped.clone(),
793 };
794 let tree = WidgetTree::new()
795 .with_theme(teksilo_core::presets::intui::light())
796 .with_text_backend(Rc::new(std::cell::RefCell::new(backend)));
797 (tree, shaped)
798 }
799
800 fn size_of(shaped: &Rc<std::cell::RefCell<Vec<(String, f32)>>>, text: &str) -> f32 {
801 shaped
802 .borrow()
803 .iter()
804 .rev()
805 .find(|(t, _)| t == text)
806 .unwrap_or_else(|| panic!("`{text}` was never shaped"))
807 .1
808 }
809
810 #[test]
811 fn label_and_caption_styles_reach_the_text() {
812 let big = teksilo_tokens::TextStyle {
813 size: 31.0,
814 ..Default::default()
815 };
816 let (mut tree, shaped) = spy_tree();
817 let _w = tree.add(
818 Checkbox::new(Signal::new(false))
819 .label(lit!("Lbl"))
820 .caption(lit!("Cap"))
821 .label_style(big.clone())
822 .caption_style(TextStyleRole::BodyBold),
823 );
824 tree.layout(SizeProposal::exact(300.0, 100.0));
825 tree.render();
826 assert_eq!(size_of(&shaped, "Lbl"), 31.0);
827 let heading = teksilo_core::presets::intui::light()
828 .typography
829 .body_bold
830 .size;
831 assert_eq!(size_of(&shaped, "Cap"), heading);
832
833 let (mut tree, shaped) = spy_tree();
834 let _w = tree.add(
835 Checkbox::new(Signal::new(false))
836 .label(lit!("Lbl"))
837 .caption(lit!("Cap")),
838 );
839 tree.layout(SizeProposal::exact(300.0, 100.0));
840 tree.render();
841 let typography = teksilo_core::presets::intui::light().typography;
842 assert_eq!(
843 size_of(&shaped, "Lbl"),
844 typography.body.size,
845 "default label"
846 );
847 assert_eq!(
848 size_of(&shaped, "Cap"),
849 typography.small.size,
850 "default caption"
851 );
852 }
853
854 #[test]
855 fn label_and_caption_colors_apply_and_a_role_still_dims_when_disabled() {
856 use std::cell::RefCell;
857 use teksilo_canvas::MockTextBackend;
858 let theme = teksilo_core::presets::intui::light();
859 let accent = theme.colors.accent.to_array();
860 let error = theme.colors.text_error.to_array();
861 let disabled = theme.colors.text_disabled.to_array();
862 let colors = |enabled: bool| {
863 let mut tree = WidgetTree::new()
864 .with_theme(theme.clone())
865 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
866 let _w = tree.add(
867 Checkbox::new(Signal::new(false))
868 .label(lit!("Lbl"))
869 .caption(lit!("Cap"))
870 .label_color(TextRole::Accent)
871 .caption_color(TextRole::Error)
872 .enabled(enabled),
873 );
874 tree.layout(SizeProposal::exact(300.0, 100.0));
875 tree.render()
876 .glyphs
877 .iter()
878 .map(|g| g.color)
879 .collect::<Vec<_>>()
880 };
881 let on = colors(true);
882 assert!(on.contains(&accent), "label takes label_color: {on:?}");
883 assert!(on.contains(&error), "caption takes caption_color: {on:?}");
884 let off = colors(false);
885 assert!(
886 !off.contains(&accent) && !off.contains(&error) && off.contains(&disabled),
887 "disabled dims the role colours: {off:?}"
888 );
889 }
890
891 #[test]
892 fn focus_ring_only_under_focus_visible() {
893 let theme = teksilo_core::presets::intui::light();
897 let ring = theme.colors.border_focused.to_array();
898 let mut tree = WidgetTree::new().with_theme(theme);
899 let cb = tree.add(Checkbox::new(Signal::new(false)).label(lit!("A")));
900 tree.layout(SizeProposal::exact(200.0, 80.0));
901
902 tree.focus(cb);
903 assert!(
904 !frame_has_color(&tree.render(), ring),
905 "no focus border while focus-visible is false (pointer modality)",
906 );
907
908 tree.press_key(Key::ArrowDown, Modifiers::NONE);
909 assert!(
910 frame_has_color(&tree.render(), ring),
911 "focus border shows under keyboard modality",
912 );
913 }
914
915 fn frame_has_color(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
917 frame.shapes.iter().any(|s| s.color == color)
918 || frame.decorations.iter().any(|d| d.color == color)
919 || frame.cosmetic_lines.iter().any(|l| l.color == color)
920 }
921
922 #[test]
925 fn click_toggles_bool_state() {
926 let checked = Signal::new(false);
927 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
928 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
929 tree.layout(SizeProposal::exact(200.0, 80.0));
930
931 assert!(!checked.get());
932 tree.click(cb);
933 assert!(checked.get());
934 tree.click(cb);
935 assert!(!checked.get());
936 }
937
938 #[test]
939 fn space_toggles_bool_state() {
940 let checked = Signal::new(false);
941 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
942 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
943 tree.layout(SizeProposal::exact(200.0, 80.0));
944
945 tree.focus(cb);
946 tree.press_key(Key::Space, Modifiers::NONE);
947 assert!(checked.get());
948 tree.press_key(Key::Space, Modifiers::NONE);
949 assert!(!checked.get());
950 }
951
952 #[test]
953 fn a_reader_is_told_each_change_as_it_happens() {
954 use crate::common::heard_test::{Heard, Listener};
959 use teksilo_core::accesskit::Toggled;
960
961 let state = Signal::new(CheckState::Unchecked);
962 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
963 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Accept")));
964 let p = SizeProposal::exact(200.0, 80.0);
965 tree.layout(p);
966 tree.focus(cb);
967 tree.layout(p);
968 let mut listener = Listener::attach(&mut tree);
969
970 tree.press_key(Key::Space, Modifiers::NONE);
971 tree.layout(p);
972 assert_eq!(
973 listener.heard(&mut tree),
974 vec![Heard::FocusToggled(Toggled::True)],
975 "Space"
976 );
977
978 tree.dispatch_access_action(
979 teksilo_core::accessibility::widget_id_to_node_id(cb),
980 teksilo_core::accesskit::Action::Click,
981 None,
982 &mut teksilo_core::NoopWindowOps,
983 );
984 tree.layout(p);
985 assert_eq!(
986 listener.heard(&mut tree),
987 vec![Heard::FocusToggled(Toggled::False)],
988 "a screen reader's click"
989 );
990
991 state.set(CheckState::Indeterminate);
992 tree.layout(p);
993 assert_eq!(
994 listener.heard(&mut tree),
995 vec![Heard::FocusToggled(Toggled::Mixed)],
996 "a write from the app"
997 );
998 }
999
1000 #[test]
1001 fn on_change_fires_for_every_user_path_and_reports_the_new_value() {
1002 use std::cell::RefCell;
1003 use std::rc::Rc;
1004
1005 let checked = Signal::new(false);
1006 let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
1007 let sink = seen.clone();
1008 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1009 let cb = tree.add(
1010 Checkbox::new(checked.clone())
1011 .label(lit!("Accept"))
1012 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
1013 );
1014 tree.layout(SizeProposal::exact(200.0, 80.0));
1015
1016 tree.click(cb);
1017 tree.focus(cb);
1018 tree.press_key(Key::Space, Modifiers::NONE);
1019 tree.dispatch_access_action(
1020 teksilo_core::accessibility::widget_id_to_node_id(cb),
1021 teksilo_core::accesskit::Action::Click,
1022 None,
1023 &mut teksilo_core::NoopWindowOps,
1024 );
1025
1026 assert_eq!(*seen.borrow(), vec![true, false, true]);
1029 assert!(checked.get());
1030 }
1031
1032 #[test]
1033 fn on_change_is_silent_for_a_programmatic_write() {
1034 use std::cell::Cell;
1035 use std::rc::Rc;
1036
1037 let checked = Signal::new(false);
1038 let fired = Rc::new(Cell::new(false));
1039 let sink = fired.clone();
1040 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1041 let _cb = tree.add(
1042 Checkbox::new(checked.clone())
1043 .label(lit!("Accept"))
1044 .on_change(move |_now, _ctx| sink.set(true)),
1045 );
1046 tree.layout(SizeProposal::exact(200.0, 80.0));
1047
1048 checked.set(true);
1051 assert!(!fired.get());
1052 }
1053
1054 #[test]
1055 fn on_change_reports_a_bool_from_a_tristate() {
1056 use std::cell::RefCell;
1057 use std::rc::Rc;
1058
1059 let state = Signal::new(CheckState::Indeterminate);
1060 let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
1061 let sink = seen.clone();
1062 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1063 let cb = tree.add(
1064 Checkbox::tristate(state.clone())
1065 .label(lit!("All"))
1066 .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
1067 );
1068 tree.layout(SizeProposal::exact(200.0, 80.0));
1069
1070 tree.click(cb);
1073 tree.click(cb);
1074 assert_eq!(*seen.borrow(), vec![true, false]);
1075 assert_eq!(state.get(), CheckState::Unchecked);
1076 }
1077
1078 #[test]
1079 fn lone_keyup_does_not_toggle() {
1080 let checked = Signal::new(false);
1083 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1084 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
1085 tree.layout(SizeProposal::exact(200.0, 80.0));
1086 tree.focus(cb);
1087
1088 tree.dispatch_event(WidgetEvent::KeyUp {
1089 key: Key::Space,
1090 modifiers: Modifiers::NONE,
1091 });
1092 assert!(!checked.get(), "a lone KeyUp must not toggle the checkbox");
1093
1094 tree.press_key(Key::Space, Modifiers::NONE);
1096 assert!(checked.get());
1097 }
1098
1099 #[test]
1100 fn disabled_ignores_click() {
1101 let checked = Signal::new(false);
1102 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1103 let cb = tree.add(
1104 Checkbox::new(checked.clone())
1105 .label(lit!("Accept"))
1106 .enabled(false),
1107 );
1108 tree.layout(SizeProposal::exact(200.0, 80.0));
1109
1110 tree.click(cb);
1111 assert!(!checked.get());
1112 }
1113
1114 #[test]
1115 fn two_state_accessibility() {
1116 let checked = Signal::new(true);
1117 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1118 let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
1119 tree.layout(SizeProposal::exact(200.0, 80.0));
1120
1121 let info = tree.accessibility_node(cb);
1122 assert_eq!(info.role(), teksilo_core::accesskit::Role::CheckBox);
1123 assert_eq!(info.name(), Some("Accept"));
1124 assert!(info.is_toggled());
1125 }
1126
1127 #[test]
1130 fn tristate_user_click_toggles_two_states() {
1131 let state = Signal::new(CheckState::Unchecked);
1136 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1137 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
1138 tree.layout(SizeProposal::exact(200.0, 80.0));
1139
1140 assert_eq!(state.get(), CheckState::Unchecked);
1141 tree.click(cb);
1142 assert_eq!(state.get(), CheckState::Checked);
1143 tree.click(cb);
1144 assert_eq!(state.get(), CheckState::Unchecked);
1145
1146 state.set(CheckState::Indeterminate);
1148 tree.click(cb);
1149 assert_eq!(state.get(), CheckState::Checked);
1150 }
1151
1152 #[test]
1153 fn tristate_space_toggles_two_states() {
1154 let state = Signal::new(CheckState::Unchecked);
1155 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1156 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
1157 tree.layout(SizeProposal::exact(200.0, 80.0));
1158
1159 tree.focus(cb);
1160 tree.press_key(Key::Space, Modifiers::NONE);
1161 assert_eq!(state.get(), CheckState::Checked);
1162 tree.press_key(Key::Space, Modifiers::NONE);
1163 assert_eq!(state.get(), CheckState::Unchecked);
1164 }
1165
1166 #[test]
1167 fn tristate_indeterminate_shows_filled_background() {
1168 let state = Signal::new(CheckState::Indeterminate);
1170 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1171 tree.add(Checkbox::tristate(state).label(lit!("Partial")));
1172 tree.layout(SizeProposal::exact(200.0, 80.0));
1173 let frame = tree.render();
1174 let primary = teksilo_core::presets::intui::light()
1175 .colors
1176 .accent
1177 .to_array();
1178 assert!(
1179 frame.shapes.iter().any(|s| s.color == primary),
1180 "indeterminate checkbox should have primary-colored background"
1181 );
1182 }
1183
1184 #[test]
1185 fn check_state_conversions() {
1186 assert_eq!(CheckState::from(true), CheckState::Checked);
1187 assert_eq!(CheckState::from(false), CheckState::Unchecked);
1188 assert!(CheckState::Checked.is_filled());
1189 assert!(CheckState::Indeterminate.is_filled());
1190 assert!(!CheckState::Unchecked.is_filled());
1191 }
1192
1193 #[test]
1194 fn disabled_has_disabled_colors() {
1195 let checked = Signal::new(true);
1196 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1197 tree.add(
1198 Checkbox::new(checked)
1199 .label(lit!("Disabled"))
1200 .enabled(false),
1201 );
1202 tree.layout(SizeProposal::exact(200.0, 80.0));
1203 let frame = tree.render();
1204 let disabled_fill = teksilo_core::presets::intui::light()
1205 .colors
1206 .accent_disabled
1207 .to_array();
1208 assert!(
1209 frame.shapes.iter().any(|s| s.color == disabled_fill),
1210 "disabled checkbox should render with disabled_fill color"
1211 );
1212 }
1213
1214 #[test]
1215 fn accessibility_has_actions() {
1216 let checked = Signal::new(false);
1217 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1218 let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
1219 tree.layout(SizeProposal::exact(200.0, 80.0));
1220 let info = tree.accessibility_node(cb);
1221 assert!(
1222 info.actions()
1223 .contains(&teksilo_core::accesskit::Action::Click)
1224 );
1225 }
1226
1227 struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
1234
1235 impl teksilo_core::styles::CheckboxStyle for PressProbe {
1236 fn make_body(
1237 &self,
1238 cfg: &teksilo_core::styles::CheckboxStyleConfig,
1239 ctx: &mut BuildContext,
1240 ) -> WidgetId {
1241 *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
1242 ctx.add(crate::primitives::FixedSize::new().width(19.0).height(19.0))
1243 }
1244 }
1245
1246 #[allow(clippy::type_complexity)]
1247 fn probed_checkbox_with_hover() -> (
1248 WidgetTree,
1249 WidgetId,
1250 Signal<bool>,
1251 Signal<bool>,
1252 Signal<bool>,
1253 ) {
1254 let probe: std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>> =
1255 std::rc::Rc::new(std::cell::RefCell::new(None));
1256 let checked = Signal::new(false);
1257 let mut theme = teksilo_core::presets::intui::light();
1258 theme.style_slots.checkbox = Some(std::rc::Rc::new(PressProbe(probe.clone())));
1259 let mut tree = WidgetTree::new().with_theme(theme);
1260 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Ready")));
1261 tree.layout(SizeProposal::exact(200.0, 60.0));
1262 let (pressed, hovered) = probe.borrow().clone().expect("style ran");
1263 (tree, cb, pressed, hovered, checked)
1264 }
1265
1266 fn probed_checkbox() -> (WidgetTree, WidgetId, Signal<bool>, Signal<bool>) {
1267 let (tree, cb, pressed, _hovered, checked) = probed_checkbox_with_hover();
1268 (tree, cb, pressed, checked)
1269 }
1270
1271 #[test]
1276 fn a_mouse_press_lights_the_pressed_state_and_the_release_toggles() {
1277 let (mut tree, cb, pressed, checked) = probed_checkbox();
1278 let at = tree.bounds(cb).center();
1279 tree.pointer_move(at);
1280 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
1281 assert!(pressed.get(), "the press shows");
1282 assert!(!checked.get(), "and has not toggled anything yet");
1283 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
1284 assert!(!pressed.get());
1285 assert!(checked.get(), "the release toggles");
1286 }
1287
1288 #[test]
1292 fn a_touch_tap_toggles_on_release() {
1293 use crate::button::press_test_support::{finger, touch};
1294 use teksilo_core::pointer::PointerPhase;
1295
1296 let (mut tree, cb, pressed, checked) = probed_checkbox();
1297 let at = tree.bounds(cb).center();
1298 let id = finger();
1299 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1300 assert!(pressed.get());
1301 assert!(!checked.get());
1302 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
1303 assert!(checked.get());
1304 assert!(!pressed.get());
1305 }
1306
1307 #[test]
1310 fn a_touch_press_disarms_on_slide_off_and_re_arms_on_re_entry() {
1311 use crate::button::press_test_support::{finger, touch};
1312 use teksilo_core::pointer::PointerPhase;
1313
1314 let (mut tree, cb, pressed, checked) = probed_checkbox();
1315 let bounds = tree.bounds(cb);
1316 let at = bounds.center();
1317 let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 80.0);
1318 let id = finger();
1319 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1320 tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
1321 assert!(!pressed.get(), "the press left its target");
1322 tree.dispatch_pointer(touch(id, PointerPhase::Move, at, 40));
1323 assert!(pressed.get(), "and came back");
1324 tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 60));
1325 assert!(!checked.get(), "a release off the control toggles nothing");
1326 }
1327
1328 #[test]
1338 fn a_mouse_toggle_rests_hovered_and_a_finger_toggle_rests_idle() {
1339 use crate::button::press_test_support::touch_tap;
1340
1341 let (mut tree, cb, pressed, hovered, checked) = probed_checkbox_with_hover();
1342 let at = tree.bounds(cb).center();
1343 tree.pointer_move(at);
1344 assert!(hovered.get(), "the pointer arrived over the box");
1345 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
1346 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
1347 assert!(checked.get(), "the release toggled");
1348 assert!(!pressed.get());
1349 assert!(
1350 hovered.get(),
1351 "a mouse that clicked the box is still on it, so it rests hovered",
1352 );
1353
1354 let (mut tree, cb, pressed, hovered, checked) = probed_checkbox_with_hover();
1355 let at = tree.bounds(cb).center();
1356 touch_tap(&mut tree, at);
1357 assert!(checked.get(), "the contact toggled on its release");
1358 assert!(!pressed.get());
1359 assert!(
1360 !hovered.get(),
1361 "a finger leaves nothing behind, so the box must rest idle",
1362 );
1363 }
1364
1365 #[test]
1368 fn the_checkbox_hit_box_clears_the_conformance_floor_at_compact() {
1369 let theme = teksilo_core::presets::intui::light();
1370 let floor = theme.input.min_target_conformance;
1371 let mut tree = WidgetTree::new().with_theme(theme);
1372 let cb = tree.add(Checkbox::new(Signal::new(false)));
1373 tree.layout(SizeProposal::exact(200.0, 60.0));
1374 let b = tree.bounds(cb);
1375 assert!(
1376 b.width >= floor && b.height >= floor,
1377 "a bare Compact Checkbox measured {}x{}",
1378 b.width,
1379 b.height,
1380 );
1381 }
1382}