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        // Publish the toggle for the data views' `Space`.
514        //
515        // A row, cell or tile in a data view is not a focus target and its
516        // subtree is kept out of the Tab order — a listbox or grid is one Tab
517        // stop, and a per-row stop would make the Tab order follow the scroll
518        // position. That leaves a checkbox inside one with no keyboard route,
519        // so the view asks the focused row/cell/tile what `Space` should do
520        // and finds this. Published by the checkbox itself rather than by each
521        // row type, so it works for a hand-written cell delegate too.
522        //
523        // Costs nothing outside a data view: the marker is only ever read by
524        // one, and a standalone checkbox keeps its own focus and its own
525        // Space/Enter handling above.
526        {
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                // AccessKit's Toggled::Mixed maps to ARIA "mixed"
581                builder
582                    .inner_mut()
583                    .set_toggled(teksilo_core::accesskit::Toggled::Mixed);
584            }
585        }
586        // Framework's accessibility walker calls `set_disabled` based
587        // on `arena.is_enabled(self_id)` — no need to mirror here.
588        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// ---------------------------------------------------------------------------
598// Tests
599// ---------------------------------------------------------------------------
600
601#[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        // `:focus-visible`: the focus border shows during keyboard navigation
611        // but not on a pointer click. Programmatic focus leaves `focus_visible`
612        // false → no border; a key press flips the modality and reveals it.
613        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    /// Whether `color` appears in any color-bearing layer of the frame.
633    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    // --- Two-state tests ---
640
641    #[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        // Lone-KeyUp guard: a KeyUp with no matching KeyDown (e.g. a shortcut
672        // consumed the KeyDown, then focus returned here) must NOT toggle.
673        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        // A matched pair still toggles.
686        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    // --- Tristate tests ---
719
720    #[test]
721    fn tristate_user_click_toggles_two_states() {
722        // User clicks only toggle Checked ↔ Unchecked. Indeterminate is
723        // reserved for external sources (TreeCheckedModel aggregation, etc.)
724        // — clicking from Indeterminate checks the whole. Outlook / Files-app
725        // folder-checkbox semantic.
726        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        // Clicking from Indeterminate checks the whole, NOT cycles.
738        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        // Indeterminate is_filled() == true, so it should have a primary background
760        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}