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 ®istered {
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 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 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}