1use std::rc::Rc;
38
39use teksilo_canvas::{Rect, Size, SizeProposal};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::event::{EventResponse, Key, WidgetEvent};
43use teksilo_core::signal::{Prop, Signal};
44use teksilo_core::styles::{
45 CheckboxState, CheckboxStyleConfig, CheckboxVariant, SharedCheckboxStyle,
46};
47use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
48use teksilo_core::widget_builder::HandlerSet;
49use teksilo_core::widget_id::WidgetId;
50use teksilo_data::CheckState;
51use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
52
53use crate::button::InteractionState;
54use crate::primitives::{HStack, MinSize, TextWidget, VStack};
55use teksilo_i18n::LocalizedString;
56
57#[derive(Clone)]
63enum CheckKind {
64 TwoState(Signal<bool>),
65 TriState(Signal<CheckState>),
66}
67
68impl CheckKind {
69 fn check_state(&self) -> CheckState {
70 match self {
71 CheckKind::TwoState(s) => CheckState::from(s.get()),
72 CheckKind::TriState(s) => s.get(),
73 }
74 }
75
76 fn check_state_signal(&self) -> Signal<CheckState> {
82 match self {
83 CheckKind::TwoState(s) => s.map(|b| CheckState::from(*b)),
84 CheckKind::TriState(s) => s.clone(),
85 }
86 }
87
88 fn toggle(&self) {
89 match self {
90 CheckKind::TwoState(s) => {
91 let current = s.get();
92 s.set(!current);
93 }
94 CheckKind::TriState(s) => {
95 let current = s.get();
103 let next = if matches!(current, CheckState::Checked) {
104 CheckState::Unchecked
105 } else {
106 CheckState::Checked
107 };
108 s.set(next);
109 }
110 }
111 }
112}
113
114impl std::fmt::Debug for CheckKind {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 CheckKind::TwoState(_) => write!(f, "TwoState"),
118 CheckKind::TriState(_) => write!(f, "TriState"),
119 }
120 }
121}
122
123pub struct Checkbox {
129 label: Option<LocalizedString>,
130 caption: Option<LocalizedString>,
131 kind: CheckKind,
132 enabled: Prop<bool>,
137 labels_hidden: bool,
144 tooltip_text: Option<LocalizedString>,
145 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
146 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
147 variant: CheckboxVariant,
148 style_override: Option<SharedCheckboxStyle>,
149 root_child_id: Option<WidgetId>,
150}
151
152impl Checkbox {
153 pub fn new(checked: Signal<bool>) -> Self {
155 Self {
156 label: None,
157 caption: None,
158 kind: CheckKind::TwoState(checked),
159 enabled: Prop::Static(true),
160 labels_hidden: false,
161 tooltip_text: None,
162 rich_tooltip_source: None,
163 composite_tooltip_content: None,
164 variant: CheckboxVariant::default(),
165 style_override: None,
166 root_child_id: None,
167 }
168 }
169
170 pub fn tristate(state: Signal<CheckState>) -> Self {
178 Self {
179 label: None,
180 caption: None,
181 kind: CheckKind::TriState(state),
182 enabled: Prop::Static(true),
183 labels_hidden: false,
184 tooltip_text: None,
185 rich_tooltip_source: None,
186 composite_tooltip_content: None,
187 variant: CheckboxVariant::default(),
188 style_override: None,
189 root_child_id: None,
190 }
191 }
192
193 pub fn labels_hidden(mut self, hidden: bool) -> Self {
210 self.labels_hidden = hidden;
211 self
212 }
213
214 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
217 let ls: LocalizedString = label.into();
218 self.label = Some(ls);
219 self
220 }
221
222 pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
226 let ls: LocalizedString = text.into();
227 self.caption = Some(ls);
228 self
229 }
230
231 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
235 self.enabled = enabled.into();
236 self
237 }
238
239 pub fn variant(mut self, variant: CheckboxVariant) -> Self {
244 self.variant = variant;
245 self
246 }
247
248 pub fn style(mut self, style: impl teksilo_core::styles::CheckboxStyle) -> Self {
252 self.style_override = Some(Rc::new(style));
253 self
254 }
255
256 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
259 self.tooltip_text = Some(text.into());
260 self.rich_tooltip_source = None;
261 self.composite_tooltip_content = None;
262 self
263 }
264
265 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
268 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
269 self.tooltip_text = None;
270 self.composite_tooltip_content = None;
271 self
272 }
273
274 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
276 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
277 self.tooltip_text = None;
278 self.composite_tooltip_content = None;
279 self
280 }
281
282 pub fn composite_tooltip(
285 mut self,
286 content: impl teksilo_core::widget::Widget + 'static,
287 ) -> Self {
288 self.composite_tooltip_content = Some(Box::new(content));
289 self.tooltip_text = None;
290 self.rich_tooltip_source = None;
291 self
292 }
293
294 fn check_state(&self) -> CheckState {
295 self.kind.check_state()
296 }
297}
298
299impl std::fmt::Debug for Checkbox {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 f.debug_struct("Checkbox")
302 .field("label", &self.label)
303 .field("caption", &self.caption)
304 .field("kind", &self.kind)
305 .field("enabled", &self.enabled.get())
306 .finish()
307 }
308}
309
310impl Widget for Checkbox {
317 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
318 use crate::styles::recipe_checkbox_style as cb_dims;
319 let kind = self.kind.clone();
320 let variant = self.variant;
321 let self_id = ctx.self_id();
322
323 ctx.enabled_when(self_id, self.enabled.clone());
328 let effective_enabled = ctx.effective_enabled_signal(self_id);
329
330 let interaction = ctx.signal(InteractionState::Idle);
333
334 let style_state = kind.check_state_signal().map(|cs| match *cs {
339 CheckState::Unchecked => CheckboxState::Unchecked,
340 CheckState::Checked => CheckboxState::Checked,
341 CheckState::Indeterminate => CheckboxState::Indeterminate,
342 });
343
344 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
345 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
346 let is_focused = interaction
350 .map(|s| matches!(s, InteractionState::Focused))
351 .and(&ctx.focus_visible());
352 let is_disabled = effective_enabled.map(|on| !*on);
354
355 let style: SharedCheckboxStyle = self
356 .style_override
357 .clone()
358 .or_else(|| ctx.theme().style_slots.checkbox.clone())
359 .unwrap_or_else(|| Rc::new(crate::styles::RecipeCheckboxStyle::default()));
360 let cfg = CheckboxStyleConfig {
361 state: style_state,
362 is_hovered,
363 is_pressed,
364 is_focused,
365 is_disabled,
366 variant,
367 };
368 let body_id = style.make_body(&cfg, ctx);
369
370 let mut row = HStack::new()
371 .spacing(cb_dims::CHECKBOX_LABEL_GAP)
372 .add_child(body_id);
373 if !self.labels_hidden
374 && let Some(ref label) = self.label
375 {
376 let label_widget = TextWidget::new(label.clone())
377 .style(TextStyleRole::Body)
378 .color(TextRole::Primary)
379 .single_line()
380 .a11y_hidden();
381 let label_id = ctx.add(label_widget);
382
383 let label_column_id = if let Some(ref caption) = self.caption {
384 let caption_widget = TextWidget::new(caption.clone())
385 .style(TextStyleRole::Small)
386 .color(TextRole::Secondary)
387 .a11y_hidden();
388 let caption_id = ctx.add(caption_widget);
389 ctx.add(
390 VStack::new()
391 .spacing(2.0)
392 .add_child(label_id)
393 .add_child(caption_id),
394 )
395 } else {
396 label_id
397 };
398 row = row.add_child(label_column_id);
399 }
400 if self.caption.is_some() && self.label.is_some() {
403 row = row.alignment(VAlignment::Top);
404 }
405
406 let row_id = ctx.add(row);
407 let root_id = ctx.add(
408 MinSize::new(
409 cb_dims::CHECKBOX_BOX_HIT_AREA,
410 cb_dims::CHECKBOX_BOX_HIT_AREA,
411 )
412 .child_id(row_id),
413 );
414
415 if let Some(content) = self.composite_tooltip_content.take() {
416 let delay = ctx.theme().motion.tooltip_delay_heavy;
417 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
418 } else if let Some(source) = self.rich_tooltip_source.take() {
419 let delay = ctx.theme().motion.tooltip_delay;
420 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
421 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
422 let delay = ctx.theme().motion.tooltip_delay;
423 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
424 }
425
426 self.root_child_id = Some(root_id);
427
428 let kind_tap = self.kind.clone();
430 let kind_key = self.kind.clone();
431 let kind_access = self.kind.clone();
432 let int_tap = interaction.clone();
433 let int_hover = interaction.clone();
434 let int_key = interaction.clone();
435 let int_focus = interaction.clone();
436
437 let handler_set = HandlerSet::new()
442 .on_tap({
443 move |_pos, _ctx: &mut EventContext| {
444 kind_tap.toggle();
445 int_tap.set(InteractionState::Hovered);
446 }
447 })
448 .on_hover({
449 move |entered: bool, _ctx: &mut EventContext| {
450 if entered {
451 int_hover.set(InteractionState::Hovered);
452 } else {
453 int_hover.set(InteractionState::Idle);
454 }
455 }
456 })
457 .on_key({
458 move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
459 match event {
460 WidgetEvent::KeyDown {
461 key: Key::Space, ..
462 } => {
463 int_key.set(InteractionState::Pressed);
464 EventResponse::Handled
465 }
466 WidgetEvent::KeyUp {
467 key: Key::Space, ..
468 } => {
469 if int_key.get() != InteractionState::Pressed {
474 return EventResponse::Ignored;
475 }
476 kind_key.toggle();
477 int_key.set(InteractionState::Focused);
478 EventResponse::Handled
479 }
480 _ => EventResponse::Ignored,
481 }
482 }
483 })
484 .on_focus({
485 move |gained: bool, _ctx: &mut EventContext| {
486 if gained {
487 if int_focus.get() == InteractionState::Idle {
488 int_focus.set(InteractionState::Focused);
489 }
490 } else {
491 int_focus.set(InteractionState::Idle);
492 }
493 }
494 })
495 .on_access_action({
496 move |action: teksilo_core::accesskit::Action,
497 _ctx: &mut EventContext|
498 -> EventResponse {
499 if action == teksilo_core::accesskit::Action::Click {
500 kind_access.toggle();
501 EventResponse::Handled
502 } else {
503 EventResponse::Ignored
504 }
505 }
506 })
507 .focusable(true)
509 .cursor(CursorIcon::Pointer);
510
511 ctx.apply_self_handlers(handler_set);
512
513 {
527 let kind_space = self.kind.clone();
528 ctx.set_keyboard_toggle(ctx.self_id(), std::rc::Rc::new(move || kind_space.toggle()));
529 }
530
531 vec![root_id]
532 }
533
534 fn layout_response(
535 &self,
536 proposal: SizeProposal,
537 ctx: &LayoutContext,
538 ) -> teksilo_core::widget::LayoutResponse {
539 if let Some(root) = self.root_child_id
540 && let Some(size) = ctx.child_size(root, proposal)
541 {
542 return (size).into();
543 }
544 proposal.resolve(0.0, 0.0).into()
545 }
546
547 fn place_children(
548 &self,
549 bounds: Rect,
550 _proposal: SizeProposal,
551 children: &mut [WidgetPlacement],
552 _ctx: &LayoutContext,
553 ) {
554 for child in children.iter_mut() {
555 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
556 child.size = Size::new(bounds.width, bounds.height);
557 }
558 }
559
560 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
561 debug_assert!(
562 self.label.is_some() || self.labels_hidden,
563 "Checkbox is missing an accessible label — \
564 screen readers will announce \"checkbox\" with no context. \
565 Call .label(...) when constructing the widget, or \
566 .labels_hidden(true) when embedded in a composite that \
567 owns the AT name."
568 );
569 builder.set_role(teksilo_core::accesskit::Role::CheckBox);
570 if let Some(ref label) = self.label {
571 builder.set_name(label.resolve_now());
572 }
573 if let Some(ref caption) = self.caption {
574 builder.set_description(caption.resolve_now());
575 }
576 match self.check_state() {
577 CheckState::Checked => builder.set_toggled(true),
578 CheckState::Unchecked => builder.set_toggled(false),
579 CheckState::Indeterminate => {
580 builder
582 .inner_mut()
583 .set_toggled(teksilo_core::accesskit::Toggled::Mixed);
584 }
585 }
586 builder.add_action(teksilo_core::accesskit::Action::Click);
589 builder.add_action(teksilo_core::accesskit::Action::Focus);
590 }
591
592 fn children(&self) -> Vec<WidgetId> {
593 self.root_child_id.into_iter().collect()
594 }
595}
596
597#[cfg(test)]
602mod tests {
603 use super::*;
604 use teksilo_core::event::Modifiers;
605 use teksilo_core::widget_tree::WidgetTree;
606 use teksilo_i18n::lit;
607
608 #[test]
609 fn focus_ring_only_under_focus_visible() {
610 let theme = teksilo_core::presets::intui::light();
614 let ring = theme.colors.border_focused.to_array();
615 let mut tree = WidgetTree::new().with_theme(theme);
616 let cb = tree.add(Checkbox::new(Signal::new(false)).label(lit!("A")));
617 tree.layout(SizeProposal::exact(200.0, 80.0));
618
619 tree.focus(cb);
620 assert!(
621 !frame_has_color(&tree.render(), ring),
622 "no focus border while focus-visible is false (pointer modality)",
623 );
624
625 tree.press_key(Key::ArrowDown, Modifiers::NONE);
626 assert!(
627 frame_has_color(&tree.render(), ring),
628 "focus border shows under keyboard modality",
629 );
630 }
631
632 fn frame_has_color(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
634 frame.shapes.iter().any(|s| s.color == color)
635 || frame.decorations.iter().any(|d| d.color == color)
636 || frame.cosmetic_lines.iter().any(|l| l.color == color)
637 }
638
639 #[test]
642 fn click_toggles_bool_state() {
643 let checked = Signal::new(false);
644 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
645 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
646 tree.layout(SizeProposal::exact(200.0, 80.0));
647
648 assert!(!checked.get());
649 tree.click(cb);
650 assert!(checked.get());
651 tree.click(cb);
652 assert!(!checked.get());
653 }
654
655 #[test]
656 fn space_toggles_bool_state() {
657 let checked = Signal::new(false);
658 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
659 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
660 tree.layout(SizeProposal::exact(200.0, 80.0));
661
662 tree.focus(cb);
663 tree.press_key(Key::Space, Modifiers::NONE);
664 assert!(checked.get());
665 tree.press_key(Key::Space, Modifiers::NONE);
666 assert!(!checked.get());
667 }
668
669 #[test]
670 fn lone_keyup_does_not_toggle() {
671 let checked = Signal::new(false);
674 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
675 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
676 tree.layout(SizeProposal::exact(200.0, 80.0));
677 tree.focus(cb);
678
679 tree.dispatch_event(WidgetEvent::KeyUp {
680 key: Key::Space,
681 modifiers: Modifiers::NONE,
682 });
683 assert!(!checked.get(), "a lone KeyUp must not toggle the checkbox");
684
685 tree.press_key(Key::Space, Modifiers::NONE);
687 assert!(checked.get());
688 }
689
690 #[test]
691 fn disabled_ignores_click() {
692 let checked = Signal::new(false);
693 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
694 let cb = tree.add(
695 Checkbox::new(checked.clone())
696 .label(lit!("Accept"))
697 .enabled(false),
698 );
699 tree.layout(SizeProposal::exact(200.0, 80.0));
700
701 tree.click(cb);
702 assert!(!checked.get());
703 }
704
705 #[test]
706 fn two_state_accessibility() {
707 let checked = Signal::new(true);
708 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
709 let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
710 tree.layout(SizeProposal::exact(200.0, 80.0));
711
712 let info = tree.accessibility_node(cb);
713 assert_eq!(info.role(), teksilo_core::accesskit::Role::CheckBox);
714 assert_eq!(info.name(), Some("Accept"));
715 assert!(info.is_toggled());
716 }
717
718 #[test]
721 fn tristate_user_click_toggles_two_states() {
722 let state = Signal::new(CheckState::Unchecked);
727 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
728 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
729 tree.layout(SizeProposal::exact(200.0, 80.0));
730
731 assert_eq!(state.get(), CheckState::Unchecked);
732 tree.click(cb);
733 assert_eq!(state.get(), CheckState::Checked);
734 tree.click(cb);
735 assert_eq!(state.get(), CheckState::Unchecked);
736
737 state.set(CheckState::Indeterminate);
739 tree.click(cb);
740 assert_eq!(state.get(), CheckState::Checked);
741 }
742
743 #[test]
744 fn tristate_space_toggles_two_states() {
745 let state = Signal::new(CheckState::Unchecked);
746 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
747 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
748 tree.layout(SizeProposal::exact(200.0, 80.0));
749
750 tree.focus(cb);
751 tree.press_key(Key::Space, Modifiers::NONE);
752 assert_eq!(state.get(), CheckState::Checked);
753 tree.press_key(Key::Space, Modifiers::NONE);
754 assert_eq!(state.get(), CheckState::Unchecked);
755 }
756
757 #[test]
758 fn tristate_indeterminate_shows_filled_background() {
759 let state = Signal::new(CheckState::Indeterminate);
761 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
762 tree.add(Checkbox::tristate(state).label(lit!("Partial")));
763 tree.layout(SizeProposal::exact(200.0, 80.0));
764 let frame = tree.render();
765 let primary = teksilo_core::presets::intui::light()
766 .colors
767 .accent
768 .to_array();
769 assert!(
770 frame.shapes.iter().any(|s| s.color == primary),
771 "indeterminate checkbox should have primary-colored background"
772 );
773 }
774
775 #[test]
776 fn check_state_conversions() {
777 assert_eq!(CheckState::from(true), CheckState::Checked);
778 assert_eq!(CheckState::from(false), CheckState::Unchecked);
779 assert!(CheckState::Checked.is_filled());
780 assert!(CheckState::Indeterminate.is_filled());
781 assert!(!CheckState::Unchecked.is_filled());
782 }
783
784 #[test]
785 fn disabled_has_disabled_colors() {
786 let checked = Signal::new(true);
787 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
788 tree.add(
789 Checkbox::new(checked)
790 .label(lit!("Disabled"))
791 .enabled(false),
792 );
793 tree.layout(SizeProposal::exact(200.0, 80.0));
794 let frame = tree.render();
795 let disabled_fill = teksilo_core::presets::intui::light()
796 .colors
797 .accent_disabled
798 .to_array();
799 assert!(
800 frame.shapes.iter().any(|s| s.color == disabled_fill),
801 "disabled checkbox should render with disabled_fill color"
802 );
803 }
804
805 #[test]
806 fn accessibility_has_actions() {
807 let checked = Signal::new(false);
808 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
809 let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
810 tree.layout(SizeProposal::exact(200.0, 80.0));
811 let info = tree.accessibility_node(cb);
812 assert!(
813 info.actions()
814 .contains(&teksilo_core::accesskit::Action::Click)
815 );
816 }
817}