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//! ## Label styling
22//!
23//! The label is `Body` / `Primary` and the caption `Small` / `Secondary` by
24//! default. [`Checkbox::label_style`] / [`Checkbox::label_color`] and
25//! [`Checkbox::caption_style`] / [`Checkbox::caption_color`] override them.
26//! A theme role colour still dims when the checkbox is disabled; a literal
27//! `Color` stays as given.
28//!
29//! ## Touch and pen
30//!
31//! The pressed state comes from the framework press
32//! (`docs/touch-and-pen.md` §7.1) rather than from this widget: sliding off
33//! abandons it, sliding back on restores it, and a pan claimant winning the
34//! press clears it with no release. Before the controls sweep `is_pressed`
35//! could only ever be set by a keyboard `Space`, so a mouse-down showed no
36//! pressed chrome at all — themes that paint one (IntUI's checked box, Material
37//! 3's state layer) now get it from every pointer.
38//!
39//! The 24 dp `MinSize` around the 19 dp glyph already meets the WCAG 2.2
40//! SC 2.5.8 floor at Compact and follows the density ladder above it, so no
41//! hit-widening mechanism is involved.
42//!
43//! ## Accessibility
44//!
45//! Announces as `Role::CheckBox`. A label is required in debug builds
46//! unless `.labelled_externally()` is set (for embedding inside a composite
47//! row that owns the AT name). Keyboard: Space toggles; lone-KeyUp guard
48//! prevents spurious toggle when focus is restored after a shortcut.
49//!
50//! ```rust
51//! # use teksilo_widgets::Checkbox;
52//! # use teksilo_core::signal::Signal;
53//! # use teksilo_i18n::lit;
54//! let checked = Signal::new(false);
55//! let _cb = Checkbox::new(checked)
56//!     .label(lit!("Accept terms and conditions"));
57//! ```
58
59use std::cell::Cell;
60use std::rc::Rc;
61
62use teksilo_canvas::{Rect, Size, SizeProposal};
63use teksilo_core::accessibility::AccessNodeBuilder;
64use teksilo_core::binding::BindingLevel;
65use teksilo_core::build_context::BuildContext;
66use teksilo_core::event::{EventResponse, Key, WidgetEvent};
67use teksilo_core::signal::{Prop, Signal};
68use teksilo_core::styles::{
69    CheckboxState, CheckboxStyleConfig, CheckboxVariant, SharedCheckboxStyle,
70};
71use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
72use teksilo_core::widget_builder::HandlerSet;
73use teksilo_core::widget_id::WidgetId;
74use teksilo_data::CheckState;
75use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
76
77use crate::button::InteractionState;
78use crate::primitives::{HStack, MinSize, TextWidget, VStack};
79use teksilo_i18n::LocalizedString;
80
81// ---------------------------------------------------------------------------
82// Internal state wrapper
83// ---------------------------------------------------------------------------
84
85/// Wraps either a bool state (two-state) or a CheckState state (tristate).
86#[derive(Clone)]
87enum CheckKind {
88    TwoState(Signal<bool>),
89    TriState(Signal<CheckState>),
90}
91
92impl CheckKind {
93    fn check_state(&self) -> CheckState {
94        match self {
95            CheckKind::TwoState(s) => CheckState::from(s.get()),
96            CheckKind::TriState(s) => s.get(),
97        }
98    }
99
100    /// A reactive `Signal<CheckState>` that tracks the underlying
101    /// mutable root of either variant. Used to compose multi-source
102    /// derived visuals (e.g. box colors that depend on both interaction
103    /// state and check state) so they dirty-track the check-state
104    /// source in addition to the interaction source.
105    fn check_state_signal(&self) -> Signal<CheckState> {
106        match self {
107            CheckKind::TwoState(s) => s.map(|b| CheckState::from(*b)),
108            CheckKind::TriState(s) => s.clone(),
109        }
110    }
111
112    /// Flip, and report the checked-ness the activation produced. Activation
113    /// never yields `Indeterminate` (see below), so a `bool` is lossless for
114    /// both kinds and `Checkbox::on_change` can take one.
115    fn toggle(&self) -> bool {
116        match self {
117            CheckKind::TwoState(s) => {
118                let current = s.get();
119                s.set(!current);
120                !current
121            }
122            CheckKind::TriState(s) => {
123                // User clicks toggle Checked ↔ Unchecked. The
124                // `Indeterminate` state is reserved for external
125                // sources (e.g. `TreeCheckedModel` aggregation when
126                // descendants are mixed) — the user can't *set* a
127                // checkbox to "half"; clicking from Indeterminate
128                // checks the whole. This matches the Outlook /
129                // Files-app folder-checkbox semantic.
130                let current = s.get();
131                let next = if matches!(current, CheckState::Checked) {
132                    CheckState::Unchecked
133                } else {
134                    CheckState::Checked
135                };
136                s.set(next);
137                matches!(next, CheckState::Checked)
138            }
139        }
140    }
141}
142
143impl std::fmt::Debug for CheckKind {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            CheckKind::TwoState(_) => write!(f, "TwoState"),
147            CheckKind::TriState(_) => write!(f, "TriState"),
148        }
149    }
150}
151
152// ---------------------------------------------------------------------------
153// Checkbox
154// ---------------------------------------------------------------------------
155
156/// A checkbox that toggles a `Signal<bool>` or cycles a `Signal<CheckState>`.
157pub struct Checkbox {
158    label: Option<LocalizedString>,
159    caption: Option<LocalizedString>,
160    kind: CheckKind,
161    /// Enabled state, static or reactive; forwarded into the arena at
162    /// build time. After build the arena is the single source of
163    /// truth — see `IconButton::enabled` for the architectural
164    /// rationale.
165    enabled: Prop<bool>,
166    /// When true, the checkbox renders only the box (no visual label /
167    /// caption next to it) AND its `accessibility(builder)` skips the
168    /// missing-label `debug_assert` — the parent composite is responsible
169    /// for providing the AT name (typically via its own `set_name(...)`
170    /// or an `access_label*` override). Used by `StandardListItem` /
171    /// `StandardTreeItem`.
172    labelled_externally: bool,
173    label_style: teksilo_core::color_prop::TextStyleProp,
174    label_color: teksilo_core::color_prop::ColorProp,
175    caption_style: teksilo_core::color_prop::TextStyleProp,
176    caption_color: teksilo_core::color_prop::ColorProp,
177    tooltip_text: Option<LocalizedString>,
178    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
179    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
180    variant: CheckboxVariant,
181    style_override: Option<SharedCheckboxStyle>,
182    on_change: Option<Rc<dyn Fn(bool, &mut EventContext)>>,
183    root_child_id: Option<WidgetId>,
184}
185
186impl Checkbox {
187    /// Create a two-state checkbox bound to a `Signal<bool>`.
188    pub fn new(checked: Signal<bool>) -> Self {
189        Self {
190            label: None,
191            caption: None,
192            kind: CheckKind::TwoState(checked),
193            enabled: Prop::Static(true),
194            labelled_externally: false,
195            label_style: TextStyleRole::Body.into(),
196            label_color: TextRole::Primary.into(),
197            caption_style: TextStyleRole::Small.into(),
198            caption_color: TextRole::Secondary.into(),
199            tooltip_text: None,
200            rich_tooltip_source: None,
201            composite_tooltip_content: None,
202            variant: CheckboxVariant::default(),
203            style_override: None,
204            on_change: None,
205            root_child_id: None,
206        }
207    }
208
209    /// Create a tristate checkbox bound to a `Signal<CheckState>`.
210    ///
211    /// User clicks toggle Checked ↔ Unchecked (clicking from Indeterminate
212    /// checks the whole). The `Indeterminate` state is reserved for external
213    /// sources — `TreeCheckedModel` aggregation when descendants are mixed,
214    /// "select all" indicators, etc. Matches the Outlook / Files-app
215    /// folder-checkbox semantic. Useful for parent checkboxes in tree views.
216    pub fn tristate(state: Signal<CheckState>) -> Self {
217        Self {
218            label: None,
219            caption: None,
220            kind: CheckKind::TriState(state),
221            enabled: Prop::Static(true),
222            labelled_externally: false,
223            label_style: TextStyleRole::Body.into(),
224            label_color: TextRole::Primary.into(),
225            caption_style: TextStyleRole::Small.into(),
226            caption_color: TextRole::Secondary.into(),
227            tooltip_text: None,
228            rich_tooltip_source: None,
229            composite_tooltip_content: None,
230            variant: CheckboxVariant::default(),
231            style_override: None,
232            on_change: None,
233            root_child_id: None,
234        }
235    }
236
237    /// Run `f` when the **user** checks or unchecks this box, with the
238    /// checked-ness the activation produced and an `EventContext`, so it can do
239    /// what a bare `Signal` write cannot (`ctx.send_intent(...)`,
240    /// `ctx.set_theme(...)`, opening a window). Fires for the pointer, for
241    /// `Space`, for an assistive-technology `Click`, and for `Space` on a data
242    /// view's focused row.
243    ///
244    /// Does **not** fire for programmatic writes to the bound signal — there is
245    /// no event in flight to carry. Observe the signal for that. The signal
246    /// stays the source of truth either way: it is written first, and `f` sees
247    /// the value it now holds.
248    ///
249    /// A tristate checkbox reports a `bool` too: activation cycles
250    /// `Checked` ↔ `Unchecked` only, and `Indeterminate` is external-source-only.
251    pub fn on_change(mut self, f: impl Fn(bool, &mut EventContext) + 'static) -> Self {
252        self.on_change = Some(Rc::new(f));
253        self
254    }
255
256    /// Declare that this checkbox's accessible name comes from an
257    /// ancestor, suppressing the visual label/caption AND the
258    /// debug-time "missing accessible label" assertion. Use it when
259    /// the checkbox is embedded inside a composite that owns the
260    /// row's name (e.g. `StandardListItem` / `StandardTreeItem`,
261    /// where the row's `accessibility(builder)` calls `set_name(...)`
262    /// with the row label).
263    ///
264    /// **A11y contract:** the caller MUST guarantee that an
265    /// addressable AT ancestor provides the name — either via that
266    /// ancestor's own `accessibility()` impl or a builder-level
267    /// `.access_label*` override. Without it the AT tree exposes a
268    /// `Role::CheckBox` node with no name; screen readers announce
269    /// "checkbox, checked" with no context. The Outlook /
270    /// Files-app row pattern (where the row label covers the
271    /// embedded checkbox) is the supported use case.
272    ///
273    /// Spelled the same way on [`Toggle`](crate::toggle::Toggle).
274    pub fn labelled_externally(mut self) -> Self {
275        self.labelled_externally = true;
276        self
277    }
278
279    /// Set the visible label rendered to the right of the checkbox box,
280    /// also used as the AT name. Required unless `.labelled_externally()` is set.
281    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
282        let ls: LocalizedString = label.into();
283        self.label = Some(ls);
284        self
285    }
286
287    /// Secondary explanatory text rendered below the label, left-aligned
288    /// with the label (not the box). Uses the `small` / `text_secondary`
289    /// style. Has no effect unless `label(...)` is also set.
290    pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
291        let ls: LocalizedString = text.into();
292        self.caption = Some(ls);
293        self
294    }
295
296    /// Override the label's text style (font, size, weight). Accepts a
297    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default is
298    /// `TextStyleRole::Body`.
299    pub fn label_style(
300        mut self,
301        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
302    ) -> Self {
303        self.label_style = style.into();
304        self
305    }
306
307    /// Override the label's text color. Accepts `Color`, a theme role, or a
308    /// `Signal` of either. Default is `TextRole::Primary`. A role still dims
309    /// to the disabled color when the checkbox is disabled; a literal `Color`
310    /// stays as given.
311    pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
312        self.label_color = color.into();
313        self
314    }
315
316    /// Override the caption's text style. Default is `TextStyleRole::Small`.
317    pub fn caption_style(
318        mut self,
319        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
320    ) -> Self {
321        self.caption_style = style.into();
322        self
323    }
324
325    /// Override the caption's text color. Accepts `Color`, a theme role, or
326    /// a `Signal` of either. Default is `TextRole::Secondary`. A role still
327    /// dims to the disabled color when the checkbox is disabled; a literal
328    /// `Color` stays as given.
329    pub fn caption_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
330        self.caption_color = color.into();
331        self
332    }
333
334    /// Set the enabled state, statically or reactively. Forwarded to the
335    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at
336    /// build time — a bound `Signal<bool>` updates live.
337    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
338        self.enabled = enabled.into();
339        self
340    }
341
342    /// Pick the design-language variant. Default `Square`. The active
343    /// `CheckboxStyle` impl decides what the variant means visually
344    /// (the IntUI `RecipeCheckboxStyle` honours all three variants
345    /// directly via corner-shape changes).
346    pub fn variant(mut self, variant: CheckboxVariant) -> Self {
347        self.variant = variant;
348        self
349    }
350
351    /// Per-call style override. Replaces the theme-wide default
352    /// `CheckboxStyle` for just this Checkbox instance — same role as
353    /// `Button::style(...)`.
354    pub fn style(mut self, style: impl teksilo_core::styles::CheckboxStyle) -> Self {
355        self.style_override = Some(Rc::new(style));
356        self
357    }
358
359    /// Attach a plain tooltip shown after a hover delay.
360    /// Clears any previously set rich or composite tooltip (last-call wins).
361    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
362        self.tooltip_text = Some(text.into());
363        self.rich_tooltip_source = None;
364        self.composite_tooltip_content = None;
365        self
366    }
367
368    /// Attach a rich tooltip resolved from the app-wide tooltip
369    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
370    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
371        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
372        self.tooltip_text = None;
373        self.composite_tooltip_content = None;
374        self
375    }
376
377    /// Attach a rich tooltip driven by inline `TooltipContent`.
378    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
379        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
380        self.tooltip_text = None;
381        self.composite_tooltip_content = None;
382        self
383    }
384
385    /// Attach a composite tooltip — third tier, hosting an arbitrary
386    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
387    pub fn composite_tooltip(
388        mut self,
389        content: impl teksilo_core::widget::Widget + 'static,
390    ) -> Self {
391        self.composite_tooltip_content = Some(Box::new(content));
392        self.tooltip_text = None;
393        self.rich_tooltip_source = None;
394        self
395    }
396
397    fn check_state(&self) -> CheckState {
398        self.kind.check_state()
399    }
400}
401
402impl std::fmt::Debug for Checkbox {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        f.debug_struct("Checkbox")
405            .field("label", &self.label)
406            .field("caption", &self.caption)
407            .field("kind", &self.kind)
408            .field("enabled", &self.enabled.get())
409            .finish()
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Widget
415// ---------------------------------------------------------------------------
416
417// Interaction state is Button's shared `InteractionState` — local to this
418// widget's handlers; the active `CheckboxStyle` only sees the four derived
419// boolean signals (is_hovered, is_pressed, is_focused, is_disabled).
420impl Widget for Checkbox {
421    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
422        use crate::styles::recipe_checkbox_style as cb_dims;
423        let kind = self.kind.clone();
424        let variant = self.variant;
425        let self_id = ctx.self_id();
426
427        // Forward the enabled state into the arena. After this point
428        // the arena is the single source of truth (same architecture
429        // as IconButton — leaves consume `effective_enabled` at paint
430        // time, events are gated on `is_enabled`, a11y walker reads it).
431        ctx.enabled_when(self_id, self.enabled.clone());
432        let effective_enabled = ctx.effective_enabled_signal(self_id);
433
434        // Interaction signal seeded to Idle — the arena's enabled-state
435        // is consulted separately via `effective_enabled`.
436        let interaction = ctx.signal(InteractionState::Idle);
437
438        // Bridge the widget-side `CheckState` (teksilo-data) to the style-
439        // protocol-side `CheckboxState` (teksilo-core). The mapping is 1-to-1;
440        // `.map()` registers the upstream root so the body repaints when
441        // the check state flips.
442        let style_state = kind.check_state_signal().map(|cs| match *cs {
443            CheckState::Unchecked => CheckboxState::Unchecked,
444            CheckState::Checked => CheckboxState::Checked,
445            CheckState::Indeterminate => CheckboxState::Indeterminate,
446        });
447        // The body's repaint is not a walk of the accessibility tree, and
448        // `accessibility()` reads the state only when one happens: without this
449        // a change reaches no platform until something unrelated re-walks it.
450        kind.check_state_signal().bind_to(
451            self_id,
452            ctx.binding_registry(),
453            BindingLevel::AccessibilityOnly,
454        );
455
456        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
457        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
458        // `:focus-visible`: reveal the focus ring during keyboard navigation
459        // only, not on a mouse click. Gate raw focus on the input-modality
460        // signal (true after a key event, false after pointer-down).
461        let is_focused = interaction
462            .map(|s| matches!(s, InteractionState::Focused))
463            .and(&ctx.focus_visible());
464        // is_disabled derives from the arena (not from interaction).
465        let is_disabled = effective_enabled.map(|on| !*on);
466
467        let style: SharedCheckboxStyle = self
468            .style_override
469            .clone()
470            .or_else(|| ctx.theme().style_slots.checkbox.clone())
471            .unwrap_or_else(|| {
472                Rc::new(crate::styles::RecipeCheckboxStyle::for_tokens(
473                    &ctx.theme().input,
474                ))
475            });
476        let cfg = CheckboxStyleConfig {
477            state: style_state,
478            is_hovered,
479            is_pressed,
480            is_focused,
481            is_disabled,
482            variant,
483        };
484        let body_id = style.make_body(&cfg, ctx);
485
486        let mut row = HStack::new()
487            .spacing(cb_dims::CHECKBOX_LABEL_GAP)
488            .child(body_id);
489        if !self.labelled_externally
490            && let Some(ref label) = self.label
491        {
492            let label_widget = TextWidget::new(label.clone())
493                .style(self.label_style.clone())
494                .color(self.label_color.clone())
495                .single_line()
496                .a11y_hidden();
497            let label_id = ctx.add(label_widget);
498
499            let label_column_id = if let Some(ref caption) = self.caption {
500                let caption_widget = TextWidget::new(caption.clone())
501                    .style(self.caption_style.clone())
502                    .color(self.caption_color.clone())
503                    .a11y_hidden();
504                let caption_id = ctx.add(caption_widget);
505                ctx.add(VStack::new().spacing(2.0).child(label_id).child(caption_id))
506            } else {
507                label_id
508            };
509            row = row.child(label_column_id);
510        }
511        // When a caption is present, top-align the row so the box sits next
512        // to the label's first line rather than the center of both lines.
513        if self.caption.is_some() && self.label.is_some() {
514            row = row.alignment(VAlignment::Top);
515        }
516
517        // The hit box comes from the same recipe the chrome was built from, so
518        // a density switch moves both together.
519        let style_recipe = crate::styles::CheckboxRecipe::for_tokens(&ctx.theme().input);
520        let row_id = ctx.add(row);
521        let root_id = ctx
522            .add(MinSize::new(style_recipe.box_hit_area, style_recipe.box_hit_area).child(row_id));
523
524        if let Some(content) = self.composite_tooltip_content.take() {
525            let delay = ctx.theme().motion.tooltip_delay_heavy;
526            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
527        } else if let Some(source) = self.rich_tooltip_source.take() {
528            let delay = ctx.theme().motion.tooltip_delay;
529            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
530        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
531            let delay = ctx.theme().motion.tooltip_delay;
532            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
533        }
534
535        self.root_child_id = Some(root_id);
536
537        // --- V2 attached handlers ---
538        let kind_tap = self.kind.clone();
539        let kind_key = self.kind.clone();
540        let kind_access = self.kind.clone();
541        let changed_tap = self.on_change.clone();
542        let changed_key = self.on_change.clone();
543        let changed_access = self.on_change.clone();
544        let int_tap = interaction.clone();
545        let int_hover = interaction.clone();
546        let int_key = interaction.clone();
547        let int_focus = interaction.clone();
548
549        // The pointer press is the framework's, not this control's own: the
550        // router knows about a press that slid off its target, one that slid
551        // back on, and one a pan claimant took away with no release to reset
552        // from — none of which a `PointerDown` / `PointerUp` pair here can
553        // see. `docs/touch-and-pen.md` §7.1. `pointer_over` carries the hover
554        // truth across the press, so a press that ends without an activation
555        // rests on the right state.
556        let pointer_over = Rc::new(Cell::new(false));
557        crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
558
559        // Framework gates events on `arena.is_enabled(self_id)`, so
560        // these closures only run when the widget is effectively
561        // enabled. The old `if !enabled { return; }` snapshot guards
562        // are gone.
563        let handler_set = HandlerSet::new()
564            .on_tap({
565                let hovering = pointer_over.clone();
566                move |_pos, ctx: &mut EventContext| {
567                    let now = kind_tap.toggle();
568                    if let Some(ref f) = changed_tap {
569                        f(now, ctx);
570                    }
571                    // A mouse or a pen is still over the control after the
572                    // release; a finger is gone and sends no hover-leave to
573                    // correct a `Hovered` state with.
574                    int_tap.set(if ctx.pointer_kind().hovers() {
575                        hovering.set(true);
576                        InteractionState::Hovered
577                    } else {
578                        InteractionState::Idle
579                    });
580                }
581            })
582            .on_hover({
583                let hovering = pointer_over.clone();
584                move |entered: bool, _ctx: &mut EventContext| {
585                    hovering.set(entered);
586                    if entered {
587                        int_hover.set(InteractionState::Hovered);
588                    } else {
589                        int_hover.set(InteractionState::Idle);
590                    }
591                }
592            })
593            .on_key({
594                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
595                    match event {
596                        WidgetEvent::KeyDown {
597                            key: Key::Space, ..
598                        } => {
599                            int_key.set(InteractionState::Pressed);
600                            EventResponse::Handled
601                        }
602                        WidgetEvent::KeyUp {
603                            key: Key::Space, ..
604                        } => {
605                            // Lone-KeyUp guard: only toggle if we saw the
606                            // matching KeyDown (state is Pressed). A stray KeyUp
607                            // — e.g. a shortcut consumed the KeyDown and focus
608                            // returned here — must NOT toggle.
609                            if int_key.get() != InteractionState::Pressed {
610                                return EventResponse::Ignored;
611                            }
612                            let now = kind_key.toggle();
613                            if let Some(ref f) = changed_key {
614                                f(now, ctx);
615                            }
616                            int_key.set(InteractionState::Focused);
617                            EventResponse::Handled
618                        }
619                        _ => EventResponse::Ignored,
620                    }
621                }
622            })
623            .on_focus({
624                move |gained: bool, _ctx: &mut EventContext| {
625                    if gained {
626                        if int_focus.get() == InteractionState::Idle {
627                            int_focus.set(InteractionState::Focused);
628                        }
629                    } else {
630                        int_focus.set(InteractionState::Idle);
631                    }
632                }
633            })
634            .on_access_action({
635                move |action: teksilo_core::accesskit::Action,
636                      ctx: &mut EventContext|
637                      -> EventResponse {
638                    if action == teksilo_core::accesskit::Action::Click {
639                        let now = kind_access.toggle();
640                        if let Some(ref f) = changed_access {
641                            f(now, ctx);
642                        }
643                        EventResponse::Handled
644                    } else {
645                        EventResponse::Ignored
646                    }
647                }
648            })
649            // Focus walker skips disabled subtrees on its own.
650            .focusable(true)
651            .cursor(CursorIcon::Pointer);
652
653        ctx.apply_self_handlers(handler_set);
654
655        // Publish the toggle for the data views' `Space`.
656        //
657        // A row, cell or tile in a data view is not a focus target and its
658        // subtree is kept out of the Tab order — a listbox or grid is one Tab
659        // stop, and a per-row stop would make the Tab order follow the scroll
660        // position. That leaves a checkbox inside one with no keyboard route,
661        // so the view asks the focused row/cell/tile what `Space` should do
662        // and finds this. Published by the checkbox itself rather than by each
663        // row type, so it works for a hand-written cell delegate too.
664        //
665        // Costs nothing outside a data view: the marker is only ever read by
666        // one, and a standalone checkbox keeps its own focus and its own
667        // Space/Enter handling above.
668        {
669            let kind_space = self.kind.clone();
670            let changed_space = self.on_change.clone();
671            ctx.set_keyboard_toggle(
672                ctx.self_id(),
673                std::rc::Rc::new(move |ctx: &mut EventContext| {
674                    let now = kind_space.toggle();
675                    if let Some(ref f) = changed_space {
676                        f(now, ctx);
677                    }
678                }),
679            );
680        }
681
682        vec![root_id]
683    }
684
685    fn layout_response(
686        &self,
687        proposal: SizeProposal,
688        ctx: &LayoutContext,
689    ) -> teksilo_core::widget::LayoutResponse {
690        if let Some(root) = self.root_child_id
691            && let Some(size) = ctx.child_size(root, proposal)
692        {
693            return (size).into();
694        }
695        proposal.resolve(0.0, 0.0).into()
696    }
697
698    fn place_children(
699        &self,
700        bounds: Rect,
701        _proposal: SizeProposal,
702        children: &mut [WidgetPlacement],
703        _ctx: &LayoutContext,
704    ) {
705        for child in children.iter_mut() {
706            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
707            child.size = Size::new(bounds.width, bounds.height);
708        }
709    }
710
711    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
712        debug_assert!(
713            self.label.is_some() || self.labelled_externally,
714            "Checkbox is missing an accessible label — \
715             screen readers will announce \"checkbox\" with no context. \
716             Call .label(...) when constructing the widget, or \
717             .labelled_externally() when embedded in a composite that \
718             owns the AT name."
719        );
720        builder.set_role(teksilo_core::accesskit::Role::CheckBox);
721        if let Some(ref label) = self.label {
722            builder.set_name(label.resolve_now());
723        }
724        if let Some(ref caption) = self.caption {
725            builder.set_description(caption.resolve_now());
726        }
727        match self.check_state() {
728            CheckState::Checked => builder.set_toggled(true),
729            CheckState::Unchecked => builder.set_toggled(false),
730            CheckState::Indeterminate => {
731                // AccessKit's Toggled::Mixed maps to ARIA "mixed"
732                builder
733                    .inner_mut()
734                    .set_toggled(teksilo_core::accesskit::Toggled::Mixed);
735            }
736        }
737        // Framework's accessibility walker calls `set_disabled` based
738        // on `arena.is_enabled(self_id)` — no need to mirror here.
739        builder.add_action(teksilo_core::accesskit::Action::Click);
740        builder.add_action(teksilo_core::accesskit::Action::Focus);
741    }
742
743    fn children(&self) -> Vec<WidgetId> {
744        self.root_child_id.into_iter().collect()
745    }
746}
747
748// ---------------------------------------------------------------------------
749// Tests
750// ---------------------------------------------------------------------------
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use teksilo_core::event::Modifiers;
756    use teksilo_core::widget_tree::WidgetTree;
757    use teksilo_i18n::lit;
758
759    /// Records the size every text run was shaped at, so a test can see which
760    /// style reached the label and which the caption. The mock backend
761    /// ignores styles.
762    struct StyleSpy {
763        inner: teksilo_canvas::MockTextBackend,
764        shaped: Rc<std::cell::RefCell<Vec<(String, f32)>>>,
765    }
766
767    impl teksilo_canvas::TextBackend for StyleSpy {
768        fn layout_single_line(
769            &mut self,
770            text: &str,
771            style: &teksilo_tokens::TextStyle,
772            max_width: Option<f32>,
773        ) -> teksilo_canvas::TextLayout {
774            self.shaped
775                .borrow_mut()
776                .push((text.to_string(), style.size));
777            self.inner.layout_single_line(text, style, max_width)
778        }
779
780        fn ensure_glyphs(
781            &mut self,
782            layout: &teksilo_canvas::TextLayout,
783        ) -> Vec<teksilo_canvas::GlyphQuad> {
784            self.inner.ensure_glyphs(layout)
785        }
786    }
787
788    fn spy_tree() -> (WidgetTree, Rc<std::cell::RefCell<Vec<(String, f32)>>>) {
789        let shaped = Rc::new(std::cell::RefCell::new(Vec::new()));
790        let backend = StyleSpy {
791            inner: teksilo_canvas::MockTextBackend::new(),
792            shaped: shaped.clone(),
793        };
794        let tree = WidgetTree::new()
795            .with_theme(teksilo_core::presets::intui::light())
796            .with_text_backend(Rc::new(std::cell::RefCell::new(backend)));
797        (tree, shaped)
798    }
799
800    fn size_of(shaped: &Rc<std::cell::RefCell<Vec<(String, f32)>>>, text: &str) -> f32 {
801        shaped
802            .borrow()
803            .iter()
804            .rev()
805            .find(|(t, _)| t == text)
806            .unwrap_or_else(|| panic!("`{text}` was never shaped"))
807            .1
808    }
809
810    #[test]
811    fn label_and_caption_styles_reach_the_text() {
812        let big = teksilo_tokens::TextStyle {
813            size: 31.0,
814            ..Default::default()
815        };
816        let (mut tree, shaped) = spy_tree();
817        let _w = tree.add(
818            Checkbox::new(Signal::new(false))
819                .label(lit!("Lbl"))
820                .caption(lit!("Cap"))
821                .label_style(big.clone())
822                .caption_style(TextStyleRole::BodyBold),
823        );
824        tree.layout(SizeProposal::exact(300.0, 100.0));
825        tree.render();
826        assert_eq!(size_of(&shaped, "Lbl"), 31.0);
827        let heading = teksilo_core::presets::intui::light()
828            .typography
829            .body_bold
830            .size;
831        assert_eq!(size_of(&shaped, "Cap"), heading);
832
833        let (mut tree, shaped) = spy_tree();
834        let _w = tree.add(
835            Checkbox::new(Signal::new(false))
836                .label(lit!("Lbl"))
837                .caption(lit!("Cap")),
838        );
839        tree.layout(SizeProposal::exact(300.0, 100.0));
840        tree.render();
841        let typography = teksilo_core::presets::intui::light().typography;
842        assert_eq!(
843            size_of(&shaped, "Lbl"),
844            typography.body.size,
845            "default label"
846        );
847        assert_eq!(
848            size_of(&shaped, "Cap"),
849            typography.small.size,
850            "default caption"
851        );
852    }
853
854    #[test]
855    fn label_and_caption_colors_apply_and_a_role_still_dims_when_disabled() {
856        use std::cell::RefCell;
857        use teksilo_canvas::MockTextBackend;
858        let theme = teksilo_core::presets::intui::light();
859        let accent = theme.colors.accent.to_array();
860        let error = theme.colors.text_error.to_array();
861        let disabled = theme.colors.text_disabled.to_array();
862        let colors = |enabled: bool| {
863            let mut tree = WidgetTree::new()
864                .with_theme(theme.clone())
865                .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
866            let _w = tree.add(
867                Checkbox::new(Signal::new(false))
868                    .label(lit!("Lbl"))
869                    .caption(lit!("Cap"))
870                    .label_color(TextRole::Accent)
871                    .caption_color(TextRole::Error)
872                    .enabled(enabled),
873            );
874            tree.layout(SizeProposal::exact(300.0, 100.0));
875            tree.render()
876                .glyphs
877                .iter()
878                .map(|g| g.color)
879                .collect::<Vec<_>>()
880        };
881        let on = colors(true);
882        assert!(on.contains(&accent), "label takes label_color: {on:?}");
883        assert!(on.contains(&error), "caption takes caption_color: {on:?}");
884        let off = colors(false);
885        assert!(
886            !off.contains(&accent) && !off.contains(&error) && off.contains(&disabled),
887            "disabled dims the role colours: {off:?}"
888        );
889    }
890
891    #[test]
892    fn focus_ring_only_under_focus_visible() {
893        // `:focus-visible`: the focus border shows during keyboard navigation
894        // but not on a pointer click. Programmatic focus leaves `focus_visible`
895        // false → no border; a key press flips the modality and reveals it.
896        let theme = teksilo_core::presets::intui::light();
897        let ring = theme.colors.border_focused.to_array();
898        let mut tree = WidgetTree::new().with_theme(theme);
899        let cb = tree.add(Checkbox::new(Signal::new(false)).label(lit!("A")));
900        tree.layout(SizeProposal::exact(200.0, 80.0));
901
902        tree.focus(cb);
903        assert!(
904            !frame_has_color(&tree.render(), ring),
905            "no focus border while focus-visible is false (pointer modality)",
906        );
907
908        tree.press_key(Key::ArrowDown, Modifiers::NONE);
909        assert!(
910            frame_has_color(&tree.render(), ring),
911            "focus border shows under keyboard modality",
912        );
913    }
914
915    /// Whether `color` appears in any color-bearing layer of the frame.
916    fn frame_has_color(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
917        frame.shapes.iter().any(|s| s.color == color)
918            || frame.decorations.iter().any(|d| d.color == color)
919            || frame.cosmetic_lines.iter().any(|l| l.color == color)
920    }
921
922    // --- Two-state tests ---
923
924    #[test]
925    fn click_toggles_bool_state() {
926        let checked = Signal::new(false);
927        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
928        let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
929        tree.layout(SizeProposal::exact(200.0, 80.0));
930
931        assert!(!checked.get());
932        tree.click(cb);
933        assert!(checked.get());
934        tree.click(cb);
935        assert!(!checked.get());
936    }
937
938    #[test]
939    fn space_toggles_bool_state() {
940        let checked = Signal::new(false);
941        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
942        let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
943        tree.layout(SizeProposal::exact(200.0, 80.0));
944
945        tree.focus(cb);
946        tree.press_key(Key::Space, Modifiers::NONE);
947        assert!(checked.get());
948        tree.press_key(Key::Space, Modifiers::NONE);
949        assert!(!checked.get());
950    }
951
952    #[test]
953    fn a_reader_is_told_each_change_as_it_happens() {
954        // Space, a screen reader's own click and a write from the app each
955        // changed the box and told no platform: its state reached the tree only
956        // when something unrelated walked it again, most often the next focus
957        // move, where Orca's "checked" was cut by the new focus.
958        use crate::common::heard_test::{Heard, Listener};
959        use teksilo_core::accesskit::Toggled;
960
961        let state = Signal::new(CheckState::Unchecked);
962        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
963        let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Accept")));
964        let p = SizeProposal::exact(200.0, 80.0);
965        tree.layout(p);
966        tree.focus(cb);
967        tree.layout(p);
968        let mut listener = Listener::attach(&mut tree);
969
970        tree.press_key(Key::Space, Modifiers::NONE);
971        tree.layout(p);
972        assert_eq!(
973            listener.heard(&mut tree),
974            vec![Heard::FocusToggled(Toggled::True)],
975            "Space"
976        );
977
978        tree.dispatch_access_action(
979            teksilo_core::accessibility::widget_id_to_node_id(cb),
980            teksilo_core::accesskit::Action::Click,
981            None,
982            &mut teksilo_core::NoopWindowOps,
983        );
984        tree.layout(p);
985        assert_eq!(
986            listener.heard(&mut tree),
987            vec![Heard::FocusToggled(Toggled::False)],
988            "a screen reader's click"
989        );
990
991        state.set(CheckState::Indeterminate);
992        tree.layout(p);
993        assert_eq!(
994            listener.heard(&mut tree),
995            vec![Heard::FocusToggled(Toggled::Mixed)],
996            "a write from the app"
997        );
998    }
999
1000    #[test]
1001    fn on_change_fires_for_every_user_path_and_reports_the_new_value() {
1002        use std::cell::RefCell;
1003        use std::rc::Rc;
1004
1005        let checked = Signal::new(false);
1006        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
1007        let sink = seen.clone();
1008        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1009        let cb = tree.add(
1010            Checkbox::new(checked.clone())
1011                .label(lit!("Accept"))
1012                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
1013        );
1014        tree.layout(SizeProposal::exact(200.0, 80.0));
1015
1016        tree.click(cb);
1017        tree.focus(cb);
1018        tree.press_key(Key::Space, Modifiers::NONE);
1019        tree.dispatch_access_action(
1020            teksilo_core::accessibility::widget_id_to_node_id(cb),
1021            teksilo_core::accesskit::Action::Click,
1022            None,
1023            &mut teksilo_core::NoopWindowOps,
1024        );
1025
1026        // Pointer, keyboard, assistive technology — and each reports the value
1027        // the activation produced, not the one before it.
1028        assert_eq!(*seen.borrow(), vec![true, false, true]);
1029        assert!(checked.get());
1030    }
1031
1032    #[test]
1033    fn on_change_is_silent_for_a_programmatic_write() {
1034        use std::cell::Cell;
1035        use std::rc::Rc;
1036
1037        let checked = Signal::new(false);
1038        let fired = Rc::new(Cell::new(false));
1039        let sink = fired.clone();
1040        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1041        let _cb = tree.add(
1042            Checkbox::new(checked.clone())
1043                .label(lit!("Accept"))
1044                .on_change(move |_now, _ctx| sink.set(true)),
1045        );
1046        tree.layout(SizeProposal::exact(200.0, 80.0));
1047
1048        // No event in flight to carry, so nothing to report. Observe the
1049        // signal for this direction.
1050        checked.set(true);
1051        assert!(!fired.get());
1052    }
1053
1054    #[test]
1055    fn on_change_reports_a_bool_from_a_tristate() {
1056        use std::cell::RefCell;
1057        use std::rc::Rc;
1058
1059        let state = Signal::new(CheckState::Indeterminate);
1060        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
1061        let sink = seen.clone();
1062        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1063        let cb = tree.add(
1064            Checkbox::tristate(state.clone())
1065                .label(lit!("All"))
1066                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
1067        );
1068        tree.layout(SizeProposal::exact(200.0, 80.0));
1069
1070        // Activation from Indeterminate checks the whole, then unchecks; it
1071        // never produces Indeterminate, which is why a bool is lossless here.
1072        tree.click(cb);
1073        tree.click(cb);
1074        assert_eq!(*seen.borrow(), vec![true, false]);
1075        assert_eq!(state.get(), CheckState::Unchecked);
1076    }
1077
1078    #[test]
1079    fn lone_keyup_does_not_toggle() {
1080        // Lone-KeyUp guard: a KeyUp with no matching KeyDown (e.g. a shortcut
1081        // consumed the KeyDown, then focus returned here) must NOT toggle.
1082        let checked = Signal::new(false);
1083        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1084        let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Accept")));
1085        tree.layout(SizeProposal::exact(200.0, 80.0));
1086        tree.focus(cb);
1087
1088        tree.dispatch_event(WidgetEvent::KeyUp {
1089            key: Key::Space,
1090            modifiers: Modifiers::NONE,
1091        });
1092        assert!(!checked.get(), "a lone KeyUp must not toggle the checkbox");
1093
1094        // A matched pair still toggles.
1095        tree.press_key(Key::Space, Modifiers::NONE);
1096        assert!(checked.get());
1097    }
1098
1099    #[test]
1100    fn disabled_ignores_click() {
1101        let checked = Signal::new(false);
1102        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1103        let cb = tree.add(
1104            Checkbox::new(checked.clone())
1105                .label(lit!("Accept"))
1106                .enabled(false),
1107        );
1108        tree.layout(SizeProposal::exact(200.0, 80.0));
1109
1110        tree.click(cb);
1111        assert!(!checked.get());
1112    }
1113
1114    #[test]
1115    fn two_state_accessibility() {
1116        let checked = Signal::new(true);
1117        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1118        let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
1119        tree.layout(SizeProposal::exact(200.0, 80.0));
1120
1121        let info = tree.accessibility_node(cb);
1122        assert_eq!(info.role(), teksilo_core::accesskit::Role::CheckBox);
1123        assert_eq!(info.name(), Some("Accept"));
1124        assert!(info.is_toggled());
1125    }
1126
1127    // --- Tristate tests ---
1128
1129    #[test]
1130    fn tristate_user_click_toggles_two_states() {
1131        // User clicks only toggle Checked ↔ Unchecked. Indeterminate is
1132        // reserved for external sources (TreeCheckedModel aggregation, etc.)
1133        // — clicking from Indeterminate checks the whole. Outlook / Files-app
1134        // folder-checkbox semantic.
1135        let state = Signal::new(CheckState::Unchecked);
1136        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1137        let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
1138        tree.layout(SizeProposal::exact(200.0, 80.0));
1139
1140        assert_eq!(state.get(), CheckState::Unchecked);
1141        tree.click(cb);
1142        assert_eq!(state.get(), CheckState::Checked);
1143        tree.click(cb);
1144        assert_eq!(state.get(), CheckState::Unchecked);
1145
1146        // Clicking from Indeterminate checks the whole, NOT cycles.
1147        state.set(CheckState::Indeterminate);
1148        tree.click(cb);
1149        assert_eq!(state.get(), CheckState::Checked);
1150    }
1151
1152    #[test]
1153    fn tristate_space_toggles_two_states() {
1154        let state = Signal::new(CheckState::Unchecked);
1155        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1156        let cb = tree.add(Checkbox::tristate(state.clone()).label(lit!("Select All")));
1157        tree.layout(SizeProposal::exact(200.0, 80.0));
1158
1159        tree.focus(cb);
1160        tree.press_key(Key::Space, Modifiers::NONE);
1161        assert_eq!(state.get(), CheckState::Checked);
1162        tree.press_key(Key::Space, Modifiers::NONE);
1163        assert_eq!(state.get(), CheckState::Unchecked);
1164    }
1165
1166    #[test]
1167    fn tristate_indeterminate_shows_filled_background() {
1168        // Indeterminate is_filled() == true, so it should have a primary background
1169        let state = Signal::new(CheckState::Indeterminate);
1170        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1171        tree.add(Checkbox::tristate(state).label(lit!("Partial")));
1172        tree.layout(SizeProposal::exact(200.0, 80.0));
1173        let frame = tree.render();
1174        let primary = teksilo_core::presets::intui::light()
1175            .colors
1176            .accent
1177            .to_array();
1178        assert!(
1179            frame.shapes.iter().any(|s| s.color == primary),
1180            "indeterminate checkbox should have primary-colored background"
1181        );
1182    }
1183
1184    #[test]
1185    fn check_state_conversions() {
1186        assert_eq!(CheckState::from(true), CheckState::Checked);
1187        assert_eq!(CheckState::from(false), CheckState::Unchecked);
1188        assert!(CheckState::Checked.is_filled());
1189        assert!(CheckState::Indeterminate.is_filled());
1190        assert!(!CheckState::Unchecked.is_filled());
1191    }
1192
1193    #[test]
1194    fn disabled_has_disabled_colors() {
1195        let checked = Signal::new(true);
1196        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1197        tree.add(
1198            Checkbox::new(checked)
1199                .label(lit!("Disabled"))
1200                .enabled(false),
1201        );
1202        tree.layout(SizeProposal::exact(200.0, 80.0));
1203        let frame = tree.render();
1204        let disabled_fill = teksilo_core::presets::intui::light()
1205            .colors
1206            .accent_disabled
1207            .to_array();
1208        assert!(
1209            frame.shapes.iter().any(|s| s.color == disabled_fill),
1210            "disabled checkbox should render with disabled_fill color"
1211        );
1212    }
1213
1214    #[test]
1215    fn accessibility_has_actions() {
1216        let checked = Signal::new(false);
1217        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1218        let cb = tree.add(Checkbox::new(checked).label(lit!("Accept")));
1219        tree.layout(SizeProposal::exact(200.0, 80.0));
1220        let info = tree.accessibility_node(cb);
1221        assert!(
1222            info.actions()
1223                .contains(&teksilo_core::accesskit::Action::Click)
1224        );
1225    }
1226
1227    // -----------------------------------------------------------------
1228    // The framework press (docs/touch-and-pen.md §7.1)
1229    // -----------------------------------------------------------------
1230
1231    /// A `CheckboxStyle` that hands the press and hover signals its chrome
1232    /// reads back to the test.
1233    struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
1234
1235    impl teksilo_core::styles::CheckboxStyle for PressProbe {
1236        fn make_body(
1237            &self,
1238            cfg: &teksilo_core::styles::CheckboxStyleConfig,
1239            ctx: &mut BuildContext,
1240        ) -> WidgetId {
1241            *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
1242            ctx.add(crate::primitives::FixedSize::new().width(19.0).height(19.0))
1243        }
1244    }
1245
1246    #[allow(clippy::type_complexity)]
1247    fn probed_checkbox_with_hover() -> (
1248        WidgetTree,
1249        WidgetId,
1250        Signal<bool>,
1251        Signal<bool>,
1252        Signal<bool>,
1253    ) {
1254        let probe: std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>> =
1255            std::rc::Rc::new(std::cell::RefCell::new(None));
1256        let checked = Signal::new(false);
1257        let mut theme = teksilo_core::presets::intui::light();
1258        theme.style_slots.checkbox = Some(std::rc::Rc::new(PressProbe(probe.clone())));
1259        let mut tree = WidgetTree::new().with_theme(theme);
1260        let cb = tree.add(Checkbox::new(checked.clone()).label(lit!("Ready")));
1261        tree.layout(SizeProposal::exact(200.0, 60.0));
1262        let (pressed, hovered) = probe.borrow().clone().expect("style ran");
1263        (tree, cb, pressed, hovered, checked)
1264    }
1265
1266    fn probed_checkbox() -> (WidgetTree, WidgetId, Signal<bool>, Signal<bool>) {
1267        let (tree, cb, pressed, _hovered, checked) = probed_checkbox_with_hover();
1268        (tree, cb, pressed, checked)
1269    }
1270
1271    /// A mouse press now lights the checkbox's own `is_pressed` — the signal
1272    /// the style has always been handed, and which until the controls sweep
1273    /// only a keyboard `Space` could ever set. The toggle still lands on the
1274    /// release.
1275    #[test]
1276    fn a_mouse_press_lights_the_pressed_state_and_the_release_toggles() {
1277        let (mut tree, cb, pressed, checked) = probed_checkbox();
1278        let at = tree.bounds(cb).center();
1279        tree.pointer_move(at);
1280        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
1281        assert!(pressed.get(), "the press shows");
1282        assert!(!checked.get(), "and has not toggled anything yet");
1283        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
1284        assert!(!pressed.get());
1285        assert!(checked.get(), "the release toggles");
1286    }
1287
1288    /// A finger: the press lights, the release toggles, and — with no hover to
1289    /// clear it — the control rests idle rather than stuck in the hover tint a
1290    /// finger never earned.
1291    #[test]
1292    fn a_touch_tap_toggles_on_release() {
1293        use crate::button::press_test_support::{finger, touch};
1294        use teksilo_core::pointer::PointerPhase;
1295
1296        let (mut tree, cb, pressed, checked) = probed_checkbox();
1297        let at = tree.bounds(cb).center();
1298        let id = finger();
1299        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1300        assert!(pressed.get());
1301        assert!(!checked.get());
1302        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
1303        assert!(checked.get());
1304        assert!(!pressed.get());
1305    }
1306
1307    /// Sliding off abandons the press visual and the toggle; sliding back on
1308    /// restores the visual (WCAG 2.2 SC 2.5.2's abort gesture, reversible).
1309    #[test]
1310    fn a_touch_press_disarms_on_slide_off_and_re_arms_on_re_entry() {
1311        use crate::button::press_test_support::{finger, touch};
1312        use teksilo_core::pointer::PointerPhase;
1313
1314        let (mut tree, cb, pressed, checked) = probed_checkbox();
1315        let bounds = tree.bounds(cb);
1316        let at = bounds.center();
1317        let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 80.0);
1318        let id = finger();
1319        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
1320        tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
1321        assert!(!pressed.get(), "the press left its target");
1322        tree.dispatch_pointer(touch(id, PointerPhase::Move, at, 40));
1323        assert!(pressed.get(), "and came back");
1324        tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 60));
1325        assert!(!checked.get(), "a release off the control toggles nothing");
1326    }
1327
1328    /// Where the checkbox comes to rest after a toggle — the `on_tap` copy of
1329    /// the button family's resting-state rule, which lives in this file rather
1330    /// than being shared, and so has to be pinned here too.
1331    ///
1332    /// The pressed transitions are shared (`button::bind_press_interaction`);
1333    /// this one is not. A mouse is still over the box when it lifts, so the
1334    /// box rests hovered; a finger is gone and sends no hover-leave, so it
1335    /// rests idle. Asserted through `CheckboxStyleConfig::is_hovered`, the
1336    /// signal the chrome tints from.
1337    #[test]
1338    fn a_mouse_toggle_rests_hovered_and_a_finger_toggle_rests_idle() {
1339        use crate::button::press_test_support::touch_tap;
1340
1341        let (mut tree, cb, pressed, hovered, checked) = probed_checkbox_with_hover();
1342        let at = tree.bounds(cb).center();
1343        tree.pointer_move(at);
1344        assert!(hovered.get(), "the pointer arrived over the box");
1345        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
1346        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
1347        assert!(checked.get(), "the release toggled");
1348        assert!(!pressed.get());
1349        assert!(
1350            hovered.get(),
1351            "a mouse that clicked the box is still on it, so it rests hovered",
1352        );
1353
1354        let (mut tree, cb, pressed, hovered, checked) = probed_checkbox_with_hover();
1355        let at = tree.bounds(cb).center();
1356        touch_tap(&mut tree, at);
1357        assert!(checked.get(), "the contact toggled on its release");
1358        assert!(!pressed.get());
1359        assert!(
1360            !hovered.get(),
1361            "a finger leaves nothing behind, so the box must rest idle",
1362        );
1363    }
1364
1365    /// The hit area is the 24 dp box the recipe reserves around a 19 dp glyph,
1366    /// so the checkbox needs no widening mechanism at all.
1367    #[test]
1368    fn the_checkbox_hit_box_clears_the_conformance_floor_at_compact() {
1369        let theme = teksilo_core::presets::intui::light();
1370        let floor = theme.input.min_target_conformance;
1371        let mut tree = WidgetTree::new().with_theme(theme);
1372        let cb = tree.add(Checkbox::new(Signal::new(false)));
1373        tree.layout(SizeProposal::exact(200.0, 60.0));
1374        let b = tree.bounds(cb);
1375        assert!(
1376            b.width >= floor && b.height >= floor,
1377            "a bare Compact Checkbox measured {}x{}",
1378            b.width,
1379            b.height,
1380        );
1381    }
1382}