Skip to main content

teksilo_widgets/
toggle.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Toggle — an animated on/off switch bound to a [`Signal<bool>`](teksilo_core::signal::Signal).
5//!
6//! Renders as a sliding-knob switch (IntUI default) or one of the alternate
7//! [`ToggleVariant`] shapes. All visual chrome is delegated to a [`ToggleStyle`]
8//! impl; the widget itself owns only event handling (tap, Space, AccessKit
9//! `Click`). The IntUI recipe
10//! ([`crate::styles::RecipeToggleStyle`]) ships out of the box; apps install a
11//! custom look per-call with `.style(impl ToggleStyle)` or theme-wide via
12//! `theme.style_slots.toggle = Some(Rc::new(…))`.
13//!
14//! ## Accessibility
15//!
16//! Emits `Role::Switch` with `toggled` reflecting the signal value. Always pair
17//! with `.label(…)` — the debug build asserts that a label is present, and
18//! screen readers will announce "switch" with no context if it is absent.
19//!
20//! ## Example
21//!
22//! ```rust
23//! # use teksilo_widgets::Toggle;
24//! # use teksilo_core::signal::Signal;
25//! # use teksilo_i18n::lit;
26//! let dark_mode = Signal::new(false);
27//! let _w = Toggle::new(dark_mode)
28//!     .label(lit!("Dark mode"));
29//! ```
30//!
31//! ## Touch and pen
32//!
33//! The pressed state is the framework's (`docs/touch-and-pen.md` §7.1) — a
34//! Material 3 thumb that grows on press must not stay grown after the finger
35//! has slid off the switch, nor grow under a finger that turns out to be
36//! scrolling the list the switch sits in. The flip lands on the release.
37
38use std::rc::Rc;
39
40use teksilo_canvas::{Rect, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::binding::BindingLevel;
43use teksilo_core::build_context::BuildContext;
44use teksilo_core::event::{EventResponse, Key, WidgetEvent};
45use teksilo_core::signal::{Prop, Signal};
46use teksilo_core::styles::{SharedToggleStyle, ToggleStyle, ToggleStyleConfig};
47use teksilo_core::widget::{
48    CursorIcon, EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement,
49};
50use teksilo_core::widget_builder::HandlerSet;
51use teksilo_core::widget_id::WidgetId;
52
53// Re-export the variant enum at module top so callers can write
54// `Toggle::new(...).variant(ToggleVariant::Pill)` without a deeper
55// import path. Same pattern as `Button` re-exporting `ButtonVariant`.
56pub use teksilo_core::styles::ToggleVariant;
57use teksilo_i18n::LocalizedString;
58
59/// An animated toggle switch bound to a `Signal<bool>`.
60pub struct Toggle {
61    on: Signal<bool>,
62    on_change: Option<Rc<dyn Fn(bool, &mut EventContext)>>,
63    label: Option<LocalizedString>,
64    /// Enabled state, static or reactive; forwarded to the arena at build
65    /// time.
66    enabled: Prop<bool>,
67    variant: ToggleVariant,
68    style: Option<SharedToggleStyle>,
69    hovered: Signal<bool>,
70    focused: Signal<bool>,
71    pressed: Signal<bool>,
72    body_id: Option<WidgetId>,
73    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
74    /// with the rich / composite slots — every setter clears the other two so
75    /// the last call wins.
76    tooltip_text: Option<LocalizedString>,
77    /// Optional rich tooltip source (registry key or inline content).
78    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
79    /// Optional composite tooltip body (arbitrary widget tree).
80    composite_tooltip_content: Option<Box<dyn Widget>>,
81    /// The accessible name comes from a sibling label wired after mount — see
82    /// [`Toggle::labelled_externally`].
83    labelled_externally: bool,
84}
85
86impl Toggle {
87    /// Create a toggle bound to `on`. The signal is both read (to paint the
88    /// current state) and written (flipped on each activation).
89    ///
90    /// See [`on_change`](Self::on_change) when flipping it has to reach the
91    /// ambient context rather than only app state.
92    pub fn new(on: Signal<bool>) -> Self {
93        Self {
94            on,
95            on_change: None,
96            label: None,
97            enabled: Prop::Static(true),
98            variant: ToggleVariant::default(),
99            style: None,
100            hovered: Signal::new(false),
101            focused: Signal::new(false),
102            pressed: Signal::new(false),
103            body_id: None,
104            tooltip_text: None,
105            rich_tooltip_source: None,
106            composite_tooltip_content: None,
107            labelled_externally: false,
108        }
109    }
110
111    /// Accessible label announced by AT and optionally displayed beside the switch.
112    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
113        let ls: LocalizedString = label.into();
114        self.label = Some(ls);
115        self
116    }
117
118    /// Run `f` when the **user** flips this switch, with the value the
119    /// activation produced and an `EventContext`, so it can do what a bare
120    /// `Signal` write cannot (`ctx.send_intent(...)`, `ctx.set_theme(...)`,
121    /// opening a window). Fires for the pointer, for `Space`, and for an
122    /// assistive-technology `Click`.
123    ///
124    /// Does **not** fire for programmatic writes to the bound signal — there is
125    /// no event in flight to carry. Observe the signal for that. The signal
126    /// stays the source of truth either way: it is written first, and `f` sees
127    /// the value it now holds.
128    ///
129    /// Spelled the same way on [`Checkbox`](crate::checkbox::Checkbox).
130    pub fn on_change(mut self, f: impl Fn(bool, &mut EventContext) + 'static) -> Self {
131        self.on_change = Some(Rc::new(f));
132        self
133    }
134
135    /// Declare that this toggle's accessible name comes from a **sibling label
136    /// widget**, wired by a container after mount (`FormLayout::line` does this
137    /// via `access_labelled_by`).
138    ///
139    /// Without it the debug assertion in `accessibility()` fires even though
140    /// the toggle *is* properly labelled: the `labelled_by` relation is pushed
141    /// post-mount, so `accessibility()` cannot see it and every form-hosted
142    /// toggle looks nameless. Setting `.label(..)` instead would satisfy the
143    /// assert but render the text a second time, beside a label column that
144    /// already has it.
145    pub fn labelled_externally(mut self) -> Self {
146        self.labelled_externally = true;
147        self
148    }
149
150    /// Set the enabled state, statically or reactively. Forwarded to the
151    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at
152    /// build time.
153    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
154        self.enabled = enabled.into();
155        self
156    }
157
158    /// Pick a Tier-1 design-language variant
159    /// ([`ToggleVariant::Switch`] / `Pill` / `Square` / `Inset`). The
160    /// active [`ToggleStyle`] decides what to do with the hint —
161    /// IntUI's default impl honours all four; a custom impl might
162    /// ignore the variant entirely.
163    pub fn variant(mut self, variant: ToggleVariant) -> Self {
164        self.variant = variant;
165        self
166    }
167
168    /// Override the active [`ToggleStyle`] for this widget instance
169    /// only. Useful for one-off custom-painted toggles in a single
170    /// view.
171    pub fn style(mut self, style: impl ToggleStyle) -> Self {
172        self.style = Some(Rc::new(style));
173        self
174    }
175
176    /// Attach a plain single-line tooltip shown after a hover delay.
177    ///
178    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
179    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
180    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter
181    /// called wins and clears the others.
182    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
183        self.tooltip_text = Some(text.into());
184        self.rich_tooltip_source = None;
185        self.composite_tooltip_content = None;
186        self
187    }
188
189    /// Attach a rich tooltip looked up by registry `key`.
190    ///
191    /// Mutually exclusive with the other tooltip setters — the last
192    /// setter called wins and clears the others.
193    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
194        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
195        self.tooltip_text = None;
196        self.composite_tooltip_content = None;
197        self
198    }
199
200    /// Attach a rich tooltip from an inline [`crate::tooltip::TooltipContent`]
201    /// value rather than a registry key.
202    ///
203    /// Mutually exclusive with the other tooltip setters — the last
204    /// setter called wins and clears the others.
205    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
206        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
207        self.tooltip_text = None;
208        self.composite_tooltip_content = None;
209        self
210    }
211
212    /// Attach a composite tooltip whose body is an arbitrary widget tree.
213    ///
214    /// Mutually exclusive with the other tooltip setters — the last
215    /// setter called wins and clears the others.
216    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
217        self.composite_tooltip_content = Some(Box::new(content));
218        self.tooltip_text = None;
219        self.rich_tooltip_source = None;
220        self
221    }
222}
223
224impl std::fmt::Debug for Toggle {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("Toggle")
227            .field("enabled", &self.enabled.get())
228            .field("variant", &self.variant)
229            .finish()
230    }
231}
232
233impl Widget for Toggle {
234    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
235        let self_id = ctx.self_id();
236        // Forward the enabled state into the arena; see IconButton.
237        ctx.enabled_when(self_id, self.enabled.clone());
238        let effective_enabled = ctx.effective_enabled_signal(self_id);
239        // `accessibility()` reads `on` only when the tree is walked, and the
240        // style's repaint does not walk it; see `Checkbox::build`.
241        self.on.bind_to(
242            self_id,
243            ctx.binding_registry(),
244            BindingLevel::AccessibilityOnly,
245        );
246        // Resolve the active style: per-call override > theme slot >
247        // built-in `RecipeToggleStyle` default.
248        let style: SharedToggleStyle = self
249            .style
250            .clone()
251            .or_else(|| ctx.theme().style_slots.toggle.clone())
252            .unwrap_or_else(|| {
253                Rc::new(crate::styles::RecipeToggleStyle::for_tokens(
254                    &ctx.theme().input,
255                ))
256            });
257
258        // Build the visual body via the active style. The body is a
259        // child subtree we'll lay out to the bounds we get.
260        let cfg = ToggleStyleConfig {
261            is_on: self.on.clone(),
262            is_hovered: self.hovered.clone(),
263            is_pressed: self.pressed.clone(),
264            is_focused: self.focused.clone(),
265            // `:focus-visible` — input modality, so the recipe shows the
266            // focus ring only during keyboard navigation, not on a click.
267            is_focus_visible: ctx.focus_visible(),
268            // is_disabled tracks the arena's effective enabled-state
269            // reactively (see `BuildContext::effective_enabled_signal`).
270            is_disabled: effective_enabled.map(|on| !*on),
271            variant: self.variant,
272        };
273        let body_id = style.make_body(&cfg, ctx);
274
275        // Wrap body + optional label in an HStack so the label paints
276        // alongside the body without this widget needing a `paint()`
277        // method. label_gap is small (6 dp default in IntUI); a fixed
278        // `HStack::spacing` is plenty without a per-theme token here.
279        let root = if let Some(ref label) = self.label {
280            use crate::primitives::{HStack, TextWidget};
281            use teksilo_tokens::TextStyleRole;
282            // The Toggle's own `Role::Switch` node already carries this text
283            // as its name (see `accessibility` below), so the embedded Label
284            // must not reach the AT tree as a second, duplicate-named stop.
285            let label_widget = TextWidget::new(label.clone())
286                .style(TextStyleRole::Body)
287                .a11y_hidden();
288            let label_id = ctx.add(label_widget);
289            ctx.add(HStack::new().spacing(6.0).child(body_id).child(label_id))
290        } else {
291            body_id
292        };
293        self.body_id = Some(root);
294
295        // Attach tooltip (at most one tier fires; each setter cleared the others).
296        if let Some(content) = self.composite_tooltip_content.take() {
297            let delay = ctx.theme().motion.tooltip_delay_heavy;
298            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
299        } else if let Some(source) = self.rich_tooltip_source.clone() {
300            let delay = ctx.theme().motion.tooltip_delay;
301            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
302        } else if let Some(text) = self.tooltip_text.clone() {
303            let delay = ctx.theme().motion.tooltip_delay;
304            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
305        }
306
307        // Wire up the toggle's interactive behaviour. The body owns
308        // paint; the wrapper owns input handling.
309        let on = self.on.clone();
310        let hovered = self.hovered.clone();
311        let focused = self.focused.clone();
312        let pressed = self.pressed.clone();
313
314        let toggle = {
315            let on = on.clone();
316            let on_change = self.on_change.clone();
317            move |ctx: &mut EventContext| {
318                let now = !on.get();
319                on.set(now);
320                if let Some(ref f) = on_change {
321                    f(now, ctx);
322                }
323            }
324        };
325
326        // Framework gates events on `arena.is_enabled(self_id)`; the
327        // focus walker skips disabled subtrees. No need to AND with
328        // a per-handler `enabled` snapshot anymore.
329        let mut handlers = HandlerSet::new()
330            .focusable(true)
331            .cursor(CursorIcon::Pointer);
332
333        {
334            let toggle = toggle.clone();
335            handlers = handlers.on_tap(move |_pos, ctx| {
336                toggle(ctx);
337            });
338        }
339        {
340            let hovered = hovered.clone();
341            handlers = handlers.on_hover(move |entered, _ctx| {
342                hovered.set(entered);
343            });
344        }
345        // Pointer-pressed signal, taken from the framework rather than kept
346        // here. IntUI ignores it; design languages with press feedback (the
347        // Material 3 switch's thumb-grow) read `is_pressed`, and a thumb that
348        // stays grown after the finger has slid off the switch — or that grows
349        // under a finger which turns out to be scrolling the list the switch
350        // sits in — is exactly what the router's state exists to prevent.
351        crate::common::interaction::bind_pressed(ctx, pressed.clone());
352        {
353            let toggle = toggle.clone();
354            // Lone-KeyUp guard: track whether we saw the matching KeyDown so
355            // a stray KeyUp (e.g. a shortcut consumed the KeyDown and focus
356            // returned here) does NOT toggle.
357            let key_pressed = std::cell::Cell::new(false);
358            handlers = handlers.on_key(move |event, ctx| match event {
359                WidgetEvent::KeyDown {
360                    key: Key::Space, ..
361                } => {
362                    key_pressed.set(true);
363                    EventResponse::Handled
364                }
365                WidgetEvent::KeyUp {
366                    key: Key::Space, ..
367                } => {
368                    if !key_pressed.replace(false) {
369                        return EventResponse::Ignored;
370                    }
371                    toggle(ctx);
372                    EventResponse::Handled
373                }
374                _ => EventResponse::Ignored,
375            });
376        }
377        {
378            let focused = focused.clone();
379            handlers = handlers.on_focus(move |gained, _ctx| focused.set(gained));
380        }
381        {
382            let toggle = toggle.clone();
383            handlers = handlers.on_access_action(move |action, ctx| {
384                if action == teksilo_core::accesskit::Action::Click {
385                    toggle(ctx);
386                    EventResponse::Handled
387                } else {
388                    EventResponse::Ignored
389                }
390            });
391        }
392
393        ctx.apply_self_handlers(handlers);
394
395        // Return `root` (the HStack wrapper when a label is set,
396        // else the bare body). Returning `body_id` here would leave
397        // the HStack as a parent-less arena root: its child list
398        // would still list `body_id`, and the AccessKit walker would
399        // see `body_id` claimed by both Toggle and the orphan HStack
400        // — a "duplicate accessibility child" log on every refresh.
401        vec![root]
402    }
403
404    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
405        self.body_id
406            .and_then(|id| ctx.child_size(id, proposal))
407            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
408            .into()
409    }
410
411    fn place_children(
412        &self,
413        bounds: Rect,
414        _proposal: SizeProposal,
415        children: &mut [WidgetPlacement],
416        _ctx: &LayoutContext,
417    ) {
418        if let Some(child) = children.first_mut() {
419            child.origin = bounds.origin();
420            child.size = bounds.size();
421        }
422    }
423
424    fn children(&self) -> Vec<WidgetId> {
425        self.body_id.into_iter().collect()
426    }
427
428    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
429        debug_assert!(
430            self.label.is_some() || self.labelled_externally,
431            "Toggle is missing an accessible label — \
432             screen readers will announce \"switch\" with no context. \
433             Call .label(...) when constructing the widget."
434        );
435        builder.set_role(teksilo_core::accesskit::Role::Switch);
436        if let Some(ref label) = self.label {
437            builder.set_name(label.resolve_now());
438        }
439        builder.set_toggled(self.on.get());
440        // Framework a11y walker sets `set_disabled` from arena state.
441        builder.add_action(teksilo_core::accesskit::Action::Click);
442        builder.add_action(teksilo_core::accesskit::Action::Focus);
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::time::Duration;
449
450    use super::*;
451
452    use teksilo_core::event::Modifiers;
453    use teksilo_core::widget_tree::WidgetTree;
454    use teksilo_i18n::lit;
455
456    #[test]
457    fn focus_ring_only_under_focus_visible() {
458        // `:focus-visible`: the keyboard-only focus ring. Programmatic focus
459        // leaves `focus_visible` false → no ring; a key press reveals it.
460        let theme = teksilo_core::presets::intui::light();
461        let ring = theme.colors.focus_ring.to_array();
462        let mut tree = WidgetTree::new().with_theme(theme);
463        let t = tree.add(Toggle::new(Signal::new(false)));
464        tree.layout(SizeProposal::exact(120.0, 60.0));
465
466        tree.focus(t);
467        assert!(
468            !frame_has_ring(&tree.render(), ring),
469            "no focus ring while focus-visible is false (pointer modality)",
470        );
471
472        tree.press_key(Key::ArrowDown, Modifiers::NONE);
473        assert!(
474            frame_has_ring(&tree.render(), ring),
475            "focus ring shows under keyboard modality",
476        );
477    }
478
479    #[test]
480    fn is_pressed_tracks_pointer_down_and_up() {
481        use std::cell::RefCell;
482        use std::rc::Rc;
483        use teksilo_core::event::PointerButton;
484        use teksilo_core::styles::{ToggleStyle, ToggleStyleConfig};
485
486        // A style that captures the cfg's is_pressed signal so the test can
487        // observe it (IntUI ignores is_pressed, so it isn't visible in paint).
488        struct CaptureStyle(Rc<RefCell<Option<Signal<bool>>>>);
489        impl ToggleStyle for CaptureStyle {
490            fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
491                *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
492                ctx.add(crate::primitives::RectWidget::new())
493            }
494        }
495
496        let captured: Rc<RefCell<Option<Signal<bool>>>> = Rc::new(RefCell::new(None));
497        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
498        let t = tree.add(Toggle::new(Signal::new(false)).style(CaptureStyle(captured.clone())));
499        tree.layout(SizeProposal::exact(120.0, 60.0));
500
501        let pressed = captured.borrow().clone().expect("is_pressed captured");
502        assert!(!pressed.get(), "not pressed initially");
503
504        let b = tree.bounds(t);
505        let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
506        tree.pointer_down_button(center, PointerButton::Primary);
507        assert!(pressed.get(), "pressed after PointerDown");
508        tree.pointer_up_button(center, PointerButton::Primary);
509        assert!(!pressed.get(), "released after PointerUp");
510    }
511
512    /// Whether the focus-ring *stroke* (ring color + non-zero stroke width) is
513    /// present. A plain color match is ambiguous: in IntUI `focus_ring` shares
514    /// the `accent` RGBA, and the toggle paints accent *fills* — the ring is
515    /// the only *stroked* shape in that color.
516    fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
517        frame
518            .shapes
519            .iter()
520            .any(|s| s.color == color && s.stroke_width > 0.0)
521            || frame.cosmetic_lines.iter().any(|l| l.color == color)
522    }
523
524    /// A middle-click is not a `Toggle` activation, so it must neither flip
525    /// the switch nor raise the press visual: before the framework owned the
526    /// press this widget gated its own handler on `PointerButton::Primary`,
527    /// and the framework gate has to keep that promise.
528    #[test]
529    fn a_middle_press_leaves_the_toggle_alone() {
530        use teksilo_core::event::PointerButton;
531
532        let on = Signal::new(false);
533        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
534        let t = tree.add(Toggle::new(on.clone()));
535        tree.layout(SizeProposal::exact(120.0, 60.0));
536        let b = tree.bounds(t);
537        let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
538
539        tree.pointer_down_button(center, PointerButton::Middle);
540        assert!(!tree.is_pressed(t), "a middle press raises no visual");
541        tree.pointer_up_button(center, PointerButton::Middle);
542        assert!(!on.get(), "and flips nothing");
543
544        // The button the toggle does act on still does both.
545        tree.pointer_down_button(center, PointerButton::Primary);
546        assert!(tree.is_pressed(t), "a primary press raises the visual");
547        tree.pointer_up_button(center, PointerButton::Primary);
548        assert!(!tree.is_pressed(t), "the release clears it");
549        assert!(on.get(), "and the toggle flipped");
550    }
551
552    #[test]
553    fn click_toggles_state() {
554        let on = Signal::new(false);
555        let mut tree = WidgetTree::new();
556        let t = tree.add(Toggle::new(on.clone()));
557        tree.layout(SizeProposal::exact(100.0, 60.0));
558
559        tree.click(t);
560        assert!(on.get());
561        tree.click(t);
562        assert!(!on.get());
563    }
564
565    #[test]
566    fn space_toggles_state() {
567        let on = Signal::new(false);
568        let mut tree = WidgetTree::new();
569        let t = tree.add(Toggle::new(on.clone()));
570        tree.layout(SizeProposal::exact(100.0, 60.0));
571
572        tree.focus(t);
573        tree.press_key(Key::Space, Modifiers::NONE);
574        assert!(on.get());
575    }
576
577    #[test]
578    fn lone_keyup_does_not_toggle() {
579        // Lone-KeyUp guard: a KeyUp with no matching KeyDown must NOT toggle.
580        let on = Signal::new(false);
581        let mut tree = WidgetTree::new();
582        let t = tree.add(Toggle::new(on.clone()));
583        tree.layout(SizeProposal::exact(100.0, 60.0));
584
585        tree.focus(t);
586        tree.dispatch_event(WidgetEvent::KeyUp {
587            key: Key::Space,
588            modifiers: Modifiers::NONE,
589        });
590        assert!(!on.get(), "a lone KeyUp must not toggle the switch");
591
592        tree.press_key(Key::Space, Modifiers::NONE);
593        assert!(on.get());
594    }
595
596    #[test]
597    fn animation_runs_after_toggle() {
598        let on = Signal::new(false);
599        let mut tree = WidgetTree::new();
600        let t = tree.add(Toggle::new(on.clone()));
601        tree.layout(SizeProposal::exact(100.0, 60.0));
602
603        tree.click(t); // toggles on, body's effect tweens knob
604        assert!(on.get());
605
606        // Mid-flight: animation should still be running.
607        tree.tick_animations(Duration::from_millis(75));
608        assert!(tree.has_active_animations());
609
610        // After the full duration, animation completes.
611        tree.tick_animations(Duration::from_millis(200));
612        assert!(!tree.has_active_animations());
613    }
614
615    #[test]
616    fn accessibility() {
617        let on = Signal::new(true);
618        let mut tree = WidgetTree::new();
619        let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
620        tree.layout(SizeProposal::exact(100.0, 60.0));
621        let info = tree.accessibility_node(t);
622        assert_eq!(info.role(), teksilo_core::accesskit::Role::Switch);
623        assert!(info.is_toggled());
624    }
625
626    #[test]
627    fn labeled_toggle_does_not_duplicate_its_label_as_a_separate_at_node() {
628        // The Toggle's own `Role::Switch` node already carries "Dark mode"
629        // as its name, so the embedded Label built alongside the switch
630        // body must not survive as a second, duplicate-named stop.
631        use teksilo_core::accessibility::widget_id_to_node_id;
632        let on = Signal::new(false);
633        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
634        let id = tree.add(Toggle::new(on).label(lit!("Dark mode")));
635        tree.layout(SizeProposal::exact(200.0, 60.0));
636        let update = tree.sync_accessibility();
637
638        let toggle_node_id = widget_id_to_node_id(id);
639        let toggle_node = update
640            .nodes
641            .iter()
642            .find(|(nid, _)| *nid == toggle_node_id)
643            .map(|(_, node)| node)
644            .expect("toggle node");
645        assert_eq!(
646            toggle_node.label(),
647            Some("Dark mode"),
648            "hiding the embedded label must not take the switch's own name away with it",
649        );
650
651        assert!(
652            !update.nodes.iter().any(|(_, node)| node.role()
653                == teksilo_core::accesskit::Role::Label
654                && node.label() == Some("Dark mode")),
655            "the label Text widget must not survive as a duplicate-named AT node",
656        );
657    }
658
659    /// Regression: a labeled Toggle wraps its body + label in an
660    /// HStack inside `build`. The earlier code returned the inner
661    /// body id instead of the HStack id, which left the HStack
662    /// parent-less in the arena. The AccessKit walker then saw the
663    /// body id claimed by both Toggle and the orphan HStack —
664    /// "Teksilo bug: duplicate accessibility child …" on every AT
665    /// refresh.
666    #[test]
667    fn labeled_toggle_does_not_orphan_hstack_wrapper() {
668        let on = Signal::new(false);
669        let mut tree = WidgetTree::new();
670        let _t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
671        tree.layout(SizeProposal::exact(200.0, 60.0));
672        let update = tree.sync_accessibility();
673        let mut seen = std::collections::HashMap::new();
674        for (parent_id, node) in &update.nodes {
675            for &child_id in node.children() {
676                let prev = seen.insert(child_id, *parent_id);
677                assert!(
678                    prev.is_none(),
679                    "duplicate AT child {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
680                );
681            }
682        }
683    }
684
685    #[test]
686    fn a_reader_is_told_each_change_as_it_happens() {
687        // The switch told no platform when Space or a screen reader's own
688        // click turned it: "pressed" came out only with the next unrelated
689        // walk of the tree, and was cut by the focus move that caused it.
690        use crate::common::heard_test::{Heard, Listener};
691        use teksilo_core::accesskit::Toggled;
692
693        let on = Signal::new(false);
694        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
695        let t = tree.add(Toggle::new(on.clone()).label(lit!("Dark mode")));
696        let p = SizeProposal::exact(200.0, 60.0);
697        tree.layout(p);
698        tree.focus(t);
699        tree.layout(p);
700        let mut listener = Listener::attach(&mut tree);
701
702        tree.press_key(Key::Space, Modifiers::NONE);
703        tree.layout(p);
704        assert_eq!(
705            listener.heard(&mut tree),
706            vec![Heard::FocusToggled(Toggled::True)],
707            "Space"
708        );
709
710        tree.dispatch_access_action(
711            teksilo_core::accessibility::widget_id_to_node_id(t),
712            teksilo_core::accesskit::Action::Click,
713            None,
714            &mut teksilo_core::NoopWindowOps,
715        );
716        tree.layout(p);
717        assert_eq!(
718            listener.heard(&mut tree),
719            vec![Heard::FocusToggled(Toggled::False)],
720            "a screen reader's click"
721        );
722    }
723
724    #[test]
725    fn on_change_fires_for_every_user_path_and_reports_the_new_value() {
726        use std::cell::RefCell;
727        use std::rc::Rc;
728
729        let on = Signal::new(false);
730        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
731        let sink = seen.clone();
732        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
733        let t = tree.add(
734            Toggle::new(on.clone())
735                .label(lit!("Dark mode"))
736                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
737        );
738        tree.layout(SizeProposal::exact(200.0, 60.0));
739
740        tree.click(t);
741        tree.focus(t);
742        tree.press_key(Key::Space, Modifiers::NONE);
743        tree.dispatch_access_action(
744            teksilo_core::accessibility::widget_id_to_node_id(t),
745            teksilo_core::accesskit::Action::Click,
746            None,
747            &mut teksilo_core::NoopWindowOps,
748        );
749
750        assert_eq!(*seen.borrow(), vec![true, false, true]);
751        assert!(on.get());
752    }
753
754    #[test]
755    fn on_change_is_silent_for_a_programmatic_write() {
756        use std::cell::Cell;
757        use std::rc::Rc;
758
759        let on = Signal::new(false);
760        let fired = Rc::new(Cell::new(false));
761        let sink = fired.clone();
762        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
763        let _t = tree.add(
764            Toggle::new(on.clone())
765                .label(lit!("Dark mode"))
766                .on_change(move |_now, _ctx| sink.set(true)),
767        );
768        tree.layout(SizeProposal::exact(200.0, 60.0));
769
770        // No event in flight to carry. Observe the signal for this direction.
771        on.set(true);
772        assert!(!fired.get());
773    }
774
775    #[test]
776    fn accessibility_has_actions() {
777        let on = Signal::new(false);
778        let mut tree = WidgetTree::new();
779        let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
780        tree.layout(SizeProposal::exact(100.0, 60.0));
781        let info = tree.accessibility_node(t);
782        assert!(
783            info.actions()
784                .contains(&teksilo_core::accesskit::Action::Click)
785        );
786    }
787
788    /// The **rich** tier, on a Toggle, opens the same way the plain one does.
789    ///
790    /// Its own case beside `tooltip_appears_on_hover` because the two tiers take
791    /// different attach paths out of `build` — `attach_rich_tooltip_source`
792    /// against `ctx.attach_tooltip` — and only the plain one was pinned. A
793    /// settings page that moved its explanations onto its switches is the first
794    /// caller to depend on the rich one here.
795    #[test]
796    fn a_rich_tooltip_appears_on_hover_too() {
797        let mut tree = WidgetTree::new();
798        let id = tree.add(
799            Toggle::new(Signal::new(false))
800                .label(lit!("Wi-Fi"))
801                .rich_tooltip_content(crate::tooltip::TooltipContent::new(
802                    "toggle.rich",
803                    lit!("What this switch does"),
804                )),
805        );
806        tree.layout(SizeProposal::exact(300.0, 200.0));
807        tree.pointer_move(tree.bounds(id).center());
808        tree.advance_time(std::time::Duration::from_secs(1));
809        assert_eq!(
810            tree.active_overlays().len(),
811            1,
812            "a rich tooltip on a Toggle must open under the pointer"
813        );
814    }
815
816    #[test]
817    fn tooltip_appears_on_hover() {
818        let mut tree = WidgetTree::new();
819        let id = tree.add(
820            Toggle::new(Signal::new(false))
821                .label(lit!("Wi-Fi"))
822                .tooltip(lit!("Tip")),
823        );
824        tree.layout(SizeProposal::exact(300.0, 200.0));
825        tree.pointer_move(tree.bounds(id).center());
826        tree.advance_time(std::time::Duration::from_secs(1));
827        assert_eq!(
828            tree.active_overlays().len(),
829            1,
830            "tooltip should appear on hover"
831        );
832        assert!(tree.find_by_label("Tip").is_some());
833    }
834
835    // -----------------------------------------------------------------
836    // The framework press (docs/touch-and-pen.md §7.1)
837    // -----------------------------------------------------------------
838
839    struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<Signal<bool>>>>);
840
841    impl teksilo_core::styles::ToggleStyle for PressProbe {
842        fn make_body(
843            &self,
844            cfg: &teksilo_core::styles::ToggleStyleConfig,
845            ctx: &mut BuildContext,
846        ) -> WidgetId {
847            *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
848            ctx.add(crate::primitives::FixedSize::new().width(36.0).height(20.0))
849        }
850    }
851
852    fn probed_toggle() -> (WidgetTree, WidgetId, Signal<bool>, Signal<bool>) {
853        let probe: std::rc::Rc<std::cell::RefCell<Option<Signal<bool>>>> =
854            std::rc::Rc::new(std::cell::RefCell::new(None));
855        let on = Signal::new(false);
856        let mut theme = teksilo_core::presets::intui::light();
857        theme.style_slots.toggle = Some(std::rc::Rc::new(PressProbe(probe.clone())));
858        let mut tree = WidgetTree::new().with_theme(theme);
859        let t = tree.add(Toggle::new(on.clone()));
860        tree.layout(SizeProposal::exact(200.0, 60.0));
861        let pressed = probe.borrow().clone().expect("style ran");
862        (tree, t, pressed, on)
863    }
864
865    /// The worked example the rest of the sweep follows: the press visual comes
866    /// from the router, the flip lands on the release, and sliding off abandons
867    /// both — a thumb that stayed grown under a finger which turned out to be
868    /// scrolling is exactly what the framework press exists to prevent.
869    #[test]
870    fn a_touch_tap_flips_on_release_and_a_slide_off_abandons_it() {
871        use crate::button::press_test_support::{finger, touch};
872        use teksilo_core::pointer::PointerPhase;
873
874        let (mut tree, t, pressed, on) = probed_toggle();
875        let bounds = tree.bounds(t);
876        let at = bounds.center();
877        let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 90.0);
878
879        let id = finger();
880        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
881        assert!(pressed.get(), "the contact holds the switch");
882        assert!(!on.get(), "and has flipped nothing");
883        tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
884        assert!(!pressed.get(), "the press slid off its target");
885        tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
886        assert!(!on.get(), "a release off the switch flips nothing");
887
888        let id = finger();
889        tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 100));
890        tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 130));
891        assert!(on.get(), "and a tap that stays on it does");
892        assert!(!pressed.get());
893    }
894
895    /// The mouse path is unchanged: press shows, release flips.
896    #[test]
897    fn a_mouse_click_presses_then_flips_on_release() {
898        let (mut tree, t, pressed, on) = probed_toggle();
899        let at = tree.bounds(t).center();
900        tree.pointer_move(at);
901        tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
902        assert!(pressed.get());
903        assert!(!on.get());
904        tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
905        assert!(!pressed.get());
906        assert!(on.get());
907    }
908}