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
31use std::rc::Rc;
32
33use teksilo_canvas::{Rect, SizeProposal};
34use teksilo_core::accessibility::AccessNodeBuilder;
35use teksilo_core::build_context::BuildContext;
36use teksilo_core::event::{EventResponse, Key, WidgetEvent};
37use teksilo_core::focus::FocusOrigin;
38use teksilo_core::signal::{Prop, Signal};
39use teksilo_core::styles::{SharedToggleStyle, ToggleStyle, ToggleStyleConfig};
40use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
41use teksilo_core::widget_builder::HandlerSet;
42use teksilo_core::widget_id::WidgetId;
43
44// Re-export the variant enum at module top so callers can write
45// `Toggle::new(...).variant(ToggleVariant::Pill)` without a deeper
46// import path. Same pattern as `Button` re-exporting `ButtonVariant`.
47pub use teksilo_core::styles::ToggleVariant;
48use teksilo_i18n::LocalizedString;
49
50/// An animated toggle switch bound to a `Signal<bool>`.
51pub struct Toggle {
52    on: Signal<bool>,
53    label: Option<LocalizedString>,
54    /// Enabled state, static or reactive; forwarded to the arena at build
55    /// time.
56    enabled: Prop<bool>,
57    variant: ToggleVariant,
58    style: Option<SharedToggleStyle>,
59    hovered: Signal<bool>,
60    focused: Signal<bool>,
61    pressed: Signal<bool>,
62    focus_origin: Signal<Option<FocusOrigin>>,
63    body_id: Option<WidgetId>,
64    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
65    /// with the rich / composite slots — every setter clears the other two so
66    /// the last call wins.
67    tooltip_text: Option<LocalizedString>,
68    /// Optional rich tooltip source (registry key or inline content).
69    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
70    /// Optional composite tooltip body (arbitrary widget tree).
71    composite_tooltip_content: Option<Box<dyn Widget>>,
72    /// The accessible name comes from a sibling label wired after mount — see
73    /// [`Toggle::labelled_externally`].
74    labelled_externally: bool,
75}
76
77impl Toggle {
78    /// Create a toggle bound to `on`. The signal is both read (to paint the
79    /// current state) and written (flipped on each activation).
80    pub fn new(on: Signal<bool>) -> Self {
81        Self {
82            on,
83            label: None,
84            enabled: Prop::Static(true),
85            variant: ToggleVariant::default(),
86            style: None,
87            hovered: Signal::new(false),
88            focused: Signal::new(false),
89            pressed: Signal::new(false),
90            focus_origin: Signal::new(None),
91            body_id: None,
92            tooltip_text: None,
93            rich_tooltip_source: None,
94            composite_tooltip_content: None,
95            labelled_externally: false,
96        }
97    }
98
99    /// Accessible label announced by AT and optionally displayed beside the switch.
100    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
101        let ls: LocalizedString = label.into();
102        self.label = Some(ls);
103        self
104    }
105
106    /// Declare that this toggle's accessible name comes from a **sibling label
107    /// widget**, wired by a container after mount (`FormLayout::line` does this
108    /// via `access_labelled_by`).
109    ///
110    /// Without it the debug assertion below fires even though the toggle *is*
111    /// properly labelled: the `labelled_by` relation is pushed post-mount, so
112    /// `accessibility()` cannot see it and every form-hosted toggle looks
113    /// nameless. Setting `.label(..)` instead would satisfy the assert but
114    /// render the text a second time, beside a label column that already has it.
115    pub fn labelled_externally(mut self) -> Self {
116        self.labelled_externally = true;
117        self
118    }
119
120    /// Set the enabled state, statically or reactively. Forwarded to the
121    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at
122    /// build time.
123    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
124        self.enabled = enabled.into();
125        self
126    }
127
128    /// Pick a Tier-1 design-language variant
129    /// ([`ToggleVariant::Switch`] / `Pill` / `Square` / `Inset`). The
130    /// active [`ToggleStyle`] decides what to do with the hint —
131    /// IntUI's default impl honours all four; a custom impl might
132    /// ignore the variant entirely.
133    pub fn variant(mut self, variant: ToggleVariant) -> Self {
134        self.variant = variant;
135        self
136    }
137
138    /// Override the active [`ToggleStyle`] for this widget instance
139    /// only. Useful for one-off custom-painted toggles in a single
140    /// view.
141    pub fn style(mut self, style: impl ToggleStyle) -> Self {
142        self.style = Some(Rc::new(style));
143        self
144    }
145
146    /// Attach a plain single-line tooltip shown after a hover delay.
147    ///
148    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
149    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
150    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter
151    /// called wins and clears the others.
152    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
153        self.tooltip_text = Some(text.into());
154        self.rich_tooltip_source = None;
155        self.composite_tooltip_content = None;
156        self
157    }
158
159    /// Attach a rich tooltip looked up by registry `key`.
160    ///
161    /// Mutually exclusive with the other tooltip setters — the last
162    /// setter called wins and clears the others.
163    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
164        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
165        self.tooltip_text = None;
166        self.composite_tooltip_content = None;
167        self
168    }
169
170    /// Attach a rich tooltip from an inline [`crate::tooltip::TooltipContent`]
171    /// value rather than a registry key.
172    ///
173    /// Mutually exclusive with the other tooltip setters — the last
174    /// setter called wins and clears the others.
175    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
176        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
177        self.tooltip_text = None;
178        self.composite_tooltip_content = None;
179        self
180    }
181
182    /// Attach a composite tooltip whose body is an arbitrary widget tree.
183    ///
184    /// Mutually exclusive with the other tooltip setters — the last
185    /// setter called wins and clears the others.
186    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
187        self.composite_tooltip_content = Some(Box::new(content));
188        self.tooltip_text = None;
189        self.rich_tooltip_source = None;
190        self
191    }
192}
193
194impl std::fmt::Debug for Toggle {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        f.debug_struct("Toggle")
197            .field("enabled", &self.enabled.get())
198            .field("variant", &self.variant)
199            .finish()
200    }
201}
202
203impl Widget for Toggle {
204    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
205        let self_id = ctx.self_id();
206        // Forward the enabled state into the arena; see IconButton.
207        ctx.enabled_when(self_id, self.enabled.clone());
208        let effective_enabled = ctx.effective_enabled_signal(self_id);
209        // Resolve the active style: per-call override > theme slot >
210        // built-in `RecipeToggleStyle` default.
211        let style: SharedToggleStyle = self
212            .style
213            .clone()
214            .or_else(|| ctx.theme().style_slots.toggle.clone())
215            .unwrap_or_else(|| Rc::new(crate::styles::RecipeToggleStyle::default()));
216
217        // Build the visual body via the active style. The body is a
218        // child subtree we'll lay out to the bounds we get.
219        let cfg = ToggleStyleConfig {
220            is_on: self.on.clone(),
221            is_hovered: self.hovered.clone(),
222            is_pressed: self.pressed.clone(),
223            is_focused: self.focused.clone(),
224            // `:focus-visible` — input modality, so the recipe shows the
225            // focus ring only during keyboard navigation, not on a click.
226            is_focus_visible: ctx.focus_visible(),
227            // is_disabled tracks the arena's effective enabled-state
228            // reactively (see `BuildContext::effective_enabled_signal`).
229            is_disabled: effective_enabled.map(|on| !*on),
230            variant: self.variant,
231        };
232        let body_id = style.make_body(&cfg, ctx);
233
234        // Wrap body + optional label in an HStack so the label paints
235        // alongside the body without this widget needing a `paint()`
236        // method. label_gap is small (6 dp default in IntUI); a fixed
237        // `HStack::spacing` is plenty without a per-theme token here.
238        let root = if let Some(ref label) = self.label {
239            use crate::primitives::{HStack, TextWidget};
240            use teksilo_tokens::TextStyleRole;
241            let label_widget = TextWidget::new(label.clone()).style(TextStyleRole::Body);
242            let label_id = ctx.add(label_widget);
243            ctx.add(
244                HStack::new()
245                    .spacing(6.0)
246                    .add_child(body_id)
247                    .add_child(label_id),
248            )
249        } else {
250            body_id
251        };
252        self.body_id = Some(root);
253
254        // Attach tooltip (at most one tier fires; each setter cleared the others).
255        if let Some(content) = self.composite_tooltip_content.take() {
256            let delay = ctx.theme().motion.tooltip_delay_heavy;
257            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
258        } else if let Some(source) = self.rich_tooltip_source.clone() {
259            let delay = ctx.theme().motion.tooltip_delay;
260            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
261        } else if let Some(text) = self.tooltip_text.clone() {
262            let delay = ctx.theme().motion.tooltip_delay;
263            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
264        }
265
266        // Wire up the toggle's interactive behaviour. The body owns
267        // paint; the wrapper owns input handling.
268        let on = self.on.clone();
269        let hovered = self.hovered.clone();
270        let focused = self.focused.clone();
271        let pressed = self.pressed.clone();
272        let focus_origin = self.focus_origin.clone();
273
274        let toggle = {
275            let on = on.clone();
276            move || {
277                on.set(!on.get());
278            }
279        };
280
281        // Framework gates events on `arena.is_enabled(self_id)`; the
282        // focus walker skips disabled subtrees. No need to AND with
283        // a per-handler `enabled` snapshot anymore.
284        let mut handlers = HandlerSet::new()
285            .focusable(true)
286            .cursor(CursorIcon::Pointer);
287
288        {
289            let toggle = toggle.clone();
290            handlers = handlers.on_tap(move |_pos, _ctx| {
291                toggle();
292            });
293        }
294        {
295            let hovered = hovered.clone();
296            handlers = handlers.on_hover(move |entered, _ctx| {
297                hovered.set(entered);
298            });
299        }
300        {
301            // Pointer-pressed signal (PointerDown→true, Up/Leave→false).
302            // IntUI ignores it; design languages with press feedback
303            // (the Material 3 switch's thumb-grow) read `is_pressed`.
304            // Returns `Ignored` so the tap gesture still recognises.
305            let pressed = pressed.clone();
306            handlers = handlers.on_pointer_event(move |event, _ctx| {
307                use teksilo_core::event::{PointerButton, WidgetEvent};
308                match event {
309                    WidgetEvent::PointerDown {
310                        button: PointerButton::Primary,
311                        ..
312                    } => pressed.set(true),
313                    WidgetEvent::PointerUp { .. } | WidgetEvent::PointerLeave => pressed.set(false),
314                    _ => {}
315                }
316                teksilo_core::event::EventResponse::Ignored
317            });
318        }
319        {
320            let toggle = toggle.clone();
321            // Lone-KeyUp guard: track whether we saw the matching KeyDown so
322            // a stray KeyUp (e.g. a shortcut consumed the KeyDown and focus
323            // returned here) does NOT toggle.
324            let key_pressed = std::cell::Cell::new(false);
325            handlers = handlers.on_key(move |event, _ctx| match event {
326                WidgetEvent::KeyDown {
327                    key: Key::Space, ..
328                } => {
329                    key_pressed.set(true);
330                    EventResponse::Handled
331                }
332                WidgetEvent::KeyUp {
333                    key: Key::Space, ..
334                } => {
335                    if !key_pressed.replace(false) {
336                        return EventResponse::Ignored;
337                    }
338                    toggle();
339                    EventResponse::Handled
340                }
341                _ => EventResponse::Ignored,
342            });
343        }
344        {
345            let focused = focused.clone();
346            let focus_origin = focus_origin.clone();
347            let hovered_for_focus = hovered.clone();
348            handlers = handlers.on_focus(move |gained, _ctx| {
349                focused.set(gained);
350                if gained {
351                    focus_origin.set(Some(if hovered_for_focus.get() {
352                        FocusOrigin::Pointer
353                    } else {
354                        FocusOrigin::Keyboard
355                    }));
356                } else {
357                    focus_origin.set(None);
358                }
359            });
360        }
361        {
362            let toggle = toggle.clone();
363            handlers = handlers.on_access_action(move |action, _ctx| {
364                if action == teksilo_core::accesskit::Action::Click {
365                    toggle();
366                    EventResponse::Handled
367                } else {
368                    EventResponse::Ignored
369                }
370            });
371        }
372
373        ctx.apply_self_handlers(handlers);
374
375        // Return `root` (the HStack wrapper when a label is set,
376        // else the bare body). Returning `body_id` here would leave
377        // the HStack as a parent-less arena root: its child list
378        // would still list `body_id`, and the AccessKit walker would
379        // see `body_id` claimed by both Toggle and the orphan HStack
380        // — a "duplicate accessibility child" log on every refresh.
381        vec![root]
382    }
383
384    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
385        self.body_id
386            .and_then(|id| ctx.child_size(id, proposal))
387            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
388            .into()
389    }
390
391    fn place_children(
392        &self,
393        bounds: Rect,
394        _proposal: SizeProposal,
395        children: &mut [WidgetPlacement],
396        _ctx: &LayoutContext,
397    ) {
398        if let Some(child) = children.first_mut() {
399            child.origin = bounds.origin();
400            child.size = bounds.size();
401        }
402    }
403
404    fn children(&self) -> Vec<WidgetId> {
405        self.body_id.into_iter().collect()
406    }
407
408    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
409        debug_assert!(
410            self.label.is_some() || self.labelled_externally,
411            "Toggle is missing an accessible label — \
412             screen readers will announce \"switch\" with no context. \
413             Call .label(...) when constructing the widget."
414        );
415        builder.set_role(teksilo_core::accesskit::Role::Switch);
416        if let Some(ref label) = self.label {
417            builder.set_name(label.resolve_now());
418        }
419        builder.set_toggled(self.on.get());
420        // Framework a11y walker sets `set_disabled` from arena state.
421        builder.add_action(teksilo_core::accesskit::Action::Click);
422        builder.add_action(teksilo_core::accesskit::Action::Focus);
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use std::time::Duration;
429
430    use super::*;
431
432    use teksilo_core::event::Modifiers;
433    use teksilo_core::widget_tree::WidgetTree;
434    use teksilo_i18n::lit;
435
436    #[test]
437    fn focus_ring_only_under_focus_visible() {
438        // `:focus-visible`: the keyboard-only focus ring. Programmatic focus
439        // leaves `focus_visible` false → no ring; a key press reveals it.
440        let theme = teksilo_core::presets::intui::light();
441        let ring = theme.colors.focus_ring.to_array();
442        let mut tree = WidgetTree::new().with_theme(theme);
443        let t = tree.add(Toggle::new(Signal::new(false)));
444        tree.layout(SizeProposal::exact(120.0, 60.0));
445
446        tree.focus(t);
447        assert!(
448            !frame_has_ring(&tree.render(), ring),
449            "no focus ring while focus-visible is false (pointer modality)",
450        );
451
452        tree.press_key(Key::ArrowDown, Modifiers::NONE);
453        assert!(
454            frame_has_ring(&tree.render(), ring),
455            "focus ring shows under keyboard modality",
456        );
457    }
458
459    #[test]
460    fn is_pressed_tracks_pointer_down_and_up() {
461        use std::cell::RefCell;
462        use std::rc::Rc;
463        use teksilo_core::event::PointerButton;
464        use teksilo_core::styles::{ToggleStyle, ToggleStyleConfig};
465
466        // A style that captures the cfg's is_pressed signal so the test can
467        // observe it (IntUI ignores is_pressed, so it isn't visible in paint).
468        struct CaptureStyle(Rc<RefCell<Option<Signal<bool>>>>);
469        impl ToggleStyle for CaptureStyle {
470            fn make_body(&self, cfg: &ToggleStyleConfig, ctx: &mut BuildContext) -> WidgetId {
471                *self.0.borrow_mut() = Some(cfg.is_pressed.clone());
472                ctx.add(crate::primitives::RectWidget::new())
473            }
474        }
475
476        let captured: Rc<RefCell<Option<Signal<bool>>>> = Rc::new(RefCell::new(None));
477        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
478        let t = tree.add(Toggle::new(Signal::new(false)).style(CaptureStyle(captured.clone())));
479        tree.layout(SizeProposal::exact(120.0, 60.0));
480
481        let pressed = captured.borrow().clone().expect("is_pressed captured");
482        assert!(!pressed.get(), "not pressed initially");
483
484        let b = tree.bounds(t);
485        let center = teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
486        tree.pointer_down_button(center, PointerButton::Primary);
487        assert!(pressed.get(), "pressed after PointerDown");
488        tree.pointer_up_button(center, PointerButton::Primary);
489        assert!(!pressed.get(), "released after PointerUp");
490    }
491
492    /// Whether the focus-ring *stroke* (ring color + non-zero stroke width) is
493    /// present. A plain color match is ambiguous: in IntUI `focus_ring` shares
494    /// the `accent` RGBA, and the toggle paints accent *fills* — the ring is
495    /// the only *stroked* shape in that color.
496    fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
497        frame
498            .shapes
499            .iter()
500            .any(|s| s.color == color && s.stroke_width > 0.0)
501            || frame.cosmetic_lines.iter().any(|l| l.color == color)
502    }
503
504    #[test]
505    fn click_toggles_state() {
506        let on = Signal::new(false);
507        let mut tree = WidgetTree::new();
508        let t = tree.add(Toggle::new(on.clone()));
509        tree.layout(SizeProposal::exact(100.0, 60.0));
510
511        tree.click(t);
512        assert!(on.get());
513        tree.click(t);
514        assert!(!on.get());
515    }
516
517    #[test]
518    fn space_toggles_state() {
519        let on = Signal::new(false);
520        let mut tree = WidgetTree::new();
521        let t = tree.add(Toggle::new(on.clone()));
522        tree.layout(SizeProposal::exact(100.0, 60.0));
523
524        tree.focus(t);
525        tree.press_key(Key::Space, Modifiers::NONE);
526        assert!(on.get());
527    }
528
529    #[test]
530    fn lone_keyup_does_not_toggle() {
531        // Lone-KeyUp guard: a KeyUp with no matching KeyDown must NOT toggle.
532        let on = Signal::new(false);
533        let mut tree = WidgetTree::new();
534        let t = tree.add(Toggle::new(on.clone()));
535        tree.layout(SizeProposal::exact(100.0, 60.0));
536
537        tree.focus(t);
538        tree.dispatch_event(WidgetEvent::KeyUp {
539            key: Key::Space,
540            modifiers: Modifiers::NONE,
541        });
542        assert!(!on.get(), "a lone KeyUp must not toggle the switch");
543
544        tree.press_key(Key::Space, Modifiers::NONE);
545        assert!(on.get());
546    }
547
548    #[test]
549    fn animation_runs_after_toggle() {
550        let on = Signal::new(false);
551        let mut tree = WidgetTree::new();
552        let t = tree.add(Toggle::new(on.clone()));
553        tree.layout(SizeProposal::exact(100.0, 60.0));
554
555        tree.click(t); // toggles on, body's effect tweens knob
556        assert!(on.get());
557
558        // Mid-flight: animation should still be running.
559        tree.tick_animations(Duration::from_millis(75));
560        assert!(tree.has_active_animations());
561
562        // After the full duration, animation completes.
563        tree.tick_animations(Duration::from_millis(200));
564        assert!(!tree.has_active_animations());
565    }
566
567    #[test]
568    fn accessibility() {
569        let on = Signal::new(true);
570        let mut tree = WidgetTree::new();
571        let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
572        tree.layout(SizeProposal::exact(100.0, 60.0));
573        let info = tree.accessibility_node(t);
574        assert_eq!(info.role(), teksilo_core::accesskit::Role::Switch);
575        assert!(info.is_toggled());
576    }
577
578    /// Regression: a labeled Toggle wraps its body + label in an
579    /// HStack inside `build`. The earlier code returned the inner
580    /// body id instead of the HStack id, which left the HStack
581    /// parent-less in the arena. The AccessKit walker then saw the
582    /// body id claimed by both Toggle and the orphan HStack —
583    /// "Teksilo bug: duplicate accessibility child …" on every AT
584    /// refresh.
585    #[test]
586    fn labeled_toggle_does_not_orphan_hstack_wrapper() {
587        let on = Signal::new(false);
588        let mut tree = WidgetTree::new();
589        let _t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
590        tree.layout(SizeProposal::exact(200.0, 60.0));
591        let update = tree.sync_accessibility();
592        let mut seen = std::collections::HashMap::new();
593        for (parent_id, node) in &update.nodes {
594            for &child_id in node.children() {
595                let prev = seen.insert(child_id, *parent_id);
596                assert!(
597                    prev.is_none(),
598                    "duplicate AT child {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
599                );
600            }
601        }
602    }
603
604    #[test]
605    fn accessibility_has_actions() {
606        let on = Signal::new(false);
607        let mut tree = WidgetTree::new();
608        let t = tree.add(Toggle::new(on).label(lit!("Dark mode")));
609        tree.layout(SizeProposal::exact(100.0, 60.0));
610        let info = tree.accessibility_node(t);
611        assert!(
612            info.actions()
613                .contains(&teksilo_core::accesskit::Action::Click)
614        );
615    }
616
617    /// The **rich** tier, on a Toggle, opens the same way the plain one does.
618    ///
619    /// Its own case beside `tooltip_appears_on_hover` because the two tiers take
620    /// different attach paths out of `build` — `attach_rich_tooltip_source`
621    /// against `ctx.attach_tooltip` — and only the plain one was pinned. A
622    /// settings page that moved its explanations onto its switches is the first
623    /// caller to depend on the rich one here.
624    #[test]
625    fn a_rich_tooltip_appears_on_hover_too() {
626        let mut tree = WidgetTree::new();
627        let id = tree.add(
628            Toggle::new(Signal::new(false))
629                .label(lit!("Wi-Fi"))
630                .rich_tooltip_content(crate::tooltip::TooltipContent::new(
631                    "toggle.rich",
632                    lit!("What this switch does"),
633                )),
634        );
635        tree.layout(SizeProposal::exact(300.0, 200.0));
636        tree.pointer_move(tree.bounds(id).center());
637        tree.advance_time(std::time::Duration::from_secs(1));
638        assert_eq!(
639            tree.active_overlays().len(),
640            1,
641            "a rich tooltip on a Toggle must open under the pointer"
642        );
643    }
644
645    #[test]
646    fn tooltip_appears_on_hover() {
647        let mut tree = WidgetTree::new();
648        let id = tree.add(
649            Toggle::new(Signal::new(false))
650                .label(lit!("Wi-Fi"))
651                .tooltip(lit!("Tip")),
652        );
653        tree.layout(SizeProposal::exact(300.0, 200.0));
654        tree.pointer_move(tree.bounds(id).center());
655        tree.advance_time(std::time::Duration::from_secs(1));
656        assert_eq!(
657            tree.active_overlays().len(),
658            1,
659            "tooltip should appear on hover"
660        );
661        assert!(tree.find_by_label("Tip").is_some());
662    }
663}