Skip to main content

teksilo_widgets/tooltip/
rich.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `RichTooltipWidget` — rich tooltip content surface.
5//!
6//! Renders a [`TooltipContent`] entry (body + optional shortcut hint +
7//! optional "more" disclosure) inside the existing tooltip rounded-rect
8//! chrome. Body and long-form text go through `TextWidget` with inline
9//! markup enabled, so `[label](url)` / `*italic*` / `**bold**` render
10//! correctly and inline links participate in the nested-tooltip
11//! cascade.
12//!
13//! This widget is the **content** of a tooltip overlay — the anchor /
14//! hover trigger / overlay lifetime live in the surrounding attach
15//! API. A caller (the owning widget's build) wraps a `RichTooltipWidget`
16//! into an `OverlayRequest` or attaches it via the simple tooltip
17//! attach API once that integration lands.
18//!
19//! Sticky-on-dwell:
20//! - At t=0 the tooltip is shown by the normal hover path.
21//! - The widget tracks visible-paint time via `paint()` interior
22//!   mutability and, every 500 ms, advances a `Signal<u32>` step
23//!   counter from 0 to 4.
24//! - The top-right `DwellIndicator` reads the step signal and
25//!   paints an empty circle filling progressively in 4 wedges.
26//! - At step 4 the indicator flips to a pin icon and the widget's
27//!   `sticky` signal goes true. The widget tree (via
28//!   `attach_tooltip_with_sticky`) auto-promotes the overlay on
29//!   the same 2 s timer: removes the entry from the hover tracker
30//!   and swaps the dismiss behavior to `EscapeOrClickOutside`. The
31//!   widget's a11y role flips from `Tooltip` to `Dialog` and a
32//!   `Focus` action is advertised on the node. Promotion does **not**
33//!   move keyboard focus into the panel — the user Tabs in. This is
34//!   the correct pattern for a non-modal sticky panel (it never steals
35//!   focus from whatever the user was doing).
36//! - Cascade children (tooltips opened from a `[label](:key)` link via
37//!   `RichTooltipWidget::cascade_child`) **omit the indicator
38//!   entirely**: they're shown by an explicit click and are already
39//!   persistent, so there's no hover dwell-to-sticky path to visualize.
40//!   They also skip straight to the persistent a11y treatment — a
41//!   non-modal `Dialog` advertising `Focus`, same as a dwell-promoted
42//!   tooltip — rather than reading as an ephemeral `Tooltip`.
43
44use std::cell::Cell;
45use std::collections::HashMap;
46use std::rc::Rc;
47use std::time::{Duration, Instant};
48use teksilo_i18n::lit;
49
50use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
51use teksilo_core::accessibility::AccessNodeBuilder;
52use teksilo_core::build_context::BuildContext;
53use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
54use teksilo_core::signal::Signal;
55use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
56use teksilo_core::widget_builder::HandlerSet;
57use teksilo_core::widget_id::WidgetId;
58use teksilo_tokens::{CornerRadius, TextRole, TextStyleRole};
59
60use crate::accordion::Accordion;
61use crate::keystroke_format::format_keystroke;
62use crate::primitives::{Grid, Padding, Spacer, TextWidget, TrackSize, VStack};
63use crate::tooltip::dwell_indicator::DwellIndicator;
64use crate::tooltip::registry::{TooltipContent, TooltipRegistry, with_tooltip_registry};
65
66/// Total dwell time before the tooltip promotes to sticky.
67///
68/// **The single source of truth for the dwell clock.** `composite.rs`, the
69/// tree's dwell wake-up scheduler (`WidgetTree::next_timer_deadline`) and the
70/// `DwellIndicator`'s wedge geometry all derive from these three constants
71/// rather than restating them; they used to be four unlinked copies, so a
72/// change here silently desynced the visible indicator from the promotion it
73/// was supposed to be counting down.
74pub(crate) const DWELL_PROMOTION: Duration = Duration::from_secs(2);
75/// Maximum step value (4 = full circle = pin icon).
76///
77/// Pinned to the tree's `TOOLTIP_DWELL_STEPS`, which is what decides how often
78/// the event loop wakes during a dwell: if the indicator drew more steps than
79/// the tree scheduled wake-ups for, the wedge would skip; fewer, and it would
80/// repaint identically on wake-ups that changed nothing.
81pub(crate) const DWELL_STEPS: u32 = teksilo_core::widget_tree::TOOLTIP_DWELL_STEPS;
82/// Per-step dwell duration: total / steps = 500 ms.
83pub(crate) const DWELL_STEP_DURATION: Duration =
84    Duration::from_millis((DWELL_PROMOTION.as_millis() / DWELL_STEPS as u128) as u64);
85
86// The step arithmetic above truncates. Unless the promotion window divides
87// exactly into its steps, the indicator's last wedge and the actual promotion
88// drift apart — four 490 ms steps would fill the circle 40 ms early and sit
89// full while nothing happened.
90const _: () = assert!(
91    DWELL_PROMOTION
92        .as_millis()
93        .is_multiple_of(DWELL_STEPS as u128),
94    "DWELL_PROMOTION must divide exactly into DWELL_STEPS"
95);
96const _: () = assert!(
97    DWELL_STEP_DURATION.as_millis() * DWELL_STEPS as u128 == DWELL_PROMOTION.as_millis(),
98    "dwell steps must sum to exactly DWELL_PROMOTION"
99);
100
101/// Rich tooltip content widget.
102///
103/// Internally composes a rounded rect surface with a VStack of
104/// `TextWidget`s for body text / shortcut / optional "more" accordion
105/// disclosure.
106pub struct RichTooltipWidget {
107    content: Option<TooltipContent>,
108    /// Pending key to resolve against the registry at build time.
109    /// Used when constructed via `from_key` — we defer resolution so
110    /// that the registry install order doesn't matter.
111    pending_key: Option<String>,
112    root_child_id: Option<WidgetId>,
113    // ── Dwell state machine ──
114    /// Dwell step in 0..=4. 0 = empty circle, 4 = pin icon.
115    /// Updated from `paint()` based on elapsed visible time.
116    dwell_step: Signal<u32>,
117    /// True after the dwell timer has reached `DWELL_PROMOTION`.
118    /// Drives the indicator pin variant and the a11y role flip.
119    sticky: Signal<bool>,
120    /// Shared with the widget tree's tooltip entry. The tree writes
121    /// `Some(now)` when the tooltip is shown and `None` when it is
122    /// dismissed. The widget reads it from `paint()` to compute the
123    /// authoritative elapsed dwell time — no paint-gap heuristic.
124    shown_at_sink: Rc<Cell<Option<Instant>>>,
125    /// True when this tooltip was opened as a *child* of another tooltip
126    /// via a `[label](:key)` cascade link. Cascade children are shown by
127    /// an explicit click and are already persistent
128    /// (`EscapeOrClickOutside`), so the hover dwell-to-sticky affordance
129    /// — and its [`DwellIndicator`] — don't apply: the indicator is
130    /// suppressed in `build()`.
131    is_cascade_child: bool,
132    /// The keys of every tooltip strictly above this one on the current
133    /// cascade path (root → … → this tooltip's parent). Threaded down so
134    /// `build()` can refuse to pre-create a nested child whose key is
135    /// already an ancestor — that would close a `[label](:key)` cycle
136    /// (e.g. `book → chapter → end-of-book → book`) and, because
137    /// pre-creation is eager and recursive, overflow the stack. Empty on
138    /// the root tooltip; each cascade child receives its parent's path
139    /// plus the parent's own key. See [`MAX_CASCADE_DEPTH`].
140    cascade_ancestors: Vec<String>,
141}
142
143/// Hard cap on cascade nesting depth. The `cascade_ancestors` visited-set
144/// already guarantees termination (no key repeats on a path, so depth is
145/// bounded by the registry size), but a large, densely cross-linked
146/// registry could still enumerate very long simple paths. This caps the
147/// eager pre-creation regardless of graph shape; a link deeper than this
148/// still renders as text but opens nothing. Realistic drill-down is one or
149/// two levels, so the ceiling is generous.
150const MAX_CASCADE_DEPTH: usize = 8;
151
152impl std::fmt::Debug for RichTooltipWidget {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("RichTooltipWidget")
155            .field("has_content", &self.content.is_some())
156            .field("pending_key", &self.pending_key)
157            .finish()
158    }
159}
160
161impl RichTooltipWidget {
162    /// Construct a rich tooltip that renders an explicit
163    /// [`TooltipContent`] entry. Use this for one-off tooltips that
164    /// aren't registered in the central registry.
165    pub fn new(content: TooltipContent) -> Self {
166        Self {
167            content: Some(content),
168            pending_key: None,
169            root_child_id: None,
170            dwell_step: Signal::new(0),
171            sticky: Signal::new(false),
172            shown_at_sink: Rc::new(Cell::new(None)),
173            is_cascade_child: false,
174            cascade_ancestors: Vec::new(),
175        }
176    }
177
178    /// Construct a rich tooltip that resolves its content from the
179    /// thread-local [`TooltipRegistry`] at build time using the given
180    /// key. This is the common path — applications register their
181    /// full tooltip catalog once at boot, then refer to entries by
182    /// key from hover-trigger sites.
183    pub fn from_key(key: impl Into<String>) -> Self {
184        Self {
185            content: None,
186            pending_key: Some(key.into()),
187            root_child_id: None,
188            dwell_step: Signal::new(0),
189            sticky: Signal::new(false),
190            shown_at_sink: Rc::new(Cell::new(None)),
191            is_cascade_child: false,
192            cascade_ancestors: Vec::new(),
193        }
194    }
195
196    /// Mark this tooltip as a cascade child — one opened from another
197    /// tooltip's `[label](:key)` link rather than by hover. Suppresses
198    /// the dwell-to-sticky [`DwellIndicator`], which is meaningless on a
199    /// tooltip that's already persistent. Internal to the cascade
200    /// mechanism (`RichTooltipWidget::build` pre-creates these).
201    pub(crate) fn cascade_child(mut self) -> Self {
202        self.is_cascade_child = true;
203        self
204    }
205
206    /// Record the cascade path (keys of every ancestor tooltip) this
207    /// tooltip hangs from, so `build()` can break `[label](:key)` cycles
208    /// and honour [`MAX_CASCADE_DEPTH`]. Internal to the cascade
209    /// mechanism — set by `build()` when it pre-creates a nested child.
210    pub(crate) fn with_cascade_ancestors(mut self, ancestors: Vec<String>) -> Self {
211        self.cascade_ancestors = ancestors;
212        self
213    }
214
215    /// Clone of the tooltip's `shown_at` sink — used by the attach
216    /// helper to thread the same `Rc<Cell<..>>` through to the
217    /// widget tree's `attach_tooltip_with_sticky_sink`.
218    pub fn shown_at_sink(&self) -> Rc<Cell<Option<Instant>>> {
219        self.shown_at_sink.clone()
220    }
221
222    /// Recompute dwell step + sticky from the authoritative
223    /// `shown_at_sink` value. Called from `paint()` so the indicator
224    /// progresses on every frame the tooltip is visible.
225    fn tick_dwell(&self) {
226        let Some(shown_at) = self.shown_at_sink.get() else {
227            // Tooltip is not currently shown according to the tree.
228            // Reset the visible state so a future show starts at 0.
229            if self.dwell_step.get() != 0 {
230                self.dwell_step.set(0);
231            }
232            if self.sticky.get() {
233                self.sticky.set(false);
234            }
235            return;
236        };
237
238        let elapsed = Instant::now().saturating_duration_since(shown_at);
239        let new_step =
240            ((elapsed.as_millis() / DWELL_STEP_DURATION.as_millis()) as u32).min(DWELL_STEPS);
241        if self.dwell_step.get() != new_step {
242            self.dwell_step.set(new_step);
243        }
244        let now_sticky = new_step >= DWELL_STEPS;
245        if self.sticky.get() != now_sticky {
246            self.sticky.set(now_sticky);
247        }
248    }
249}
250
251impl Widget for RichTooltipWidget {
252    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
253        // Resolve the content: either the inline entry or a registry
254        // lookup via the pending key.
255        if self.content.is_none()
256            && let Some(key) = self.pending_key.as_deref()
257        {
258            self.content = with_tooltip_registry(|reg| reg.get(key).cloned()).flatten();
259        }
260
261        let Some(content) = self.content.clone() else {
262            // No content resolved (unknown key or missing registry) —
263            // fall back to an empty Spacer so layout doesn't crash.
264            let id = ctx.add(Spacer::new());
265            self.root_child_id = Some(id);
266            return vec![id];
267        };
268
269        // Snapshot the theme once for static text styles; reactive colors
270        // are piped into leaf widgets via `theme_signal.map(...)` below.
271        // Rich tooltips are short-lived overlays, so a theme switch that
272        // happens while one is already visible refreshes the next time
273        // it's re-shown (or immediately for the signal-bound colors).
274        let theme_signal = ctx.theme_signal();
275        let theme = theme_signal.get();
276        use crate::styles::recipe_tooltip_style as tt;
277        let self_id = ctx.self_id();
278
279        // Pre-create dormant RichTooltipWidgets for every :key URL
280        // referenced by the body text or the "more" body. The
281        // resulting `HashMap<key → WidgetId>` is shared with the link
282        // handler closures so a link click / hover can open the
283        // matching child overlay via `show_overlay`. Pre-creating
284        // (rather than creating at event time) matches the established
285        // menu-submenu pattern in menu_item.rs.
286        let mut nested_ids: HashMap<String, WidgetId> = HashMap::new();
287        let body_source = content.text.resolve_now();
288        let more_source = content.more.as_ref().map(|m| m.resolve_now());
289        let mut nested_keys: Vec<String> = Vec::new();
290        scan_tooltip_key_urls(&body_source, &mut nested_keys);
291        if let Some(ref m) = more_source {
292            scan_tooltip_key_urls(m, &mut nested_keys);
293        }
294        nested_keys.sort();
295        nested_keys.dedup();
296        // The cascade path handed to any child: this tooltip's ancestors
297        // plus its own key. Guards the eager, recursive pre-creation below
298        // against `[label](:key)` cycles (e.g. `book → chapter →
299        // end-of-book → book`), which would otherwise recurse forever and
300        // overflow the stack.
301        let mut child_ancestors = self.cascade_ancestors.clone();
302        child_ancestors.push(content.key.clone());
303        let at_depth_limit = child_ancestors.len() >= MAX_CASCADE_DEPTH;
304        // Pre-create only for keys that are registered, not already on the
305        // cascade path (cycle break — a link back to self or an ancestor is
306        // dropped), and within the depth budget.
307        let registered: Vec<String> = if at_depth_limit {
308            Vec::new()
309        } else {
310            nested_keys
311                .into_iter()
312                .filter(|k| !child_ancestors.contains(k))
313                .filter(|k| with_tooltip_registry(|r| r.get(k).is_some()).unwrap_or(false))
314                .collect()
315        };
316        for key in &registered {
317            let nested = RichTooltipWidget::from_key(key.clone())
318                .cascade_child()
319                .with_cascade_ancestors(child_ancestors.clone());
320            // Detached, not a child. Adding these under `children()` would
321            // propagate `ctx.activate(nested)` down to their own sub-nested
322            // tooltips when the user clicks a link to open one. Those have no
323            // overlay registration yet, so they would miss `overlay_skip` and
324            // the paint walk would render them as ordinary children at zero
325            // size, spilling their TextWidgets' glyphs at the parent origin
326            // (the "ghost text" cascade bug).
327            //
328            // `add_detached` keeps them out of that walk while still recording
329            // who owns them, so the arena reaps them with this tooltip. They
330            // used to be added with a bare `ctx.add`, which owns nothing: the
331            // note here read "nested tooltip widgets leak memory across
332            // rebuilds of the host; this is acceptable because
333            // RichTooltipWidget is built once per overlay show". It is not
334            // built once per show — the content widget is `ctx.add`ed and
335            // built on every build of its *anchor*, and each build pre-creates
336            // this whole cascade recursively. A writer clicking around an
337            // outline stranded thousands of ~16-widget subtrees a minute.
338            let nested_id = ctx.add_detached(nested);
339            // Dormant immediately: a parentless node is laid out and painted
340            // as a root otherwise, instead of waiting for a hover. Activation
341            // happens in `make_link_click_handler` when the user clicks a
342            // `:key` link.
343            ctx.set_dormant(nested_id);
344            nested_ids.insert(key.clone(), nested_id);
345        }
346        let nested_map = Rc::new(nested_ids);
347
348        // Resolve the shortcut label: the manual override wins;
349        // otherwise, if the tooltip was bound to a shortcut id via
350        // `.for_shortcut(id)`, the effective primary keystroke is
351        // pulled from the tree's `ShortcutRegistry`. The registry's
352        // `version` signal is bound to the tooltip at `Relayout`
353        // level so user rebinds and late registrations refresh the
354        // chip on the next pass.
355        let shortcut_text: Option<String> = content.shortcut_label.clone().or_else(|| {
356            content.shortcut_id.and_then(|id| {
357                ctx.effective_shortcut(id)
358                    .and_then(|eff| eff.primary.map(format_keystroke))
359            })
360        });
361        if content.shortcut_id.is_some() {
362            // Rebuild (not Relayout): the chip's text is read from
363            // the registry by value during build(), so a rebind only
364            // updates when build() re-enters.
365            ctx.shortcut_version().bind_to(
366                ctx.self_id(),
367                ctx.binding_registry(),
368                teksilo_core::binding::BindingLevel::Rebuild,
369            );
370        }
371
372        // Body row: text + optional shortcut chip.
373        // a11y_hidden: the tooltip root owns `set_name(body_text)`, so the
374        // body TextWidget would duplicate it as a child Label node.
375        // Bind the body to the `LocalizedString` itself (not a resolved
376        // snapshot) so a `tr!(...)` source re-renders on locale change
377        // without rebuilding the tooltip. `body_source` above is only the
378        // build-time snapshot used to pre-scan `:key` cascade links.
379        let body_widget = TextWidget::new(content.text.clone())
380            .style(TextStyleRole::Small)
381            .color(TextRole::TooltipText)
382            .markup(true)
383            .on_link_click(make_link_click_handler(nested_map.clone(), self_id))
384            .a11y_hidden();
385        let body_id = ctx.add(body_widget);
386
387        let header: WidgetId = if let Some(shortcut) = shortcut_text {
388            let shortcut_widget = TextWidget::new(lit!(shortcut))
389                .style(TextStyleRole::Small)
390                .color(TextRole::TooltipShortcut)
391                .single_line()
392                .a11y_hidden();
393            let shortcut_id = ctx.add(shortcut_widget);
394            // Grid is used here (not HStack + Spacer) because the body
395            // text needs a width proposal that excludes the shortcut
396            // column so it wraps correctly. HStack would propose the
397            // body's natural single-line width and the shortcut chip
398            // would overflow off the right edge of the tooltip.
399            ctx.add(
400                Grid::new()
401                    .columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
402                    .rows(vec![TrackSize::Auto])
403                    .column_gap(8.0)
404                    .add_child(body_id)
405                    .add_child(shortcut_id),
406            )
407        } else {
408            body_id
409        };
410
411        // Optional "more" disclosure accordion, independent of the dwell
412        // indicator. `None` when the entry has no long-form body.
413        let more_accordion: Option<WidgetId> = if let Some(more_ls) = content.more.clone() {
414            // Bind the long-form body reactively too (the `more_source`
415            // snapshot is only for the build-time `:key` cascade scan).
416            let more_widget = TextWidget::new(more_ls)
417                .style(TextStyleRole::Small)
418                .color(TextRole::TooltipText)
419                .markup(true)
420                .on_link_click(make_link_click_handler(nested_map.clone(), self_id));
421            let expanded = ctx.signal(false);
422            // Smaller title style so the disclosure label doesn't
423            // dominate the footer row inside a tooltip. Keep the
424            // body's line height so the chevron icon aligns
425            // vertically with the indicator on the same baseline.
426            let mut accordion_title_style = theme.typography.tiny.clone();
427            accordion_title_style.line_height = theme.typography.small.line_height;
428            // Framework-owned chrome string → resolve against the
429            // teksilo-widgets bundle (locales/*.ftl) via tr_widget!, so it
430            // translates with the active locale and apps can override it.
431            let accordion = Accordion::new(teksilo_i18n::tr_widget!(tooltip_more()), expanded)
432                .title_color(theme.colors.tooltip_text)
433                .title_style(accordion_title_style)
434                .content(more_widget);
435            Some(ctx.add(accordion))
436        } else {
437            None
438        };
439
440        let mut root_vstack = VStack::new().spacing(6.0).add_child(header);
441
442        if self.is_cascade_child {
443            // Cascade children are opened by an explicit click and are
444            // already persistent (`EscapeOrClickOutside`) — there's no
445            // hover dwell-to-sticky path, so the `DwellIndicator` would
446            // be meaningless. Drop it; keep only the "more" accordion
447            // when the entry has one.
448            if let Some(accordion) = more_accordion {
449                root_vstack = root_vstack.add_child(accordion);
450            }
451        } else {
452            // Dwell indicator, right-anchored in a footer row.
453            let indicator = ctx.add(DwellIndicator::new(
454                self.dwell_step.clone(),
455                self.sticky.clone(),
456                TextRole::TooltipText,
457            ));
458            // Footer row uses a Grid (Fractional + Auto columns) so the
459            // accordion column receives an explicit width proposal during
460            // Grid's pass-2 measurement. This lets the accordion's
461            // expanded "more" content wrap correctly inside the tooltip,
462            // and keeps the indicator right-anchored on the same row as
463            // the accordion's disclosure label.
464            //
465            // - Column 0 (Fractional 1.0): accordion (or empty Spacer
466            //   when no "more" body) — receives `tooltip_max_width
467            //   - indicator_width - column_gap`.
468            // - Column 1 (Auto): the dwell indicator — sized to its
469            //   intrinsic 14×14.
470            let footer_left = more_accordion.unwrap_or_else(|| ctx.add(Spacer::new()));
471            let footer_row = ctx.add(
472                Grid::new()
473                    .columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
474                    .rows(vec![TrackSize::Auto])
475                    .column_gap(8.0)
476                    .add_child(footer_left)
477                    .add_child(indicator),
478            );
479            root_vstack = root_vstack.add_child(footer_row);
480        }
481
482        let root_content = ctx.add(root_vstack);
483
484        // Wrap everything in padding matching the existing TooltipStyle
485        // tokens so RichTooltipWidget drops into the same chrome the
486        // plain TooltipWidget uses.
487        let padded = ctx.add(
488            Padding::symmetric(tt::TOOLTIP_PADDING_VERTICAL, tt::TOOLTIP_PADDING_HORIZONTAL)
489                .child_id(root_content),
490        );
491
492        self.root_child_id = Some(padded);
493
494        // Sticky tooltip becomes a focusable Dialog. Keyboard users
495        // press Tab to enter the promoted surface (e.g. to click
496        // inline links). Ephemeral tooltips dismiss on pointer-leave
497        // so they can't realistically be tab targets; leaving
498        // `focusable(true)` unconditionally avoids a rebuild on every
499        // sticky flip.
500        let handlers = HandlerSet::new().focusable(true);
501        ctx.apply_self_handlers(handlers);
502
503        // Rebind the sticky signal at AccessibilityOnly so the role
504        // flip (Tooltip → Dialog) and the `Action::Focus` addition in
505        // `accessibility()` reach AT without a relayout or repaint.
506        self.sticky.bind_to(
507            self_id,
508            ctx.binding_registry(),
509            teksilo_core::binding::BindingLevel::AccessibilityOnly,
510        );
511
512        vec![padded]
513    }
514
515    fn layout_response(
516        &self,
517        proposal: SizeProposal,
518        ctx: &LayoutContext,
519    ) -> teksilo_core::widget::LayoutResponse {
520        // Clamp proposal width to the tooltip max_width token so long
521        // bodies wrap rather than stretching the surface.
522        let max_w = crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH;
523        let clamped = SizeProposal {
524            width: Some(proposal.width.map(|w| w.min(max_w)).unwrap_or(max_w)),
525            height: proposal.height,
526        };
527        self.root_child_id
528            .and_then(|id| ctx.child_size(id, clamped))
529            .unwrap_or_else(|| Size::new(0.0, 0.0))
530            .into()
531    }
532
533    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
534        let radius =
535            CornerRadius::uniform(crate::styles::recipe_tooltip_style::TOOLTIP_CORNER_RADIUS);
536        super::paint_tooltip_shadows(canvas, bounds, radius, ctx);
537        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
538        // paint() is the visibility hook — only called when the
539        // tooltip is active. Drives the dwell-promotion timer.
540        self.tick_dwell();
541    }
542
543    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
544        // A tooltip reads as a persistent, focusable panel (non-modal
545        // `Role::Dialog` + advertised `Focus`) rather than an ephemeral
546        // hover hint when either: it has dwell-promoted to sticky, or it
547        // is a cascade child — opened by an explicit click and already
548        // persistent (`EscapeOrClickOutside`) from the moment it shows.
549        let persistent = self.sticky.get() || self.is_cascade_child;
550        let role = if persistent {
551            teksilo_core::accesskit::Role::Dialog
552        } else {
553            teksilo_core::accesskit::Role::Tooltip
554        };
555        builder.set_role(role);
556        if let Some(content) = self.content.as_ref() {
557            builder.set_name(content.text.resolve_now());
558        }
559        if persistent {
560            builder.add_action(teksilo_core::accesskit::Action::Focus);
561        }
562    }
563
564    fn children(&self) -> Vec<WidgetId> {
565        self.root_child_id.map(|id| vec![id]).unwrap_or_default()
566    }
567}
568
569/// Build an `on_link_click` handler closure with two behaviours:
570///
571/// - When the clicked URL is a tooltip key (`:key`), spawn the
572///   corresponding pre-created nested `RichTooltipWidget` as a child
573///   overlay of the current tooltip.
574/// - When the URL is anything else (`http://`, `https://`, `mailto:`,
575///   a bare file path, …), hand it off to the [`open`] crate, which
576///   spawns the OS default handler — a browser for web URLs, a mail
577///   client for `mailto:`, a file manager for paths, and so on.
578///
579/// Errors from `open::that` are swallowed and logged at debug level:
580/// a failed launch shouldn't crash the UI, and the user can always
581/// retry or copy the URL elsewhere. We deliberately don't block the
582/// event loop — `open::that` is already synchronous but returns
583/// quickly (it spawns the child handler and detaches).
584fn make_link_click_handler(
585    nested: Rc<HashMap<String, WidgetId>>,
586    anchor_id: WidgetId,
587) -> impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static {
588    move |url, ctx| {
589        if let Some(key) = TooltipRegistry::parse_url(url) {
590            if let Some(&content_id) = nested.get(key) {
591                ctx.activate(content_id);
592                ctx.show_overlay(OverlayRequest {
593                    content_id,
594                    anchor: anchor_id,
595                    placement: OverlayPlacement::NearAnchor {
596                        offset: teksilo_canvas::Vec2 { x: 0.0, y: 8.0 },
597                    },
598                    dismiss: DismissBehavior::EscapeOrClickOutside,
599                    layer: OverlayLayer::InTree,
600                    // `None` is intentional and correct: the handler
601                    // can't know its own overlay id, so the dispatch
602                    // layer injects the real parent
603                    // (`overlay_ancestor_for_widget(source_widget)` in
604                    // event_dispatch_impl.rs). That links this nested
605                    // tooltip to the one it was opened from, so
606                    // dismissing the parent cascade-closes it
607                    // (`OverlayManager::dismiss_immediate` BFS). Same
608                    // mechanism MenuItem submenus rely on.
609                    parent_overlay: None,
610                    on_dismiss: None,
611                    fade_duration: None,
612                });
613            }
614            return;
615        }
616
617        // Non-tooltip URL — delegate to the OS default handler.
618        // Skip in cfg(test) builds so unit tests don't actually try
619        // to launch a browser against mock URLs. Errors from
620        // `open::that` are intentionally swallowed: a failed launch
621        // shouldn't take down the UI, and there's no inline surface
622        // to report it to from inside a tooltip.
623        #[cfg(not(test))]
624        {
625            let _ = open::that(url);
626        }
627        #[cfg(test)]
628        let _ = url;
629    }
630}
631
632/// Scan a minimal-markdown source string for `[label](:key)` link
633/// URLs and append every `key` (the part after the leading colon) to
634/// `out`. Duplicates are handled by the caller via `sort` + `dedup`.
635///
636/// This is a deliberate small scanner rather than a re-import of
637/// `text-typeset::InlineMarkup::parse`: `teksilo-widgets` doesn't depend
638/// on `text-typeset` directly, and extracting just the tooltip key
639/// URLs doesn't need the full shaping-aware parser.
640fn scan_tooltip_key_urls(source: &str, out: &mut Vec<String>) {
641    let bytes = source.as_bytes();
642    let mut i = 0;
643    while i + 3 < bytes.len() {
644        // Telltale sequence for a tooltip-key link: `](:`
645        if bytes[i] == b']' && bytes[i + 1] == b'(' && bytes[i + 2] == b':' {
646            let start = i + 3;
647            let mut end = start;
648            while end < bytes.len() && bytes[end] != b')' && bytes[end] != b'\\' {
649                end += 1;
650            }
651            if end < bytes.len()
652                && bytes[end] == b')'
653                && start < end
654                && let Ok(key) = std::str::from_utf8(&bytes[start..end])
655            {
656                out.push(key.to_string());
657            }
658            i = end + 1;
659        } else {
660            i += 1;
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn scan_tooltip_key_urls_finds_colon_keys() {
671        let mut out = Vec::new();
672        scan_tooltip_key_urls("see [docs](:docs-key) and [more](:more-key) here", &mut out);
673        assert_eq!(out, vec!["docs-key".to_string(), "more-key".to_string()]);
674    }
675
676    #[test]
677    fn scan_tooltip_key_urls_ignores_http_links() {
678        let mut out = Vec::new();
679        scan_tooltip_key_urls("go to [example](https://example.com)", &mut out);
680        assert!(out.is_empty());
681    }
682
683    #[test]
684    fn scan_tooltip_key_urls_mixed() {
685        let mut out = Vec::new();
686        scan_tooltip_key_urls(
687            "[regular](https://x) and [tip](:my-key) and [also](:other)",
688            &mut out,
689        );
690        assert_eq!(out, vec!["my-key".to_string(), "other".to_string()]);
691    }
692
693    #[test]
694    fn scan_tooltip_key_urls_empty_source() {
695        let mut out = Vec::new();
696        scan_tooltip_key_urls("", &mut out);
697        assert!(out.is_empty());
698    }
699
700    #[test]
701    fn scan_tooltip_key_urls_no_links() {
702        let mut out = Vec::new();
703        scan_tooltip_key_urls("no links here at all", &mut out);
704        assert!(out.is_empty());
705    }
706
707    fn rich_tooltip_height(content: TooltipContent, cascade: bool) -> f32 {
708        use std::cell::RefCell;
709        use teksilo_canvas::MockTextBackend;
710        use teksilo_core::widget_tree::WidgetTree;
711
712        let mut tree =
713            WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
714        let mut w = RichTooltipWidget::new(content);
715        if cascade {
716            w = w.cascade_child();
717        }
718        let id = tree.add(w);
719        // Loose height (width-only) proposal so the tooltip sizes to its
720        // natural content height — an exact height would just be echoed
721        // back as the root's bounds. The VStack has no flexible children,
722        // so the measured height reflects the real layout.
723        tree.layout(SizeProposal::with_width(400.0));
724        tree.bounds(id).height
725    }
726
727    #[test]
728    fn cascade_child_omits_dwell_indicator() {
729        // A normal rich tooltip carries the dwell-to-sticky indicator in
730        // a footer row; a cascade child (opened from a `[label](:key)`
731        // link) suppresses it, since it's already persistent and has no
732        // hover-to-sticky path. With identical content, the cascade
733        // child therefore lays out shorter — no indicator footer row.
734        let make = || TooltipContent::new("k", lit!("Tooltip body"));
735        let normal_h = rich_tooltip_height(make(), false);
736        let cascade_h = rich_tooltip_height(make(), true);
737        assert!(
738            cascade_h < normal_h,
739            "cascade child should be shorter without the dwell-indicator footer \
740             (cascade = {cascade_h}, normal = {normal_h})"
741        );
742    }
743
744    #[test]
745    fn cascade_child_announces_as_persistent_dialog() {
746        use teksilo_core::accessibility::AccessNodeBuilder;
747        use teksilo_core::accesskit::{Action, Role};
748
749        // A cascade child is persistent and focusable from the moment it
750        // shows, so it reads as a non-modal `Dialog` advertising `Focus`
751        // — not an ephemeral `Tooltip`.
752        let child = RichTooltipWidget::new(TooltipContent::new("k", lit!("Body"))).cascade_child();
753        let mut cb = AccessNodeBuilder::new();
754        child.accessibility(&mut cb);
755        assert_eq!(
756            cb.role(),
757            Role::Dialog,
758            "cascade child should read as Dialog"
759        );
760        assert!(
761            cb.actions().contains(&Action::Focus),
762            "cascade child should advertise the Focus action"
763        );
764
765        // A normal tooltip stays an ephemeral Tooltip until it dwell-promotes.
766        let normal = RichTooltipWidget::new(TooltipContent::new("k", lit!("Body")));
767        let mut nb = AccessNodeBuilder::new();
768        normal.accessibility(&mut nb);
769        assert_eq!(
770            nb.role(),
771            Role::Tooltip,
772            "non-cascade tooltip stays a Tooltip when not sticky"
773        );
774        assert!(
775            !nb.actions().contains(&Action::Focus),
776            "non-sticky tooltip should not advertise Focus"
777        );
778    }
779
780    #[test]
781    fn cyclic_cascade_links_do_not_overflow_the_stack() {
782        use crate::tooltip::registry::{_reset_tooltip_registry, install_tooltip_registry};
783        use std::cell::RefCell;
784        use teksilo_canvas::MockTextBackend;
785        use teksilo_core::widget_tree::WidgetTree;
786
787        // The shape that used to overflow: a `[label](:key)` cycle
788        // `a → b → c → a`, plus a self-link `a → a`. Eager, recursive
789        // pre-creation of cascade children followed the cycle forever.
790        _reset_tooltip_registry();
791        install_tooltip_registry(vec![
792            TooltipContent::new("a", lit!("A cites [b](:b) and itself [a](:a)")),
793            TooltipContent::new("b", lit!("B cites [c](:c)")),
794            TooltipContent::new("c", lit!("C cites back to [a](:a)")),
795        ]);
796
797        let mut tree =
798            WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
799        // Reaching layout at all proves the visited-set guard terminated
800        // the cascade pre-creation instead of recursing to a stack overflow.
801        let id = tree.add(RichTooltipWidget::from_key("a"));
802        tree.layout(SizeProposal::with_width(400.0));
803        assert!(tree.bounds(id).height >= 0.0);
804
805        _reset_tooltip_registry();
806    }
807
808    /// Rebuilding a rich tooltip must not strand the cascade children it
809    /// pre-creates.
810    ///
811    /// `build()` eagerly pre-creates one `RichTooltipWidget` per `[label](:key)`
812    /// link in the body, recursively — and those are deliberately not children
813    /// (activating one would propagate down to its own sub-nested tooltips,
814    /// which paint inline as "ghost text"). Held by a bare `ctx.add` they were
815    /// owned by nobody, so every rebuild left the whole cascade behind: in
816    /// Skribisto, whose outline attaches these to its Create/Convert menus,
817    /// clicking between two rows stranded ~2 600 widgets *per click* and grew
818    /// the arena's slotmap past 124 MiB in a couple of minutes.
819    #[test]
820    fn rebuilding_a_rich_tooltip_reaps_its_cascade_children() {
821        use crate::tooltip::registry::{_reset_tooltip_registry, install_tooltip_registry};
822        use std::cell::RefCell;
823        use teksilo_canvas::MockTextBackend;
824        use teksilo_core::widget_tree::WidgetTree;
825
826        _reset_tooltip_registry();
827        install_tooltip_registry(vec![
828            TooltipContent::new("root", lit!("Root cites [b](:b) and [c](:c)")),
829            TooltipContent::new("b", lit!("B cites [d](:d)")),
830            TooltipContent::new("c", lit!("C is a leaf")),
831            TooltipContent::new("d", lit!("D is a leaf")),
832        ]);
833
834        let mut tree =
835            WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
836        let id = tree.add(RichTooltipWidget::from_key("root"));
837        tree.layout(SizeProposal::with_width(400.0));
838
839        let baseline = tree.widget_count();
840        assert!(
841            baseline > 1,
842            "the cascade must actually pre-create children"
843        );
844        for _ in 0..10 {
845            tree.arena_mark_needs_rebuild_for_testing(id);
846            tree.layout(SizeProposal::with_width(400.0));
847        }
848        assert_eq!(
849            tree.widget_count(),
850            baseline,
851            "each rebuild stranded another copy of the cascade"
852        );
853
854        // …and destroying the tooltip takes the cascade with it.
855        tree.destroy_subtree_for_testing(id);
856        tree.layout(SizeProposal::with_width(400.0));
857        assert_eq!(
858            tree.widget_count(),
859            0,
860            "the whole cascade must die with the tooltip that owns it"
861        );
862
863        _reset_tooltip_registry();
864    }
865
866    /// Host with two focusable-by-`tree.focus()` anchors, the first carrying a
867    /// rich tooltip. Used to drive the keyboard path end to end: focus
868    /// promotion, Tab-into-the-surface, Escape, and re-summoning.
869    #[derive(Debug)]
870    struct FocusTooltipHost {
871        anchor_id: Option<WidgetId>,
872        elsewhere_id: Option<WidgetId>,
873        ids_sink: Rc<std::cell::Cell<Option<(WidgetId, WidgetId, WidgetId)>>>,
874    }
875
876    impl teksilo_core::widget::Widget for FocusTooltipHost {
877        fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
878            let anchor = ctx.add(crate::primitives::TextWidget::new(lit!("anchor")));
879            let elsewhere = ctx.add(crate::primitives::TextWidget::new(lit!("elsewhere")));
880            let tip = crate::tooltip::attach::attach_rich_tooltip_content(
881                ctx,
882                anchor,
883                TooltipContent::new("focus-tip", lit!("Focus-promoted body")),
884                ctx.theme().motion.tooltip_delay,
885            );
886            self.anchor_id = Some(anchor);
887            self.elsewhere_id = Some(elsewhere);
888            self.ids_sink.set(Some((anchor, elsewhere, tip)));
889            vec![anchor, elsewhere]
890        }
891        fn layout_response(
892            &self,
893            proposal: teksilo_canvas::SizeProposal,
894            ctx: &teksilo_core::LayoutContext<'_>,
895        ) -> teksilo_core::LayoutResponse {
896            self.anchor_id
897                .and_then(|id| ctx.child_size(id, proposal))
898                .unwrap_or_else(|| teksilo_canvas::Size::new(0.0, 0.0))
899                .into()
900        }
901        fn children(&self) -> Vec<WidgetId> {
902            self.anchor_id
903                .into_iter()
904                .chain(self.elsewhere_id)
905                .collect()
906        }
907    }
908
909    /// `reduced_motion` is a real behavioural axis here, not a cosmetic one.
910    /// With the fade enabled, a dismissed overlay lingers while it fades and
911    /// `try_dismiss_top_on_escape` reports no content ids yet, so the entry
912    /// keeps its stale `overlay_id` and `tooltip_focus_enter` skips it for
913    /// unrelated reasons. That masks the re-summon bug for most users and
914    /// exposes it only for those who have asked for reduced motion — i.e.
915    /// exactly the audience most likely to be navigating by keyboard.
916    fn focus_tooltip_tree_with(
917        reduced_motion: bool,
918    ) -> (
919        teksilo_core::widget_tree::WidgetTree,
920        WidgetId,
921        WidgetId,
922        WidgetId,
923    ) {
924        use std::cell::RefCell;
925        use teksilo_canvas::MockTextBackend;
926        use teksilo_core::widget_tree::WidgetTree;
927
928        let mut tree =
929            WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
930        tree.set_accessibility_preferences(false, reduced_motion, 1.0);
931        let sink = Rc::new(std::cell::Cell::new(None));
932        tree.add(FocusTooltipHost {
933            anchor_id: None,
934            elsewhere_id: None,
935            ids_sink: sink.clone(),
936        });
937        tree.layout(SizeProposal::exact(400.0, 200.0));
938        let (anchor, elsewhere, tip) = sink.get().expect("host built");
939        (tree, anchor, elsewhere, tip)
940    }
941
942    /// Two real `Button`s, the first carrying a rich tooltip — the shape every
943    /// real call site has, and the one Tab traversal can be reasoned about in.
944    fn two_buttons_first_with_tooltip()
945    -> (teksilo_core::widget_tree::WidgetTree, WidgetId, WidgetId) {
946        use crate::button::Button;
947        use std::cell::RefCell;
948        use teksilo_canvas::MockTextBackend;
949        use teksilo_core::widget_tree::WidgetTree;
950
951        let mut tree =
952            WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
953        tree.set_accessibility_preferences(false, true, 1.0);
954        let first = tree.add(
955            Button::new(lit!("First")).rich_tooltip_content(TooltipContent::new(
956                "first-tip",
957                lit!("Body of the first button's tip"),
958            )),
959        );
960        let second = tree.add(Button::new(lit!("Second")));
961        tree.layout(SizeProposal::exact(400.0, 200.0));
962        (tree, first, second)
963    }
964
965    /// Focus *arms* the tip's delay; it does not show on arrival.
966    ///
967    /// The pointer has always required a pause before a tip appears. Showing
968    /// the instant focus arrived made a Tab sweep across a row of tooltipped
969    /// buttons strobe a tip at every stop.
970    #[test]
971    fn focus_arms_the_delay_rather_than_showing_the_tooltip_on_arrival() {
972        let (mut tree, first, _second) = two_buttons_first_with_tooltip();
973
974        tree.focus(first);
975
976        assert!(
977            tree.active_overlays().is_empty(),
978            "focus arriving must not pop a tip — it arms the same delay the \\
979             pointer arms"
980        );
981
982        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
983
984        assert_eq!(
985            tree.active_overlays().len(),
986            1,
987            "and the tip appears once focus has come to rest for the delay"
988        );
989        assert!(
990            !tree.tooltip_is_sticky_within(first),
991            "resting long enough to show is still not long enough to promote"
992        );
993    }
994
995    /// Tabbing straight past a control never shows its tip at all.
996    #[test]
997    fn tabbing_through_without_resting_never_shows_a_tooltip() {
998        use teksilo_core::event::{Key, Modifiers};
999
1000        let (mut tree, first, second) = two_buttons_first_with_tooltip();
1001        tree.focus(first);
1002
1003        // Move on well before the delay ripens.
1004        tree.advance_time(Duration::from_millis(80));
1005        tree.press_key(Key::Tab, Modifiers::NONE);
1006        assert_eq!(tree.focused(), Some(second));
1007
1008        // Let far more than the delay pass on the *new* control.
1009        tree.advance_time(tree.theme().motion.tooltip_delay * 4);
1010
1011        assert!(
1012            tree.active_overlays().is_empty(),
1013            "a tip armed by focus that has already moved on must be disarmed, \\
1014             not left to open a beat later over a control the user has left"
1015        );
1016    }
1017
1018    /// Tab from a control whose tip is merely *shown* continues past it.
1019    ///
1020    /// An unpromoted tip appeared because focus arrived, not because the user
1021    /// asked to enter it, so it takes no Tab stop — the ARIA tooltip rule.
1022    #[test]
1023    fn tab_skips_an_unpromoted_tooltip_and_goes_to_the_next_control() {
1024        use teksilo_core::event::{Key, Modifiers};
1025
1026        let (mut tree, first, second) = two_buttons_first_with_tooltip();
1027        tree.focus(first);
1028        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1029        assert_eq!(tree.active_overlays().len(), 1);
1030
1031        tree.press_key(Key::Tab, Modifiers::NONE);
1032
1033        assert_eq!(
1034            tree.focused(),
1035            Some(second),
1036            "an unpromoted tip is informational and must not capture Tab"
1037        );
1038    }
1039
1040    /// A promoted panel takes the Tab stop **directly after its anchor** —
1041    /// not wherever arena insertion order happened to put its parentless root.
1042    #[test]
1043    fn a_promoted_tooltip_takes_the_tab_stop_right_after_its_anchor() {
1044        use teksilo_core::event::{Key, Modifiers};
1045
1046        let (mut tree, first, second) = two_buttons_first_with_tooltip();
1047        tree.focus(first);
1048        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1049        let tip = tree
1050            .tooltip_content_within(first)
1051            .expect("the button registered a tooltip");
1052        tree.promote_tooltip_to_sticky(tip);
1053
1054        tree.press_key(Key::Tab, Modifiers::NONE);
1055        let after_anchor = tree.focused().expect("Tab landed somewhere");
1056        assert!(
1057            after_anchor == tip || tree.is_descendant_of(after_anchor, tip),
1058            "a promoted panel belongs immediately after the control it \
1059             describes, the way a disclosure's panel follows its button"
1060        );
1061
1062        tree.press_key(Key::Tab, Modifiers::NONE);
1063        assert_eq!(
1064            tree.focused(),
1065            Some(second),
1066            "and traversal continues to the next control once past it"
1067        );
1068    }
1069
1070    /// Escape must hand focus back to the anchor.
1071    ///
1072    /// The keyboard path (`tooltip_focus_enter`) opens its overlay with the
1073    /// plain `show_overlay`, which records no focus-restore target — so the
1074    /// Escape handler skipped its restore branch entirely, leaving focus
1075    /// stranded on the tooltip content that had just gone dormant. The next
1076    /// `revalidate_interaction_state` pass then drops it to `None`, so a user
1077    /// who Tabbed into the surface to reach a link or control ended up with no
1078    /// focus at all and had to Tab in from the top of the window again.
1079    #[test]
1080    fn escape_returns_focus_to_the_anchor_after_tabbing_into_a_focus_promoted_tooltip() {
1081        use teksilo_core::event::{Key, Modifiers};
1082
1083        let (mut tree, anchor, _elsewhere, tip) = focus_tooltip_tree_with(true);
1084
1085        tree.focus(anchor);
1086        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1087        assert_eq!(
1088            tree.active_overlays().len(),
1089            1,
1090            "focus coming to rest must surface the rich tooltip"
1091        );
1092        // Focus no longer promotes on arrival; the dwell does. Promote through
1093        // the public API so the test does not depend on wall-clock timing.
1094        tree.promote_tooltip_to_sticky(tip);
1095
1096        // Move into the surface — the step that makes the bug observable. With
1097        // focus still on the anchor, `focus_with_origin_ops` early-returns on
1098        // an already-focused id and nothing is lost.
1099        tree.focus(tip);
1100        assert_eq!(
1101            tree.active_overlays().len(),
1102            1,
1103            "focus moving into the tooltip's own content must not dismiss it"
1104        );
1105
1106        tree.press_key(Key::Escape, Modifiers::NONE);
1107
1108        assert!(
1109            tree.active_overlays().is_empty(),
1110            "Escape must dismiss the focus-promoted tooltip"
1111        );
1112        assert_eq!(
1113            tree.focused(),
1114            Some(anchor),
1115            "Escape must return focus to the anchor, not strand it on the \
1116             dismissed surface"
1117        );
1118    }
1119
1120    /// …and must not re-open the tooltip on that same keystroke.
1121    ///
1122    /// The restore runs the ordinary focus path, which ends in
1123    /// `tooltip_focus_enter`; by then `dormant_dismissed_content` has cleared
1124    /// the entry's `overlay_id`, so without the suppression flag the entry
1125    /// looks eligible again and the tip the user just dismissed springs back.
1126    #[test]
1127    fn escape_does_not_immediately_re_summon_the_tooltip_it_dismissed() {
1128        use teksilo_core::event::{Key, Modifiers};
1129
1130        let (mut tree, anchor, _elsewhere, tip) = focus_tooltip_tree_with(true);
1131        tree.focus(anchor);
1132        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1133        tree.promote_tooltip_to_sticky(tip);
1134        tree.focus(tip);
1135        tree.press_key(Key::Escape, Modifiers::NONE);
1136
1137        assert!(
1138            tree.active_overlays().is_empty(),
1139            "the restored focus must not re-trigger the tooltip it just closed"
1140        );
1141    }
1142
1143    /// The suppression is served by leaving, not permanent: Tab away and back
1144    /// and the tooltip returns. Otherwise one Escape would mute that anchor
1145    /// for the rest of the session.
1146    #[test]
1147    fn a_dismissed_focus_tooltip_returns_after_focus_leaves_and_comes_back() {
1148        use teksilo_core::event::{Key, Modifiers};
1149
1150        let (mut tree, anchor, elsewhere, tip) = focus_tooltip_tree_with(true);
1151        tree.focus(anchor);
1152        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1153        tree.promote_tooltip_to_sticky(tip);
1154        tree.focus(tip);
1155        tree.press_key(Key::Escape, Modifiers::NONE);
1156        assert!(tree.active_overlays().is_empty());
1157
1158        tree.focus(elsewhere);
1159        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1160        assert!(
1161            tree.active_overlays().is_empty(),
1162            "an unrelated widget must not surface the anchor's tooltip"
1163        );
1164
1165        tree.focus(anchor);
1166        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
1167        assert_eq!(
1168            tree.active_overlays().len(),
1169            1,
1170            "returning to the anchor must summon its tooltip again"
1171        );
1172    }
1173}