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