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 vec![root_id]
514 }
515
516 fn layout_response(
517 &self,
518 proposal: SizeProposal,
519 ctx: &LayoutContext,
520 ) -> teksilo_core::widget::LayoutResponse {
521 if let Some(root) = self.root_child_id
522 && let Some(size) = ctx.child_size(root, proposal)
523 {
524 return (size).into();
525 }
526 proposal.resolve(0.0, 0.0).into()
527 }
528
529 fn place_children(
530 &self,
531 bounds: Rect,
532 _proposal: SizeProposal,
533 children: &mut [WidgetPlacement],
534 _ctx: &LayoutContext,
535 ) {
536 for child in children.iter_mut() {
537 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
538 child.size = Size::new(bounds.width, bounds.height);
539 }
540 }
541
542 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
543 debug_assert!(
544 self.label.is_some() || self.labels_hidden,
545 "Checkbox is missing an accessible label — \
546 screen readers will announce \"checkbox\" with no context. \
547 Call .label(...) when constructing the widget, or \
548 .labels_hidden(true) when embedded in a composite that \
549 owns the AT name."
550 );
551 builder.set_role(teksilo_core::accesskit::Role::CheckBox);
552 if let Some(ref label) = self.label {
553 builder.set_name(label.resolve_now());
554 }
555 if let Some(ref caption) = self.caption {
556 builder.set_description(caption.resolve_now());
557 }
558 match self.check_state() {
559 CheckState::Checked => builder.set_toggled(true),
560 CheckState::Unchecked => builder.set_toggled(false),
561 CheckState::Indeterminate => {
562 builder
564 .inner_mut()
565 .set_toggled(teksilo_core::accesskit::Toggled::Mixed);
566 }
567 }
568 builder.add_action(teksilo_core::accesskit::Action::Click);
571 builder.add_action(teksilo_core::accesskit::Action::Focus);
572 }
573
574 fn children(&self) -> Vec<WidgetId> {
575 self.root_child_id.into_iter().collect()
576 }
577}
578
579#[cfg(test)]
584mod tests {
585 use super::*;
586 use teksilo_core::event::Modifiers;
587 use teksilo_core::widget_tree::WidgetTree;
588 use teksilo_i18n::lit;
589
590 #[test]
591 fn focus_ring_only_under_focus_visible() {
592 let theme = teksilo_core::presets::intui::light();
596 let ring = theme.colors.border_focused.to_array();
597 let mut tree = WidgetTree::new().with_theme(theme);
598 let cb = tree.add(Checkbox::new(Signal::new(false)).label(lit!("A")));
599 tree.layout(SizeProposal::exact(200.0, 80.0));
600
601 tree.focus(cb);
602 assert!(
603 !frame_has_color(&tree.render(), ring),
604 "no focus border while focus-visible is false (pointer modality)",
605 );
606
607 tree.press_key(Key::ArrowDown, Modifiers::NONE);
608 assert!(
609 frame_has_color(&tree.render(), ring),
610 "focus border shows under keyboard modality",
611 );
612 }
613
614 fn frame_has_color(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
616 frame.shapes.iter().any(|s| s.color == color)
617 || frame.decorations.iter().any(|d| d.color == color)
618 || frame.cosmetic_lines.iter().any(|l| l.color == color)
619 }
620
621 #[test]
624 fn click_toggles_bool_state() {
625 let checked = Signal::new(false);
626 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
627 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
628 tree.layout(SizeProposal::exact(200.0, 80.0));
629
630 assert!(!checked.get());
631 tree.click(cb);
632 assert!(checked.get());
633 tree.click(cb);
634 assert!(!checked.get());
635 }
636
637 #[test]
638 fn space_toggles_bool_state() {
639 let checked = Signal::new(false);
640 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
641 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
642 tree.layout(SizeProposal::exact(200.0, 80.0));
643
644 tree.focus(cb);
645 tree.press_key(Key::Space, Modifiers::NONE);
646 assert!(checked.get());
647 tree.press_key(Key::Space, Modifiers::NONE);
648 assert!(!checked.get());
649 }
650
651 #[test]
652 fn lone_keyup_does_not_toggle() {
653 let checked = Signal::new(false);
656 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
657 let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
658 tree.layout(SizeProposal::exact(200.0, 80.0));
659 tree.focus(cb);
660
661 tree.dispatch_event(WidgetEvent::KeyUp {
662 key: Key::Space,
663 modifiers: Modifiers::NONE,
664 });
665 assert!(!checked.get(), "a lone KeyUp must not toggle the checkbox");
666
667 tree.press_key(Key::Space, Modifiers::NONE);
669 assert!(checked.get());
670 }
671
672 #[test]
673 fn disabled_ignores_click() {
674 let checked = Signal::new(false);
675 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
676 let cb = tree.add(
677 Checkbox::new(checked.clone())
678 .label(lit!("Accept"))
679 .enabled(false),
680 );
681 tree.layout(SizeProposal::exact(200.0, 80.0));
682
683 tree.click(cb);
684 assert!(!checked.get());
685 }
686
687 #[test]
688 fn two_state_accessibility() {
689 let checked = Signal::new(true);
690 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
691 let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
692 tree.layout(SizeProposal::exact(200.0, 80.0));
693
694 let info = tree.accessibility_node(cb);
695 assert_eq!(info.role(), teksilo_core::accesskit::Role::CheckBox);
696 assert_eq!(info.name(), Some("Accept"));
697 assert!(info.is_toggled());
698 }
699
700 #[test]
703 fn tristate_user_click_toggles_two_states() {
704 let state = Signal::new(CheckState::Unchecked);
709 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
710 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
711 tree.layout(SizeProposal::exact(200.0, 80.0));
712
713 assert_eq!(state.get(), CheckState::Unchecked);
714 tree.click(cb);
715 assert_eq!(state.get(), CheckState::Checked);
716 tree.click(cb);
717 assert_eq!(state.get(), CheckState::Unchecked);
718
719 state.set(CheckState::Indeterminate);
721 tree.click(cb);
722 assert_eq!(state.get(), CheckState::Checked);
723 }
724
725 #[test]
726 fn tristate_space_toggles_two_states() {
727 let state = Signal::new(CheckState::Unchecked);
728 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
729 let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
730 tree.layout(SizeProposal::exact(200.0, 80.0));
731
732 tree.focus(cb);
733 tree.press_key(Key::Space, Modifiers::NONE);
734 assert_eq!(state.get(), CheckState::Checked);
735 tree.press_key(Key::Space, Modifiers::NONE);
736 assert_eq!(state.get(), CheckState::Unchecked);
737 }
738
739 #[test]
740 fn tristate_indeterminate_shows_filled_background() {
741 let state = Signal::new(CheckState::Indeterminate);
743 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
744 tree.add(Checkbox::tristate(state).label(lit!("Partial")));
745 tree.layout(SizeProposal::exact(200.0, 80.0));
746 let frame = tree.render();
747 let primary = teksilo_core::presets::intui::light()
748 .colors
749 .accent
750 .to_array();
751 assert!(
752 frame.shapes.iter().any(|s| s.color == primary),
753 "indeterminate checkbox should have primary-colored background"
754 );
755 }
756
757 #[test]
758 fn check_state_conversions() {
759 assert_eq!(CheckState::from(true), CheckState::Checked);
760 assert_eq!(CheckState::from(false), CheckState::Unchecked);
761 assert!(CheckState::Checked.is_filled());
762 assert!(CheckState::Indeterminate.is_filled());
763 assert!(!CheckState::Unchecked.is_filled());
764 }
765
766 #[test]
767 fn disabled_has_disabled_colors() {
768 let checked = Signal::new(true);
769 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
770 tree.add(
771 Checkbox::new(checked)
772 .label(lit!("Disabled"))
773 .enabled(false),
774 );
775 tree.layout(SizeProposal::exact(200.0, 80.0));
776 let frame = tree.render();
777 let disabled_fill = teksilo_core::presets::intui::light()
778 .colors
779 .accent_disabled
780 .to_array();
781 assert!(
782 frame.shapes.iter().any(|s| s.color == disabled_fill),
783 "disabled checkbox should render with disabled_fill color"
784 );
785 }
786
787 #[test]
788 fn accessibility_has_actions() {
789 let checked = Signal::new(false);
790 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
791 let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
792 tree.layout(SizeProposal::exact(200.0, 80.0));
793 let info = tree.accessibility_node(cb);
794 assert!(
795 info.actions()
796 .contains(&teksilo_core::accesskit::Action::Click)
797 );
798 }
799}