Skip to main content

teksilo_widgets/
checkbox.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Checkbox — a two-state or tristate checkbox with an optional label.
5//!
6//! `Checkbox` renders a square (or rounded-square / circle) toggle box
7//! alongside an optional label and caption. Two modes are supported:
8//!
9//! - **Two-state** ([`Checkbox::new`]): toggles a `Signal<bool>` between
10//!   `true` (checked) and `false` (unchecked) on click or Space.
11//! - **Tristate** ([`Checkbox::tristate`]): cycles a `Signal<CheckState>`
12//!   between `Checked` and `Unchecked` on user interaction; the
13//!   `Indeterminate` state is set only by external sources such as
14//!   `TreeCheckedModel` aggregation — clicking from `Indeterminate` goes
15//!   to `Checked`, not a further third state.
16//!
17//! Chrome (box shape, fill, focus ring) is driven by the active
18//! `CheckboxStyle`; three visual variants are available via
19//! [`CheckboxVariant`].
20//!
21//! ## Accessibility
22//!
23//! Announces as `Role::CheckBox`. A label is required in debug builds
24//! unless `.labels_hidden(true)` is set (for embedding inside a composite
25//! row that owns the AT name). Keyboard: Space toggles; lone-KeyUp guard
26//! prevents spurious toggle when focus is restored after a shortcut.
27//!
28//! ```rust
29//! # use teksilo_widgets::Checkbox;
30//! # use teksilo_core::signal::Signal;
31//! # use teksilo_i18n::lit;
32//! let checked = Signal::new(false);
33//! let _cb = Checkbox::new(checked)
34//!     .label(lit!("Accept terms and conditions"));
35//! ```
36
37use 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// ---------------------------------------------------------------------------
58// Internal state wrapper
59// ---------------------------------------------------------------------------
60
61/// Wraps either a bool state (two-state) or a CheckState state (tristate).
62#[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    /// A reactive `Signal<CheckState>` that tracks the underlying
77    /// mutable root of either variant. Used to compose multi-source
78    /// derived visuals (e.g. box colors that depend on both interaction
79    /// state and check state) so they dirty-track the check-state
80    /// source in addition to the interaction source.
81    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                // User clicks toggle Checked ↔ Unchecked. The
96                // `Indeterminate` state is reserved for external
97                // sources (e.g. `TreeCheckedModel` aggregation when
98                // descendants are mixed) — the user can't *set* a
99                // checkbox to "half"; clicking from Indeterminate
100                // checks the whole. This matches the Outlook /
101                // Files-app folder-checkbox semantic.
102                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
123// ---------------------------------------------------------------------------
124// Checkbox
125// ---------------------------------------------------------------------------
126
127/// A checkbox that toggles a `Signal<bool>` or cycles a `Signal<CheckState>`.
128pub struct Checkbox {
129    label: Option<LocalizedString>,
130    caption: Option<LocalizedString>,
131    kind: CheckKind,
132    /// Enabled state, static or reactive; forwarded into the arena at
133    /// build time. After build the arena is the single source of
134    /// truth — see `IconButton::enabled` for the architectural
135    /// rationale.
136    enabled: Prop<bool>,
137    /// When true, the checkbox renders only the box (no visual label /
138    /// caption next to it) AND its `accessibility(builder)` skips the
139    /// missing-label `debug_assert` — the parent composite is responsible
140    /// for providing the AT name (typically via its own `set_name(...)`
141    /// or an `access_label*` override). Used by `StandardListItem` /
142    /// `StandardTreeItem`.
143    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    /// Create a two-state checkbox bound to a `Signal<bool>`.
154    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    /// Create a tristate checkbox bound to a `Signal<CheckState>`.
171    ///
172    /// User clicks toggle Checked ↔ Unchecked (clicking from Indeterminate
173    /// checks the whole). The `Indeterminate` state is reserved for external
174    /// sources — `TreeCheckedModel` aggregation when descendants are mixed,
175    /// "select all" indicators, etc. Matches the Outlook / Files-app
176    /// folder-checkbox semantic. Useful for parent checkboxes in tree views.
177    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    /// Suppress the visual label/caption AND the debug-time
194    /// "missing accessible label" assertion. Use this **only** when
195    /// the checkbox is embedded inside a composite that owns the
196    /// row's accessible name (e.g. `StandardListItem` /
197    /// `StandardTreeItem`, where the row's `accessibility(builder)`
198    /// calls `set_name(...)` with the row label).
199    ///
200    /// **A11y contract:** when `labels_hidden(true)` is set, the
201    /// caller MUST guarantee that an addressable AT ancestor
202    /// provides the name — either via that ancestor's own
203    /// `accessibility()` impl or a builder-level
204    /// `.access_label*` override. Without it the AT tree exposes a
205    /// `Role::CheckBox` node with no name; screen readers announce
206    /// "checkbox, checked" with no context. The Outlook /
207    /// Files-app row pattern (where the row label covers the
208    /// embedded checkbox) is the supported use case.
209    pub fn labels_hidden(mut self, hidden: bool) -> Self {
210        self.labels_hidden = hidden;
211        self
212    }
213
214    /// Set the visible label rendered to the right of the checkbox box,
215    /// also used as the AT name. Required unless `.labels_hidden(true)` is set.
216    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    /// Secondary explanatory text rendered below the label, left-aligned
223    /// with the label (not the box). Uses the `small` / `text_secondary`
224    /// style. Has no effect unless `label(...)` is also set.
225    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    /// Set the enabled state, statically or reactively. Forwarded to the
232    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at
233    /// build time — a bound `Signal<bool>` updates live.
234    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
235        self.enabled = enabled.into();
236        self
237    }
238
239    /// Pick the design-language variant. Default `Square`. The active
240    /// `CheckboxStyle` impl decides what the variant means visually
241    /// (the IntUI `RecipeCheckboxStyle` honours all three variants
242    /// directly via corner-shape changes).
243    pub fn variant(mut self, variant: CheckboxVariant) -> Self {
244        self.variant = variant;
245        self
246    }
247
248    /// Per-call style override. Replaces the theme-wide default
249    /// `CheckboxStyle` for just this Checkbox instance — same role as
250    /// `Button::style(...)`.
251    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    /// Attach a plain tooltip shown after a hover delay.
257    /// Clears any previously set rich or composite tooltip (last-call wins).
258    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    /// Attach a rich tooltip resolved from the app-wide tooltip
266    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
267    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    /// Attach a rich tooltip driven by inline `TooltipContent`.
275    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    /// Attach a composite tooltip — third tier, hosting an arbitrary
283    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
284    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
310// ---------------------------------------------------------------------------
311// Widget
312// ---------------------------------------------------------------------------
313
314/// Internal interaction state — local to this widget's handlers; the
315/// active `CheckboxStyle` only sees the four derived boolean signals
316impl 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        // Forward the enabled state into the arena. After this point
324        // the arena is the single source of truth (same architecture
325        // as IconButton — leaves consume `effective_enabled` at paint
326        // time, events are gated on `is_enabled`, a11y walker reads it).
327        ctx.enabled_when(self_id, self.enabled.clone());
328        let effective_enabled = ctx.effective_enabled_signal(self_id);
329
330        // Interaction signal seeded to Idle — the arena's enabled-state
331        // is consulted separately via `effective_enabled`.
332        let interaction = ctx.signal(InteractionState::Idle);
333
334        // Bridge the widget-side `CheckState` (teksilo-data) to the style-
335        // protocol-side `CheckboxState` (teksilo-core). The mapping is 1-to-1;
336        // `.map()` registers the upstream root so the body repaints when
337        // the check state flips.
338        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        // `:focus-visible`: reveal the focus ring during keyboard navigation
347        // only, not on a mouse click. Gate raw focus on the input-modality
348        // signal (true after a key event, false after pointer-down).
349        let is_focused = interaction
350            .map(|s| matches!(s, InteractionState::Focused))
351            .and(&ctx.focus_visible());
352        // is_disabled derives from the arena (not from interaction).
353        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        // When a caption is present, top-align the row so the box sits next
401        // to the label's first line rather than the center of both lines.
402        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        // --- V2 attached handlers ---
429        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        // Framework gates events on `arena.is_enabled(self_id)`, so
438        // these closures only run when the widget is effectively
439        // enabled. The old `if !enabled { return; }` snapshot guards
440        // are gone.
441        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                            // Lone-KeyUp guard: only toggle if we saw the
470                            // matching KeyDown (state is Pressed). A stray KeyUp
471                            // — e.g. a shortcut consumed the KeyDown and focus
472                            // returned here — must NOT toggle.
473                            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            // Focus walker skips disabled subtrees on its own.
508            .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                // AccessKit's Toggled::Mixed maps to ARIA "mixed"
563                builder
564                    .inner_mut()
565                    .set_toggled(teksilo_core::accesskit::Toggled::Mixed);
566            }
567        }
568        // Framework's accessibility walker calls `set_disabled` based
569        // on `arena.is_enabled(self_id)` — no need to mirror here.
570        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// ---------------------------------------------------------------------------
580// Tests
581// ---------------------------------------------------------------------------
582
583#[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        // `:focus-visible`: the focus border shows during keyboard navigation
593        // but not on a pointer click. Programmatic focus leaves `focus_visible`
594        // false → no border; a key press flips the modality and reveals it.
595        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    /// Whether `color` appears in any color-bearing layer of the frame.
615    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    // --- Two-state tests ---
622
623    #[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        // Lone-KeyUp guard: a KeyUp with no matching KeyDown (e.g. a shortcut
654        // consumed the KeyDown, then focus returned here) must NOT toggle.
655        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        // A matched pair still toggles.
668        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    // --- Tristate tests ---
701
702    #[test]
703    fn tristate_user_click_toggles_two_states() {
704        // User clicks only toggle Checked ↔ Unchecked. Indeterminate is
705        // reserved for external sources (TreeCheckedModel aggregation, etc.)
706        // — clicking from Indeterminate checks the whole. Outlook / Files-app
707        // folder-checkbox semantic.
708        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        // Clicking from Indeterminate checks the whole, NOT cycles.
720        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        // Indeterminate is_filled() == true, so it should have a primary background
742        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}