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