teksilo_core/widget_tree/overlay_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6use crate::accessibility::AccessNodeBuilder;
7
8impl WidgetTree {
9 /// Attach a tooltip to a widget. The tooltip content widget must already
10 /// be in the tree (typically added as a dormant widget during build).
11 pub fn attach_tooltip(
12 &mut self,
13 anchor_id: WidgetId,
14 content_id: WidgetId,
15 delay: std::time::Duration,
16 ) {
17 self.attach_tooltip_inner(
18 anchor_id,
19 content_id,
20 delay,
21 None,
22 None,
23 crate::overlay::TooltipPlacement::Below,
24 );
25 }
26
27 /// Variant of [`attach_tooltip`](Self::attach_tooltip) that opens the
28 /// tooltip at the given [`TooltipPlacement`](crate::overlay::TooltipPlacement)
29 /// — `Side` for anchors stacked vertically (menu items, a vertical tab
30 /// strip, list/tree rows) where `Below` would cover the next sibling.
31 pub fn attach_tooltip_with_placement(
32 &mut self,
33 anchor_id: WidgetId,
34 content_id: WidgetId,
35 delay: std::time::Duration,
36 placement: crate::overlay::TooltipPlacement,
37 ) {
38 self.attach_tooltip_inner(anchor_id, content_id, delay, None, None, placement);
39 }
40
41 /// Attach a tooltip that auto-promotes to "sticky" after
42 /// `sticky_after` elapses post-show. Used by rich tooltips
43 /// implementing the sticky-on-dwell UX (typically 2 seconds).
44 ///
45 /// The tooltip is shown normally after `delay`, then each
46 /// subsequent layout pass checks whether `sticky_after` has
47 /// elapsed since the overlay was shown. When it has, the tree
48 /// calls [`promote_tooltip_to_sticky`](Self::promote_tooltip_to_sticky)
49 /// — the entry is flagged sticky (so pointer-leave no longer
50 /// auto-dismisses) and the overlay's dismiss behavior is
51 /// swapped to `EscapeOrClickOutside`.
52 pub fn attach_tooltip_with_sticky(
53 &mut self,
54 anchor_id: WidgetId,
55 content_id: WidgetId,
56 delay: std::time::Duration,
57 sticky_after: Option<std::time::Duration>,
58 ) {
59 self.attach_tooltip_inner(
60 anchor_id,
61 content_id,
62 delay,
63 sticky_after,
64 None,
65 crate::overlay::TooltipPlacement::Below,
66 );
67 }
68
69 /// Variant of [`attach_tooltip_with_sticky`](Self::attach_tooltip_with_sticky)
70 /// that also takes a shared `Rc<Cell<Option<Instant>>>` "sink"
71 /// the tree updates whenever the tooltip is shown or dismissed.
72 /// The rich tooltip widget reads from this sink to drive its
73 /// dwell indicator without needing a paint-gap heuristic.
74 pub fn attach_tooltip_with_sticky_sink(
75 &mut self,
76 anchor_id: WidgetId,
77 content_id: WidgetId,
78 delay: std::time::Duration,
79 sticky_after: Option<std::time::Duration>,
80 shown_at_sink: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
81 ) {
82 self.attach_tooltip_inner(
83 anchor_id,
84 content_id,
85 delay,
86 sticky_after,
87 Some(shown_at_sink),
88 crate::overlay::TooltipPlacement::Below,
89 );
90 }
91
92 /// Variant of [`attach_tooltip_with_sticky_sink`](Self::attach_tooltip_with_sticky_sink)
93 /// that also carries a [`TooltipPlacement`](crate::overlay::TooltipPlacement)
94 /// — the full-featured path used by `MenuItem` / `TabHeader` /
95 /// `StandardItem` rich + composite tooltips that want `Side` placement.
96 pub fn attach_tooltip_with_sticky_sink_placement(
97 &mut self,
98 anchor_id: WidgetId,
99 content_id: WidgetId,
100 delay: std::time::Duration,
101 sticky_after: Option<std::time::Duration>,
102 shown_at_sink: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
103 placement: crate::overlay::TooltipPlacement,
104 ) {
105 self.attach_tooltip_inner(
106 anchor_id,
107 content_id,
108 delay,
109 sticky_after,
110 Some(shown_at_sink),
111 placement,
112 );
113 }
114
115 /// Drop every tooltip previously attached to `anchor_id` whose content is
116 /// not `keep_content_id`: dismiss a live overlay, destroy the orphaned
117 /// content subtree, and remove the entry.
118 ///
119 /// **An anchor owns at most one tooltip.** `attach_tooltip*` is called from
120 /// `build()`, which re-runs on every rebuild with a freshly-`ctx.add`ed
121 /// content widget — and `ctx.add` creates a *parentless* node, so the
122 /// rebuild teardown (which walks `old_children`) never reaches it. Without
123 /// this retirement the entry table would gain one dead row plus one
124 /// orphaned arena node per rebuild, forever. That table is not cold
125 /// storage: it is scanned on every pointer move, four times per layout
126 /// pass, on every event-loop wake (`next_timer_deadline`) and — worst —
127 /// once *per widget* during the accessibility walk, so the leak is O(n)
128 /// on the hottest paths in the tree.
129 fn retire_tooltips_for_anchor(&mut self, anchor_id: WidgetId, keep_content_id: WidgetId) {
130 let stale: Vec<(WidgetId, Option<crate::overlay::OverlayId>)> = self
131 .tooltips
132 .iter()
133 .filter(|entry| entry.anchor_id == anchor_id && entry.content_id != keep_content_id)
134 .map(|entry| (entry.content_id, entry.overlay_id))
135 .collect();
136 if stale.is_empty() {
137 return;
138 }
139 self.tooltips
140 .retain(|entry| entry.anchor_id != anchor_id || entry.content_id == keep_content_id);
141 for (content_id, overlay_id) in stale {
142 // Retire the overlay before the widget: `dismiss_overlay` walks the
143 // content subtree for focus/hover restoration, which needs the
144 // nodes to still exist.
145 if let Some(overlay_id) = overlay_id {
146 self.dismiss_overlay(overlay_id);
147 }
148 self.destroy_subtree(content_id);
149 }
150 }
151
152 /// Reap the tooltip owned by a widget that is being destroyed.
153 ///
154 /// The anchor's own teardown never reaches the tooltip content — it is a
155 /// parentless node (see [`Self::retire_tooltips_for_anchor`]) — so a destroyed
156 /// widget would otherwise leave its entry and content node behind for the
157 /// lifetime of the tree. Called from `destroy_subtree_inner`.
158 pub(super) fn retire_tooltips_of_destroyed_anchor(&mut self, anchor_id: WidgetId) {
159 let stale: Vec<(WidgetId, Option<crate::overlay::OverlayId>)> = self
160 .tooltips
161 .iter()
162 .filter(|entry| entry.anchor_id == anchor_id)
163 .map(|entry| (entry.content_id, entry.overlay_id))
164 .collect();
165 if stale.is_empty() {
166 return;
167 }
168 self.tooltips.retain(|entry| entry.anchor_id != anchor_id);
169 for (content_id, overlay_id) in stale {
170 if let Some(overlay_id) = overlay_id {
171 self.dismiss_overlay(overlay_id);
172 }
173 self.destroy_subtree(content_id);
174 }
175 }
176
177 fn attach_tooltip_inner(
178 &mut self,
179 anchor_id: WidgetId,
180 content_id: WidgetId,
181 delay: std::time::Duration,
182 sticky_after: Option<std::time::Duration>,
183 shown_at_sink: Option<std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>>,
184 placement: crate::overlay::TooltipPlacement,
185 ) {
186 self.retire_tooltips_for_anchor(anchor_id, content_id);
187 // A re-attach with the *same* content id (a widget whose build reuses
188 // its tooltip node) updates in place rather than stacking a duplicate.
189 if let Some(entry) = self
190 .tooltips
191 .iter_mut()
192 .find(|entry| entry.anchor_id == anchor_id && entry.content_id == content_id)
193 {
194 entry.delay = delay;
195 entry.sticky_after = sticky_after;
196 entry.placement = placement;
197 if shown_at_sink.is_some() {
198 entry.shown_at_sink = shown_at_sink;
199 }
200 return;
201 }
202 self.arena.set_dormant(content_id);
203 self.tooltips.push(TooltipEntry {
204 anchor_id,
205 content_id,
206 // The historic placement, and the right one for a widget that
207 // anchors its tooltip on itself. `BuildContext` overwrites it
208 // immediately after for everything else.
209 description_owner_id: anchor_id,
210 delay,
211 hover_start: None,
212 real_hover_start: None,
213 hover_origin: None,
214 overlay_id: None,
215 sticky_after,
216 is_sticky: false,
217 shown_at_sim: None,
218 shown_at_real: None,
219 shown_at_sink,
220 promoted_by_focus: false,
221 armed_by_focus: false,
222 armed_by_hold: false,
223 suppressed_until_focus_leaves: false,
224 placement,
225 });
226 }
227
228 /// Record which widget's accessibility node should carry a tooltip's
229 /// description, when that is not the node the overlay hangs off.
230 ///
231 /// Called by every `BuildContext::attach_tooltip*` wrapper with the widget
232 /// that was building, so a composing control gets the right node without a
233 /// line in `button.rs`, `toggle.rs` or the two dozen others like them.
234 ///
235 /// **A claim, not an instruction.** One `build()` can attach many tooltips
236 /// -- a list body pane attaches one per visible row -- and `self_id()` is
237 /// the same for all of them, so an owner naming itself here may be naming
238 /// itself for a dozen rows at once. Which of those claims is honoured is
239 /// settled where the whole set can be seen at once, in the accessibility
240 /// walk; nothing is resolved at attach time, because at attach time the
241 /// second row has not been attached yet.
242 ///
243 /// Only the accessibility walk reads this. Overlay placement, hover
244 /// hit-testing, dwell, focus promotion and retirement all go on reading
245 /// `anchor_id`, which is still where the tooltip actually opens.
246 pub(crate) fn set_tooltip_description_owner(
247 &mut self,
248 anchor_id: WidgetId,
249 owner_id: WidgetId,
250 ) {
251 if let Some(entry) = self
252 .tooltips
253 .iter_mut()
254 .find(|entry| entry.anchor_id == anchor_id)
255 {
256 entry.description_owner_id = owner_id;
257 }
258 }
259
260 /// Resolve a [`TooltipPlacement`](crate::overlay::TooltipPlacement) to
261 /// the concrete [`OverlayPlacement`](crate::overlay::OverlayPlacement) used to position the tooltip
262 /// overlay. `Below` keeps the historic tooltip offset; `Side` reuses
263 /// the submenu-style `TrailingEdge` (RTL-aware, leading fallback,
264 /// viewport-clamped) so the tooltip opens beside — not over — the next
265 /// vertically-stacked sibling.
266 fn tooltip_overlay_placement(
267 placement: crate::overlay::TooltipPlacement,
268 ) -> crate::overlay::OverlayPlacement {
269 match placement {
270 crate::overlay::TooltipPlacement::Below => {
271 crate::overlay::OverlayPlacement::NearAnchor {
272 offset: teksilo_canvas::Vec2::new(0.0, 8.0),
273 }
274 }
275 crate::overlay::TooltipPlacement::Side => {
276 crate::overlay::OverlayPlacement::TrailingEdge
277 }
278 }
279 }
280
281 pub(super) fn process_tooltips(&mut self) {
282 let sim_now = self.sim_clock;
283 let session_active = self.tooltip_session_active_sim(sim_now);
284 self.process_tooltips_impl(
285 |entry| {
286 entry
287 .hover_start
288 .map(|start| sim_now.saturating_duration_since(start))
289 },
290 session_active,
291 true,
292 );
293 }
294
295 pub(super) fn process_tooltips_real(&mut self) {
296 let real_now = std::time::Instant::now();
297 let session_active = self.tooltip_session_active_real(real_now);
298 self.process_tooltips_impl(
299 |entry| {
300 entry
301 .real_hover_start
302 .map(|start| real_now.saturating_duration_since(start))
303 },
304 session_active,
305 false,
306 );
307 }
308
309 /// Whether any tooltip is visible *and still part of an active hover
310 /// session*.
311 ///
312 /// A pointer-dwelled **sticky** tooltip is deliberately excluded. It
313 /// survives pointer-leave and stays up until Escape or a click outside, so
314 /// counting it would keep the reshow session warm for as long as it is
315 /// pinned — every other anchor in the window would then fire on the 100 ms
316 /// path indefinitely, which is not a "session", it is a stuck state.
317 fn any_tooltip_in_session(&self) -> bool {
318 self.tooltips
319 .iter()
320 .any(|e| e.overlay_id.is_some() && !e.is_sticky)
321 }
322
323 /// Whether the shortened reshow delay applies right now (sim clock).
324 fn tooltip_session_active_sim(&self, now: std::time::Instant) -> bool {
325 self.any_tooltip_in_session()
326 || self
327 .tooltip_session_until_sim
328 .is_some_and(|until| now < until)
329 }
330
331 /// Whether the shortened reshow delay applies right now (real clock).
332 fn tooltip_session_active_real(&self, now: std::time::Instant) -> bool {
333 self.any_tooltip_in_session()
334 || self
335 .tooltip_session_until_real
336 .is_some_and(|until| now < until)
337 }
338
339 /// Resolve the delay for a pending tooltip: full initial delay, or the
340 /// shortened reshow delay while a tooltip session is active.
341 ///
342 /// The shortening is **proportional**, not a flat floor. Windows derives
343 /// `TTDT_RESHOW` as `TTDT_INITIAL / 5` rather than pinning an absolute
344 /// number, and that ratio is what the two theme tokens encode (100 ms of
345 /// 500 ms). Clamping every entry to the absolute `tooltip_reshow_delay`
346 /// instead collapsed `tooltip_delay_heavy` to the light tier's 100 ms
347 /// whenever a session happened to be warm — so a composite surface or a
348 /// scene-item tip, which exists precisely because heavier content needs a
349 /// longer statement of intent, popped after an incidental 100 ms brush.
350 /// Scaling keeps the light tier at exactly 100 ms while a 700 ms heavy
351 /// entry reshows at 140 ms.
352 fn effective_tooltip_delay(
353 &self,
354 entry_delay: std::time::Duration,
355 session_active: bool,
356 ) -> std::time::Duration {
357 if !session_active {
358 return entry_delay;
359 }
360 let motion = &self.theme.motion;
361 let base = motion.tooltip_delay.as_secs_f64();
362 if base <= 0.0 {
363 // Degenerate theme (no initial delay to take a ratio of) — fall
364 // back to the absolute token.
365 return entry_delay.min(motion.tooltip_reshow_delay);
366 }
367 let ratio = motion.tooltip_reshow_delay.as_secs_f64() / base;
368 // Never *lengthen* a delay on the warm path, whatever the theme says.
369 entry_delay.mul_f64(ratio).min(entry_delay)
370 }
371
372 fn process_tooltips_impl(
373 &mut self,
374 elapsed_fn: impl Fn(&TooltipEntry) -> Option<std::time::Duration>,
375 session_active: bool,
376 use_sim_clock: bool,
377 ) {
378 // Reconcile externally-dismissed overlays (audit G12): a shown tooltip's
379 // overlay may have been removed by the overlay stack's PointerLeave
380 // machinery (pointer left BOTH anchor and tooltip for 100ms). Clear the
381 // now-stale overlay_id + shown state so the tooltip can re-show on the
382 // next dwell, and reset its "shown at" sink.
383 let dismissed_indices: Vec<usize> = self
384 .tooltips
385 .iter()
386 .enumerate()
387 .filter_map(|(i, e)| match e.overlay_id {
388 Some(oid) if !self.overlay_manager.stack.iter().any(|o| o.id == oid) => Some(i),
389 _ => None,
390 })
391 .collect();
392 let any_dismissed = !dismissed_indices.is_empty();
393 for i in dismissed_indices {
394 let e = &mut self.tooltips[i];
395 e.overlay_id = None;
396 e.is_sticky = false;
397 e.promoted_by_focus = false;
398 e.armed_by_hold = false;
399 e.shown_at_sim = None;
400 e.shown_at_real = None;
401 e.hover_origin = None;
402 if let Some(sink) = e.shown_at_sink.as_ref() {
403 sink.set(None);
404 }
405 }
406 // Keep the reshow session warm for a short grace after dismiss so
407 // moving to the next toolbar icon does not pay the full initial delay.
408 if any_dismissed {
409 let grace = super::TOOLTIP_SESSION_GRACE;
410 if use_sim_clock {
411 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
412 } else {
413 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
414 }
415 }
416
417 // Re-evaluate session after dismiss bookkeeping: a still-visible
418 // sibling tip, or the grace we just opened, both count. Uses the same
419 // sticky-excluding predicate as the two `tooltip_session_active_*`
420 // helpers — a pinned tip is not an active session.
421 let session_active = session_active
422 || self.any_tooltip_in_session()
423 || if use_sim_clock {
424 self.tooltip_session_active_sim(self.sim_clock)
425 } else {
426 self.tooltip_session_active_real(std::time::Instant::now())
427 };
428
429 let mut to_show = Vec::new();
430 // Collect show candidates first so we don't hold a mutable borrow
431 // across `arena.is_active` (which needs `&self`).
432 let pending: Vec<usize> = self
433 .tooltips
434 .iter()
435 .enumerate()
436 .filter(|(_, entry)| entry.overlay_id.is_none())
437 .filter(|(_, entry)| self.arena.is_active(entry.anchor_id))
438 .filter_map(|(i, entry)| {
439 let delay = self.effective_tooltip_delay(entry.delay, session_active);
440 let elapsed = elapsed_fn(entry)?;
441 (elapsed >= delay).then_some(i)
442 })
443 .collect();
444 for index in pending {
445 // Tooltip bodies are built lazily (`add_detached_deferred_on_demand`),
446 // so materialize this one before anything asks it a question — the
447 // `tooltip_has_content` check below reads the content widget, and an
448 // unbuilt host would answer for a body that does not exist yet.
449 let content_id = self.tooltips[index].content_id;
450 self.materialize_deferred(content_id);
451 // A tooltip with nothing to say must not open. An unresolved or
452 // blank i18n key would otherwise pop an empty chromed bubble,
453 // which reads as a rendering fault rather than as "no tip here".
454 // The content widget decides — see `Widget::tooltip_has_content`,
455 // which defaults to `true` so arbitrary bodies are never
456 // suppressed by a check they did not opt into.
457 // …and ask the *body*, not the host: once materialized the deferred
458 // host has handed its widget value to a real child, so probing the
459 // host would consult the default `true` and let an empty bubble
460 // through on exactly the second hover.
461 let body_id = self.tooltip_content_node(content_id);
462 if !self
463 .arena
464 .get(body_id)
465 .is_none_or(|node| node.widget.tooltip_has_content())
466 {
467 let entry = &mut self.tooltips[index];
468 entry.hover_start = None;
469 entry.real_hover_start = None;
470 entry.hover_origin = None;
471 continue;
472 }
473 let entry = &mut self.tooltips[index];
474 to_show.push((
475 entry.anchor_id,
476 entry.content_id,
477 entry.placement,
478 entry.armed_by_focus,
479 entry.armed_by_hold,
480 ));
481 entry.hover_start = None;
482 entry.real_hover_start = None;
483 entry.hover_origin = None;
484 }
485 let sim_now = self.sim_clock;
486 let real_now = std::time::Instant::now();
487 // Tooltips fade in over `duration_fast` (~120 ms) — matches the
488 // MotionTokens recommendation for "tooltip fade, popup fade".
489 // Reduced-motion users get an instant snap, and so does the warm
490 // reshow path: on a toolbar sweep the whole point of the ~100 ms
491 // reshow is that the next tip is *already there*, and a 120 ms fade
492 // on top of it costs more than the delay it just saved.
493 let fade_duration = if self.prefers_reduced_motion || session_active {
494 None
495 } else {
496 Some(self.theme.motion.duration_fast)
497 };
498 for (anchor_id, content_id, placement, by_focus, by_hold) in to_show {
499 self.arena.activate(content_id);
500 // A tip the keyboard summoned has no pointer to leave, so the
501 // pointer-leave grace would never fire and it would hang on screen.
502 // It ends the way a keyboard user ends things: Escape, a click
503 // outside, or focus moving off the anchor. A tip a **hold**
504 // summoned is in the same position — the finger has lifted by the
505 // time it appears — with one difference: nothing moves focus off it
506 // either, so it also carries its own expiry (see
507 // `super::touch_route::TOUCH_TOOLTIP_DISMISS`).
508 let dismiss = if by_focus || by_hold {
509 crate::overlay::DismissBehavior::EscapeOrClickOutside
510 } else {
511 crate::overlay::DismissBehavior::PointerLeave {
512 delay: std::time::Duration::from_millis(100),
513 }
514 };
515 let request = crate::overlay::OverlayRequest {
516 content_id,
517 anchor: anchor_id,
518 placement: Self::tooltip_overlay_placement(placement),
519 dismiss,
520 layer: crate::overlay::OverlayLayer::InTree,
521 parent_overlay: None,
522 on_dismiss: None,
523 fade_duration,
524 };
525 let oid = if by_hold {
526 self.show_overlay_for(request, super::touch_route::TOUCH_TOOLTIP_DISMISS)
527 } else {
528 self.show_overlay(request)
529 };
530 if by_focus && let Some(focused) = self.focused {
531 self.overlay_manager.set_top_focus_restore(focused);
532 }
533 if let Some(entry) = self
534 .tooltips
535 .iter_mut()
536 .find(|e| e.content_id == content_id)
537 {
538 entry.overlay_id = Some(oid);
539 entry.shown_at_sim = Some(sim_now);
540 entry.shown_at_real = Some(real_now);
541 entry.promoted_by_focus = by_focus;
542 if let Some(sink) = entry.shown_at_sink.as_ref() {
543 sink.set(Some(real_now));
544 }
545 }
546 }
547
548 // Sticky-on-dwell sweep:
549 // 1. Mark every shown rich tooltip's **subtree** needs_paint
550 // so its `paint()` re-runs each layout pass during the
551 // dwell window AND its children (notably the
552 // `DwellIndicator`) repaint with the freshly-set step.
553 // Marking only the root would leave the indicator's
554 // cached_paint in place and the visible wedge stale.
555 // 2. Auto-promote any tooltip whose dwell window has
556 // elapsed: flag the entry sticky and swap the overlay's
557 // dismiss behavior to `EscapeOrClickOutside`. Marking
558 // runs even on the promoting frame so `tick_dwell` can
559 // observe `elapsed >= sticky_after` and flip the
560 // indicator to its pin variant.
561 let mut to_mark_paint: Vec<WidgetId> = Vec::new();
562 let mut to_promote: Vec<WidgetId> = Vec::new();
563 for entry in &self.tooltips {
564 let Some(sticky_after) = entry.sticky_after else {
565 continue;
566 };
567 if entry.overlay_id.is_none() || entry.is_sticky {
568 continue;
569 }
570 let elapsed = entry
571 .shown_at_real
572 .map(|t| real_now.saturating_duration_since(t));
573 let elapsed = match elapsed {
574 Some(e) => e,
575 None => continue,
576 };
577 // Always mark needs_paint on the dwell window — the
578 // promoting frame still needs the widget to repaint so
579 // tick_dwell can flip the indicator to its pin variant.
580 to_mark_paint.push(entry.content_id);
581 if elapsed >= sticky_after {
582 to_promote.push(entry.content_id);
583 }
584 }
585 for id in to_mark_paint {
586 self.arena.mark_subtree_needs_paint(id);
587 }
588 for content_id in to_promote {
589 self.promote_tooltip_to_sticky(content_id);
590 }
591 }
592
593 pub(super) fn process_delayed_overlays(&mut self) {
594 let sim_now = self.sim_clock;
595 let mut noop = crate::window::NoopWindowOps;
596 self.process_delayed_overlays_impl(
597 |p| sim_now.saturating_duration_since(p.sim_requested_at),
598 &mut noop,
599 );
600 }
601
602 pub(super) fn process_delayed_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
603 let real_now = std::time::Instant::now();
604 self.process_delayed_overlays_impl(
605 |p| real_now.saturating_duration_since(p.real_requested_at),
606 &mut *ops,
607 );
608 }
609
610 fn process_delayed_overlays_impl(
611 &mut self,
612 elapsed_fn: impl Fn(&PendingDelayedOverlay) -> std::time::Duration,
613 ops: &mut dyn crate::window::WindowOps,
614 ) {
615 let mut ready_indices = Vec::new();
616 for (index, pending) in self.pending_delayed_overlays.iter().enumerate() {
617 if elapsed_fn(pending) >= pending.delay {
618 ready_indices.push(index);
619 }
620 }
621
622 let mut ready = Vec::new();
623 for &index in ready_indices.iter().rev() {
624 ready.push(self.pending_delayed_overlays.remove(index));
625 }
626 ready.reverse();
627
628 let any_shown = !ready.is_empty();
629 for pending in ready {
630 let content_id = pending.request.content_id;
631 // Deferred hover-switch: the sibling submenu that was open
632 // while this one was merely pending goes now, not when the
633 // pointer first touched the trigger. `preserve` finds
634 // nothing yet (this overlay is not on the stack), which is
635 // exactly right — everything below the anchor's own overlay
636 // goes, and this one is pushed immediately after.
637 if pending.replace_siblings {
638 let anchor = pending.request.anchor;
639 self.dismiss_child_overlays_for_source(anchor, Some(content_id), &mut *ops);
640 }
641 self.arena.activate(content_id);
642 let current_focus = self.focused;
643 self.overlay_manager.show(pending.request);
644 if let Some(focus_id) = current_focus {
645 self.overlay_manager.set_top_focus_restore(focus_id);
646 }
647 self.arena.mark_needs_paint(content_id);
648 if let Some(focus_target) = pending.focus_target
649 && self.arena.is_active(focus_target)
650 {
651 self.focus_ops(focus_target, &mut *ops);
652 }
653 }
654 if any_shown {
655 self.a11y_dirty = true;
656 }
657 }
658
659 pub(super) fn overlay_ancestor_for_widget(
660 &self,
661 widget_id: WidgetId,
662 ) -> Option<crate::overlay::OverlayId> {
663 self.overlay_manager
664 .stack
665 .iter()
666 .rev()
667 .find(|overlay| self.is_descendant_of(widget_id, overlay.content_id))
668 .map(|overlay| overlay.id)
669 }
670
671 pub(super) fn modal_overlay_for_widget(
672 &self,
673 widget_id: WidgetId,
674 ) -> Option<crate::overlay::OverlayId> {
675 let mut current = self.overlay_ancestor_for_widget(widget_id);
676
677 while let Some(overlay_id) = current {
678 let overlay = self.overlay_manager.overlay(overlay_id)?;
679 if matches!(
680 overlay.placement,
681 crate::overlay::OverlayPlacement::Centered
682 ) {
683 return Some(overlay_id);
684 }
685 current = overlay.parent_overlay;
686 }
687
688 None
689 }
690
691 fn menu_ancestor_for_widget(&self, widget_id: WidgetId) -> Option<WidgetId> {
692 let mut current = Some(widget_id);
693 while let Some(id) = current {
694 if let Some(node) = self.arena.get(id) {
695 let mut builder = AccessNodeBuilder::new();
696 node.widget.accessibility(&mut builder);
697 if builder.role() == accesskit::Role::Menu {
698 return Some(id);
699 }
700 }
701 current = self.arena.parent(id);
702 }
703 None
704 }
705
706 pub(super) fn dismiss_child_overlays_for_source(
707 &mut self,
708 source_widget: WidgetId,
709 preserve_content: Option<WidgetId>,
710 ops: &mut dyn crate::window::WindowOps,
711 ) {
712 if let Some(parent_overlay) = self.overlay_ancestor_for_widget(source_widget) {
713 let preserve_overlay = preserve_content
714 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
715 let (dismissed, focus_restore) = self
716 .overlay_manager
717 .dismiss_descendants_of(parent_overlay, preserve_overlay);
718 self.dormant_dismissed_content(&dismissed, &mut *ops);
719 if let Some(restore_id) = focus_restore
720 && self.arena.is_active(restore_id)
721 {
722 self.focus_ops(restore_id, &mut *ops);
723 }
724 return;
725 }
726
727 let Some(menu_root) = self.menu_ancestor_for_widget(source_widget) else {
728 return;
729 };
730 let preserve_overlay = preserve_content
731 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
732 let overlay_ids: Vec<crate::overlay::OverlayId> = self
733 .overlay_manager
734 .stack
735 .iter()
736 .filter(|overlay| {
737 overlay.parent_overlay.is_none()
738 && self.is_descendant_of(overlay.anchor, menu_root)
739 && !preserve_overlay.is_some_and(|keep| {
740 overlay.id == keep
741 || self.overlay_manager.is_descendant_of(overlay.id, keep)
742 })
743 })
744 .map(|overlay| overlay.id)
745 .collect();
746
747 for overlay_id in overlay_ids {
748 let (dismissed, focus_restore) =
749 self.overlay_manager.dismiss_with_focus_restore(overlay_id);
750 self.dormant_dismissed_content(&dismissed, &mut *ops);
751 if let Some(restore_id) = focus_restore
752 && self.arena.is_active(restore_id)
753 {
754 self.focus_ops(restore_id, &mut *ops);
755 }
756 }
757 }
758
759 /// Dismiss the menu chain anchored to `source_widget`. Walks from
760 /// the source's containing overlay up the `parent_overlay` chain,
761 /// collecting every overlay whose content role is *not* a "stop"
762 /// role (`Tooltip`, `Dialog`, `AlertDialog`). Dismisses the
763 /// topmost collected overlay — `OverlayManager::dismiss` cascades
764 /// to descendants, so the entire menu/popover cascade closes in
765 /// one go while a hosting tooltip / dialog is preserved.
766 ///
767 /// Replacement for `dismiss_all_overlays` in menu / dropdown item
768 /// activation handlers, where the popover may be hosted inside a
769 /// composite tooltip and "all overlays" is too broad.
770 pub(super) fn dismiss_self_overlay_chain_for_source(
771 &mut self,
772 source_widget: WidgetId,
773 ops: &mut dyn crate::window::WindowOps,
774 ) {
775 let Some(start) = self.overlay_ancestor_for_widget(source_widget) else {
776 return;
777 };
778
779 let mut topmost_to_dismiss: Option<crate::overlay::OverlayId> = None;
780 let mut current = Some(start);
781 while let Some(overlay_id) = current {
782 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
783 break;
784 };
785 if self.overlay_is_host_surface(overlay_id) {
786 break;
787 }
788 topmost_to_dismiss = Some(overlay_id);
789 current = overlay.parent_overlay;
790 }
791
792 if let Some(target) = topmost_to_dismiss {
793 let (dismissed, focus_restore) =
794 self.overlay_manager.dismiss_with_focus_restore(target);
795 self.dormant_dismissed_content(&dismissed, &mut *ops);
796 if let Some(restore_id) = focus_restore
797 && self.arena.is_active(restore_id)
798 {
799 self.focus_ops(restore_id, &mut *ops);
800 }
801 }
802 }
803
804 /// Whether an overlay is **positioned relative to its anchor** — a panel
805 /// that belongs to a control, rather than to the window.
806 ///
807 /// This is the line between a disclosure and a notification. `Below`,
808 /// `Above`, `TrailingEdge`, `AtPointer`, `NearAnchor` and `BelowPreferred`
809 /// all place content *at* the widget that opened it: the anchor is a real
810 /// relationship, and "focus left that control" is a meaningful thing to say
811 /// about them. The viewport-placed variants are not that — `ViewportCorner`
812 /// and `FullViewport` say outright that anchor bounds are ignored,
813 /// `BottomCenter` is where a snackbar lives, and `Centered` is the modal.
814 /// Their `anchor` field is bookkeeping (whatever widget happened to ask),
815 /// not a disclosure they hang off.
816 ///
817 /// Written as an exhaustive `match` on purpose: a placement variant added
818 /// later must not quietly inherit whichever answer happened to be the
819 /// default, so the compiler makes it a decision.
820 fn overlay_is_anchor_positioned(placement: &crate::overlay::OverlayPlacement) -> bool {
821 use crate::overlay::OverlayPlacement as P;
822 match placement {
823 P::Below
824 | P::Above
825 | P::TrailingEdge
826 | P::AtPointer(_)
827 | P::AtPointerAvoiding { .. }
828 | P::NearAnchor { .. }
829 | P::BelowPreferred => true,
830 // `AboveSelection` joins the viewport-placed group: what it hangs
831 // off is a range of text, not the widget recorded as its anchor.
832 // Its lifetime belongs to the selection controller that raised it,
833 // which is also why it lives in the text-affordance band.
834 P::Centered
835 | P::BottomCenter
836 | P::ViewportCorner { .. }
837 | P::FullViewport
838 | P::AboveSelection { .. } => false,
839 }
840 }
841
842 /// Whether an overlay is one that keyboard focus leaving it should close.
843 ///
844 /// Three exclusions, all structural rather than declared:
845 ///
846 /// * Anything **not positioned at its anchor** (see
847 /// [`Self::overlay_is_anchor_positioned`]). That covers the modal — the
848 /// one surface that legitimately *contains* focus (ARIA's Dialog (Modal)
849 /// pattern), which `cycle_focus` already roots Tab traversal at — its
850 /// full-viewport scrim, and the notification surfaces. A snackbar is the
851 /// sharp case: it is shown *from* a focused button and keeps that button
852 /// focused, so an anchor-aware rule would otherwise tear it down on the
853 /// user's very next keystroke, overriding both its timer and
854 /// `.persistent()`. A toast's lifetime belongs to its timer, never to
855 /// where the keyboard happens to be.
856 /// * An overlay **already fading out**. Dismissing it again collapses the
857 /// tween it is mid-way through — and a dismissal is usually what moved
858 /// focus here in the first place, so this is the common case, not a
859 /// corner.
860 /// * A **tooltip**, owned end-to-end by `tooltip_focus_leave_outside`,
861 /// which asks a deliberately wider question — it keeps a tip alive while
862 /// focus rests on its *anchor*, which is the normal state of a
863 /// focus-promoted tip. Judging it by content alone would kill it the
864 /// moment it appeared.
865 ///
866 /// Note what is *not* consulted: [`DismissBehavior`](crate::overlay::DismissBehavior).
867 /// That enum picks which of Escape / click-outside / hover-out apply, which
868 /// is an orthogonal axis — a popover that opted out of click-outside did not
869 /// thereby ask to survive being tabbed away from.
870 ///
871 /// Deliberately *not* `overlay_is_host_surface` either: that matches
872 /// `Role::Dialog`, and `PopoverSurface` announces itself as exactly that,
873 /// so host-ness would exempt every popover in the framework — the one
874 /// surface this rule exists for. Host-ness still stops the walk *above*
875 /// the overlay focus left; see `dismiss_overlays_left_by_focus`.
876 fn overlay_follows_focus_out(&self, overlay_id: crate::overlay::OverlayId) -> bool {
877 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
878 return false;
879 };
880 if !Self::overlay_is_anchor_positioned(&overlay.placement) {
881 return false;
882 }
883 if overlay.is_dismissing() {
884 return false;
885 }
886 !self
887 .tooltips
888 .iter()
889 .any(|entry| entry.content_id == overlay.content_id)
890 }
891
892 /// The topmost overlay `widget_id` belongs to for focus purposes — either
893 /// because it sits *inside* that overlay's content, or because it is the
894 /// **anchor** the overlay hangs off.
895 ///
896 /// The anchor half is not a nicety. A non-searchable `ComboBox` keeps focus
897 /// on its own trigger the whole time its dropdown is open, and a
898 /// `SearchField` keeps it in the text input while the suggestion list
899 /// floats below — in both cases focus is never inside the overlay at all,
900 /// so a content-only test would decide nothing was ever open and leave the
901 /// panel behind when the user tabbed away. `tooltip_focus_leave_outside`
902 /// reached the same conclusion for tips, for the same reason.
903 ///
904 /// **One pass over the stack, top down, asking both questions at each
905 /// level** — not "by content, else by anchor". Those two orderings differ
906 /// exactly when a widget is inside one overlay and the anchor of another,
907 /// which is the ordinary shape of a dropdown opened inside a modal: a
908 /// content-first lookup answers with the *modal*, whose whole point is that
909 /// it does not follow focus out, and the dropdown anchored to the very
910 /// widget holding focus never gets considered at all. That left a
911 /// `ComboBox` panel open over the Settings dialog after Tab had moved on.
912 /// Scanning top down instead lets the nearer, more specific overlay win,
913 /// because the stack is already ordered by exactly that.
914 fn overlay_orbit_for_widget(&self, widget_id: WidgetId) -> Option<crate::overlay::OverlayId> {
915 self.overlay_manager
916 .stack
917 .iter()
918 .rev()
919 .find(|overlay| {
920 self.is_descendant_of(widget_id, overlay.content_id)
921 || (self.overlay_follows_focus_out(overlay.id)
922 && self.is_descendant_of(widget_id, overlay.anchor))
923 })
924 .map(|overlay| overlay.id)
925 }
926
927 /// Close whatever non-modal overlay the keyboard just walked out of.
928 ///
929 /// Non-modal overlays do not trap Tab — menus, popovers and dropdown
930 /// panels implement patterns (Menu, Disclosure, Combobox) that all treat
931 /// Tab as an *exit* gesture, not a navigation one. APG is unqualified
932 /// about menus in particular: Tab "moves focus out of the menu or menubar,
933 /// and closes all menus and submenus". So rather than contain focus, we
934 /// let it go and take the overlay down behind it — which is also what
935 /// keeps an open panel from hiding the focus ring that just left it
936 /// (WCAG 2.2 SC 2.4.11, Focus Not Obscured).
937 ///
938 /// **Ancestry here is two different questions, and they use two
939 /// identically-named helpers.** A submenu's content is `add_detached`, so
940 /// it is never an arena descendant of the menu that opened it — only
941 /// [`OverlayManager::is_descendant_of`](crate::overlay::OverlayManager::is_descendant_of),
942 /// which walks `parent_overlay`, relates the two. Asking
943 /// [`Self::is_descendant_of`] (the arena walk) instead would close a menu
944 /// the instant its own submenu took focus.
945 ///
946 /// Walking *up* the chain and dismissing the outermost eligible level is
947 /// what delivers APG's plural "all menus": `dismiss` already cascades back
948 /// down to every descendant, so one call closes the whole tree.
949 ///
950 /// Unlike every other dismissal path this one must **not** restore focus to
951 /// the overlay's `focus_restore`. The caller has already installed the new
952 /// focus target; re-focusing the trigger here would yank it straight back.
953 pub(super) fn dismiss_overlays_left_by_focus(
954 &mut self,
955 old: Option<WidgetId>,
956 new_focus: WidgetId,
957 ops: &mut dyn crate::window::WindowOps,
958 ) {
959 let Some(old) = old else {
960 return;
961 };
962 let Some(old_overlay) = self.overlay_orbit_for_widget(old) else {
963 return;
964 };
965 // Deliberately the *content*-only lookup, not the orbit: arriving on an
966 // overlay's own anchor is leaving it. That is the trigger — exactly
967 // where Escape would have put you — so Shift+Tab off the front of a
968 // popover closes it rather than parking an open panel under the focus
969 // ring of the button that owns it.
970 let new_overlay = self.overlay_ancestor_for_widget(new_focus);
971
972 // Focus went *deeper* into the same cascade — a submenu opening off its
973 // parent, a picker inside a popover. Nothing was left behind.
974 if let Some(new_overlay) = new_overlay
975 && (new_overlay == old_overlay
976 || self
977 .overlay_manager
978 .is_descendant_of(new_overlay, old_overlay))
979 {
980 return;
981 }
982
983 if !self.overlay_follows_focus_out(old_overlay) {
984 return;
985 }
986
987 let mut topmost = old_overlay;
988 let mut current = self
989 .overlay_manager
990 .overlay(old_overlay)
991 .and_then(|overlay| overlay.parent_overlay);
992 while let Some(overlay_id) = current {
993 // Focus backed out to a shallower level of this same cascade (a
994 // submenu handing back to the menu that owns it). That level stays;
995 // only what sits below it goes.
996 if Some(overlay_id) == new_overlay {
997 break;
998 }
999 // Never let a menu take its hosting dialog, composite tooltip or
1000 // revealed menubar down with it.
1001 if self.overlay_is_host_surface(overlay_id) {
1002 break;
1003 }
1004 if !self.overlay_follows_focus_out(overlay_id) {
1005 break;
1006 }
1007 topmost = overlay_id;
1008 current = self
1009 .overlay_manager
1010 .overlay(overlay_id)
1011 .and_then(|overlay| overlay.parent_overlay);
1012 }
1013
1014 let dismissed = self.overlay_manager.dismiss(topmost);
1015 self.dormant_dismissed_content(&dismissed, &mut *ops);
1016 }
1017
1018 /// Dismiss every overlay whose content's role is *not* a host
1019 /// surface (`Tooltip` / `Dialog` / `AlertDialog`). Targets stay
1020 /// stable across the loop because we resolve ids first, then
1021 /// dismiss — `OverlayManager::dismiss` cascades to descendants,
1022 /// so an already-cascaded id is a no-op.
1023 ///
1024 /// Replacement for `dismiss_all_overlays` in popover triggers and
1025 /// pre-show cleanup paths, where the broad "dismiss everything"
1026 /// semantics also closed an outer composite tooltip or modal
1027 /// hosting the trigger.
1028 pub(super) fn dismiss_all_overlays_except_hosts(
1029 &mut self,
1030 ops: &mut dyn crate::window::WindowOps,
1031 ) {
1032 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1033 .overlay_manager
1034 .stack
1035 .iter()
1036 .map(|overlay| overlay.id)
1037 .filter(|&id| !self.overlay_is_host_surface(id))
1038 .collect();
1039
1040 for overlay_id in to_dismiss {
1041 let dismissed = self.overlay_manager.dismiss(overlay_id);
1042 self.dormant_dismissed_content(&dismissed, &mut *ops);
1043 }
1044 }
1045
1046 /// Whether `overlay_id` is a "host" surface — a tooltip, dialog,
1047 /// or alert dialog. Host overlays survive `dismiss_all_except_hosts`
1048 /// and stop the upward walk in `dismiss_self_overlay_chain_for_source`,
1049 /// so a popover hosted inside a composite tooltip can dismiss
1050 /// itself (or its menu cascade) without taking the host with it.
1051 pub(super) fn overlay_is_host_surface(&self, overlay_id: crate::overlay::OverlayId) -> bool {
1052 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
1053 return false;
1054 };
1055 // A modal (a `Centered` overlay — see `modal_overlay_for_widget`) is
1056 // always a host surface: a dropdown / popover / menu opened *inside* a
1057 // modal must dismiss only its own cascade, never tear down the hosting
1058 // modal. Without this, a `ComboBox`/menu inside a modal that closes via
1059 // `dismiss_all_except_hosts` / `dismiss_self_overlay_chain_for_source`
1060 // walks past the modal (whose content is not a `Dialog`-role widget) and
1061 // dismisses it too. Same fix that stopped a `TabWidget` overflow menu
1062 // from closing its hosting composite tooltip, extended to modals.
1063 if matches!(
1064 overlay.placement,
1065 crate::overlay::OverlayPlacement::Centered
1066 ) {
1067 return true;
1068 }
1069 let Some(node) = self.arena.get(overlay.content_id) else {
1070 return false;
1071 };
1072 // Prefer an `.access_role(...)` override (e.g. a collapsible
1073 // `MenuBar`'s bar-content node marked `Role::MenuBar`) over the
1074 // widget's own role — the override is what the AccessKit walker
1075 // surfaces, so it must also decide host-ness here. Without this,
1076 // the revealed bar would not be recognised as a host and
1077 // `dismiss_all_except_hosts` (called when a menu opens) would
1078 // tear it down mid-navigation.
1079 let role = node
1080 .access_overrides
1081 .as_ref()
1082 .and_then(|o| o.role)
1083 .unwrap_or_else(|| {
1084 let mut builder = AccessNodeBuilder::new();
1085 node.widget.accessibility(&mut builder);
1086 builder.role()
1087 });
1088 matches!(
1089 role,
1090 accesskit::Role::Tooltip
1091 | accesskit::Role::Dialog
1092 | accesskit::Role::AlertDialog
1093 | accesskit::Role::MenuBar
1094 )
1095 }
1096
1097 pub(super) fn dismiss_modal_for_source(
1098 &mut self,
1099 source_widget: WidgetId,
1100 ops: &mut dyn crate::window::WindowOps,
1101 ) -> bool {
1102 let Some(modal_overlay) = self.modal_overlay_for_widget(source_widget) else {
1103 return false;
1104 };
1105
1106 let (dismissed, focus_restore) = self
1107 .overlay_manager
1108 .dismiss_with_focus_restore(modal_overlay);
1109 self.dormant_dismissed_content(&dismissed, &mut *ops);
1110 if let Some(restore_id) = focus_restore
1111 && self.arena.is_active(restore_id)
1112 {
1113 self.focus_ops(restore_id, &mut *ops);
1114 }
1115 true
1116 }
1117
1118 pub fn is_descendant_of(&self, widget_id: WidgetId, ancestor: WidgetId) -> bool {
1119 if widget_id == ancestor {
1120 return true;
1121 }
1122 let mut current = self.arena.parent(widget_id);
1123 while let Some(parent_id) = current {
1124 if parent_id == ancestor {
1125 return true;
1126 }
1127 current = self.arena.parent(parent_id);
1128 }
1129 false
1130 }
1131
1132 /// Whether `widget_id` is the root content node of an active overlay
1133 /// (an open menu, popover, dialog, …). Menus and popovers move keyboard
1134 /// focus onto their whole content container while navigating items via
1135 /// an internal highlight index, so the focus-tooltip path must not treat
1136 /// that container's focus as a per-item trigger (it would surface every
1137 /// descendant's rich tooltip at once).
1138 fn is_overlay_content_root(&self, widget_id: WidgetId) -> bool {
1139 self.overlay_manager.find_by_content(widget_id).is_some()
1140 }
1141
1142 /// Called when a widget gains keyboard focus. For any *rich* tooltip (one
1143 /// with `sticky_after` set) whose anchor contains `widget_id`, **arm** the
1144 /// tooltip's ordinary show delay — it appears only if focus comes to rest,
1145 /// and even then it is not promoted. Promotion is left to the dwell sweep,
1146 /// on the same `sticky_after` clock and the same visible `DwellIndicator`
1147 /// the pointer path uses.
1148 ///
1149 /// Both halves mirror the pointer deliberately. A tip that appeared the
1150 /// instant focus arrived strobed across a row of tooltipped buttons as the
1151 /// user Tabbed through, the same way a tip on pointer-*enter* (rather than
1152 /// pointer-*pause*) would flicker across a toolbar.
1153 ///
1154 /// Focus arriving is not a statement of intent: Tab passes through
1155 /// controls constantly, and a tip that promoted on arrival turned every
1156 /// such pass into a persistent `Role::Dialog` that assistive tech
1157 /// announces and that claims a slot in the Tab cycle. Sustained focus is
1158 /// the keyboard's equivalent of a stationary pointer, so it earns
1159 /// stickiness the same way and over the same window — which also leaves
1160 /// the user a couple of seconds to move on if they did not want the
1161 /// panel. Until then the surface stays an ephemeral `Role::Tooltip`,
1162 /// reachable to screen readers through the anchor's own description, and
1163 /// out of the Tab order (see `cycle_focus`).
1164 ///
1165 /// Plain tooltips (no `sticky_after`) are deliberately NOT
1166 /// auto-shown on focus — their text reaches assistive tech via
1167 /// the anchor's `aria-describedby` relationship wired in the
1168 /// a11y tree pass, which is the W3C-recommended pattern for
1169 /// supplementary hints.
1170 pub(super) fn tooltip_focus_enter(&mut self, widget_id: WidgetId) {
1171 // Two ways a registered rich/composite tooltip can relate to the
1172 // focus target:
1173 // • direct — focus landed ON the anchor or somewhere inside it
1174 // (an ordinary focusable control, a self-anchored focusable
1175 // widget, and composites whose focus sinks into an inner field).
1176 // Always show.
1177 // • reverse — the anchor sits strictly *inside* the focused widget.
1178 // This exists for composing controls (e.g. `Button`) that keep
1179 // focus on their outer node but anchor the tooltip on an inner
1180 // body root. Show ONLY when the focused widget is a single
1181 // such control — never when it is a *container* that merely
1182 // happens to be focusable and owns many tooltip-bearing
1183 // descendants (an open `MenuList`, whose whole panel receives
1184 // focus while items are navigated by an internal highlight
1185 // index), or every descendant's rich tooltip fires at once — the
1186 // "wall of tooltips".
1187 //
1188 // The two arms are mutually exclusive (`if` / `else if`): a widget
1189 // that anchors its own tooltip to its `self_id` (`TabHeader`,
1190 // `ColorSwatch`) satisfies BOTH predicates because `is_descendant_of`
1191 // is reflexive — routing it to `direct` only avoids a duplicate
1192 // `show_overlay` (which would leak the first overlay).
1193 type ToShow = (WidgetId, WidgetId, crate::overlay::TooltipPlacement);
1194 let mut direct: Vec<ToShow> = Vec::new();
1195 let mut reverse: Vec<ToShow> = Vec::new();
1196 for e in &self.tooltips {
1197 if e.sticky_after.is_none() || e.overlay_id.is_some() || e.suppressed_until_focus_leaves
1198 {
1199 continue;
1200 }
1201 if self.is_descendant_of(widget_id, e.anchor_id) {
1202 direct.push((e.anchor_id, e.content_id, e.placement));
1203 } else if self.is_descendant_of(e.anchor_id, widget_id) {
1204 reverse.push((e.anchor_id, e.content_id, e.placement));
1205 }
1206 }
1207 let mut to_show = direct;
1208 // A reverse match is a single composing control only when it resolves
1209 // to exactly one anchor and the focused node is not itself a menu /
1210 // popover content root. Otherwise it is a container fan-out — skip it.
1211 if reverse.len() == 1 && !self.is_overlay_content_root(widget_id) {
1212 to_show.extend(reverse);
1213 }
1214
1215 // Arm the same pending-delay timer the pointer path arms — do not show
1216 // now. Focus arriving is not intent, and Tab moves fast: showing on
1217 // arrival made a sweep across a row of tooltipped buttons strobe a tip
1218 // at every stop. The pointer has always required a *pause* before a tip
1219 // appears; the keyboard equivalent is focus coming to rest, so it waits
1220 // out the same `delay` (and gets the same warm-reshow discount inside a
1221 // session). `hover_origin` stays `None`: there is no pointer to apply
1222 // the stationary-slop filter to.
1223 let sim_now = self.sim_clock;
1224 let real_now = std::time::Instant::now();
1225 for (_anchor_id, content_id, _placement) in to_show {
1226 if let Some(entry) = self
1227 .tooltips
1228 .iter_mut()
1229 .find(|e| e.content_id == content_id)
1230 {
1231 entry.hover_start = Some(sim_now);
1232 entry.real_hover_start = Some(real_now);
1233 entry.hover_origin = None;
1234 entry.armed_by_focus = true;
1235 entry.armed_by_hold = false;
1236 }
1237 }
1238 }
1239
1240 /// Surface the tooltip of a keyboard-highlighted menu item immediately
1241 /// (no dwell), positioned per its own `TooltipPlacement`, and dismiss the
1242 /// previously-highlighted item's tooltip. Real keyboard focus stays on the
1243 /// enclosing `MenuList` (for key handling); this is keyed on `item_id`.
1244 ///
1245 /// The tooltip is shown as a `Manual`-dismiss **child overlay of the
1246 /// enclosing menu**, so closing the menu (Escape / click-outside / the
1247 /// opener) cascades the tooltip away automatically, and a single Escape
1248 /// reaches the menu rather than only clearing the tooltip.
1249 pub(super) fn show_highlight_tooltip(
1250 &mut self,
1251 item_id: WidgetId,
1252 ops: &mut dyn crate::window::WindowOps,
1253 ) {
1254 // Clear (and reconcile) any currently-shown highlight tooltip first —
1255 // moving the highlight replaces it; a tooltip-less target clears it.
1256 self.clear_highlight_tooltip(&mut *ops);
1257
1258 // Find the single tooltip anchored within the highlighted item's
1259 // subtree (a `MenuItem` anchors to its inner body root). Skip if it is
1260 // already shown or the item carries no tooltip.
1261 let found = self.tooltips.iter().find_map(|e| {
1262 if e.overlay_id.is_none() && self.is_descendant_of(e.anchor_id, item_id) {
1263 Some((e.anchor_id, e.content_id, e.placement))
1264 } else {
1265 None
1266 }
1267 });
1268 let Some((anchor_id, content_id, placement)) = found else {
1269 return;
1270 };
1271
1272 let parent_overlay = self.overlay_ancestor_for_widget(item_id);
1273 self.arena.activate(content_id);
1274 let fade_duration = if self.prefers_reduced_motion {
1275 None
1276 } else {
1277 Some(self.theme.motion.duration_fast)
1278 };
1279 let oid = self.show_overlay(crate::overlay::OverlayRequest {
1280 content_id,
1281 anchor: anchor_id,
1282 placement: Self::tooltip_overlay_placement(placement),
1283 dismiss: crate::overlay::DismissBehavior::Manual,
1284 layer: crate::overlay::OverlayLayer::InTree,
1285 parent_overlay,
1286 on_dismiss: None,
1287 fade_duration,
1288 });
1289 let real_now = std::time::Instant::now();
1290 let sim_now = self.sim_clock;
1291 if let Some(entry) = self
1292 .tooltips
1293 .iter_mut()
1294 .find(|e| e.content_id == content_id)
1295 {
1296 entry.overlay_id = Some(oid);
1297 entry.shown_at_sim = Some(sim_now);
1298 entry.shown_at_real = Some(real_now);
1299 if let Some(sink) = entry.shown_at_sink.as_ref() {
1300 sink.set(Some(real_now));
1301 }
1302 }
1303 self.highlight_tooltip = Some((oid, content_id));
1304 }
1305
1306 /// Dismiss the keyboard-highlight tooltip if one is showing, resetting its
1307 /// entry so it can re-show later. Safe to call when none is active or when
1308 /// the overlay was already cascade-dismissed by a menu close (the tracked
1309 /// id is simply no longer in the stack).
1310 pub(super) fn clear_highlight_tooltip(&mut self, ops: &mut dyn crate::window::WindowOps) {
1311 let Some((oid, content_id)) = self.highlight_tooltip.take() else {
1312 return;
1313 };
1314 if self.overlay_manager.stack.iter().any(|o| o.id == oid) {
1315 let dismissed = self.overlay_manager.dismiss(oid);
1316 self.dormant_dismissed_content(&dismissed, &mut *ops);
1317 }
1318 if let Some(entry) = self
1319 .tooltips
1320 .iter_mut()
1321 .find(|e| e.content_id == content_id)
1322 {
1323 entry.overlay_id = None;
1324 entry.shown_at_sim = None;
1325 entry.shown_at_real = None;
1326 if let Some(sink) = entry.shown_at_sink.as_ref() {
1327 sink.set(None);
1328 }
1329 }
1330 }
1331
1332 /// Called when focus moves to a new widget. Dismisses every
1333 /// focus-promoted rich tooltip whose anchor- and tooltip-content
1334 /// subtrees both fail to contain the new focus — so Tab'ing INTO
1335 /// a sticky tooltip to click a link keeps it up, but Tab'ing
1336 /// past it onto unrelated controls closes it (preventing sticky
1337 /// accumulation as the user navigates through a form).
1338 ///
1339 /// Pointer-dwelled stickies (`promoted_by_focus == false`)
1340 /// survive focus changes intact — they're dismissed only via
1341 /// Escape or click-outside, matching the existing mouse UX.
1342 pub(super) fn tooltip_focus_leave_outside(
1343 &mut self,
1344 new_focus: Option<WidgetId>,
1345 ops: &mut dyn crate::window::WindowOps,
1346 ) {
1347 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1348 .tooltips
1349 .iter()
1350 .filter(|e| e.promoted_by_focus && e.overlay_id.is_some())
1351 .filter(|e| {
1352 let in_scope = new_focus
1353 .map(|nf| {
1354 // In scope when the new focus lands in either
1355 // the anchor's subtree or the tooltip content's
1356 // subtree — covers Tab-to-anchor, Tab-deeper-
1357 // inside-anchor, and Tab-into-tooltip.
1358 self.is_descendant_of(nf, e.anchor_id)
1359 || self.is_descendant_of(e.anchor_id, nf)
1360 || self.is_descendant_of(nf, e.content_id)
1361 })
1362 .unwrap_or(false);
1363 !in_scope
1364 })
1365 .filter_map(|e| e.overlay_id)
1366 .collect();
1367
1368 for oid in to_dismiss {
1369 let dismissed = self.overlay_manager.dismiss(oid);
1370 self.dormant_dismissed_content(&dismissed, &mut *ops);
1371 }
1372
1373 // Focus moved on before the delay ripened: disarm, or the tip would
1374 // still open a beat later, over a control the user has already left.
1375 for e in &mut self.tooltips {
1376 if e.armed_by_focus && e.overlay_id.is_none() {
1377 e.hover_start = None;
1378 e.real_hover_start = None;
1379 e.armed_by_focus = false;
1380 }
1381 }
1382
1383 // Focus has landed somewhere new: any entry it is now outside of has
1384 // had its Escape-suppression served. Tabbing away and back must
1385 // re-summon the tip, or one Escape would mute that anchor forever.
1386 let served: Vec<usize> = self
1387 .tooltips
1388 .iter()
1389 .enumerate()
1390 .filter(|(_, e)| e.suppressed_until_focus_leaves)
1391 .filter(|(_, e)| {
1392 let still_inside = new_focus
1393 .map(|nf| {
1394 self.is_descendant_of(nf, e.anchor_id)
1395 || self.is_descendant_of(nf, e.content_id)
1396 })
1397 .unwrap_or(false);
1398 !still_inside
1399 })
1400 .map(|(i, _)| i)
1401 .collect();
1402 for i in served {
1403 self.tooltips[i].suppressed_until_focus_leaves = false;
1404 }
1405 }
1406
1407 /// Whether a pointer hover over `widget_id` should count as a hover
1408 /// of `anchor_id` for tooltip purposes.
1409 ///
1410 /// `widget_id` must be a descendant-or-self of `anchor_id`, AND no
1411 /// active-overlay boundary may separate them. The second clause is
1412 /// what stops a tooltip leaking onto overlay content that merely
1413 /// *remains* an arena child of its anchor: a `ComboBox`'s dropdown
1414 /// panel, a `PopoverButton`'s popover, a menu — all are kept under
1415 /// their trigger in the arena (for hit-test / a11y / teardown) but
1416 /// are shown as overlays. Hovering one of their rows is a hover of
1417 /// the *overlay*, not of the anchor's own chrome, so the anchor's
1418 /// (or any ancestor's) tooltip must not fire.
1419 ///
1420 /// Concretely: if the hovered widget lives inside an active overlay
1421 /// whose content subtree does **not** contain the anchor, the hover
1422 /// is on the overlay and we return `false`. A tooltip attached to a
1423 /// widget *inside* the overlay (e.g. a dropdown row's own tooltip)
1424 /// still fires, because that anchor is itself within the overlay's
1425 /// content subtree.
1426 fn tooltip_hover_targets_anchor(&self, widget_id: WidgetId, anchor_id: WidgetId) -> bool {
1427 if !self.is_descendant_of(widget_id, anchor_id) {
1428 return false;
1429 }
1430 if let Some(overlay_id) = self.overlay_ancestor_for_widget(widget_id)
1431 && let Some(content_id) = self
1432 .overlay_manager
1433 .overlay(overlay_id)
1434 .map(|o| o.content_id)
1435 && !self.is_descendant_of(anchor_id, content_id)
1436 {
1437 return false;
1438 }
1439 true
1440 }
1441
1442 /// Arm the dwell for the tooltip that owns this hover.
1443 ///
1444 /// A hover target can sit inside several tooltip anchors at once (a row
1445 /// with its own tip inside a panel with a tip). Only the **innermost**
1446 /// anchor arms: it is the most specific description of what the pointer is
1447 /// actually over, and arming the outer ones too would let two tooltips
1448 /// mature and open simultaneously, one on top of the other.
1449 ///
1450 /// "Innermost" is measured by arena depth from the hovered widget, so
1451 /// nesting order — not the order the anchors happened to be attached in —
1452 /// decides the winner.
1453 /// The tooltip entry a pointer over `widget_id` should surface: the
1454 /// **innermost** one whose anchor is `widget_id` or an ancestor of it.
1455 ///
1456 /// Shared by the hover arm and by the tree-owned hold route
1457 /// ([`super::touch_route`]) so a hold and a hover cannot pick different
1458 /// tips for the same node.
1459 pub(super) fn tooltip_index_for(&self, widget_id: WidgetId) -> Option<usize> {
1460 self.tooltips
1461 .iter()
1462 .enumerate()
1463 .filter(|(_, entry)| self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id))
1464 .filter_map(|(index, entry)| {
1465 self.ancestor_distance(widget_id, entry.anchor_id)
1466 .map(|depth| (depth, index))
1467 })
1468 .min()
1469 .map(|(_, index)| index)
1470 }
1471
1472 pub(super) fn tooltip_pointer_enter(&mut self, widget_id: WidgetId) {
1473 let Some(index) = self.tooltip_index_for(widget_id) else {
1474 return;
1475 };
1476 // Don't restart a timer for a tip that is already showing.
1477 if self.tooltips[index].overlay_id.is_some() {
1478 return;
1479 }
1480 self.tooltips[index].hover_start = Some(self.sim_clock);
1481 self.tooltips[index].real_hover_start = Some(std::time::Instant::now());
1482 self.tooltips[index].hover_origin = self.hover_owner_position();
1483 self.tooltips[index].armed_by_focus = false;
1484 self.tooltips[index].armed_by_hold = false;
1485 self.arena.mark_needs_paint(self.tooltips[index].anchor_id);
1486 }
1487
1488 /// Number of parent hops from `widget_id` up to `ancestor`, or `None` if
1489 /// `ancestor` is not on the chain. `0` when they are the same widget.
1490 fn ancestor_distance(&self, widget_id: WidgetId, ancestor: WidgetId) -> Option<usize> {
1491 let mut current = widget_id;
1492 let mut depth = 0usize;
1493 loop {
1494 if current == ancestor {
1495 return Some(depth);
1496 }
1497 current = self.arena.parent(current)?;
1498 depth += 1;
1499 }
1500 }
1501
1502 /// Cancel tooltip activity on a pointer press.
1503 ///
1504 /// A press is a statement that the user already knows what the control
1505 /// does: a pending dwell is cancelled (so a tooltip does not pop *after*
1506 /// the click that answered it), and a shown non-sticky tooltip is
1507 /// dismissed (so it stops covering what was just clicked). Re-hovering
1508 /// restarts the delay from scratch, matching Windows and GTK.
1509 ///
1510 /// Pointer-dwelled **sticky** tooltips are left alone — they are an
1511 /// interactive surface the user deliberately pinned, and they own their
1512 /// own Escape / click-outside dismissal.
1513 /// `at` is the press position, when there is one. A press landing *inside*
1514 /// a tooltip's own surface is the user reaching into it (a rich tooltip's
1515 /// inline link), not dismissing it, so that tooltip is spared.
1516 pub(super) fn tooltip_pointer_press(&mut self, at: Option<teksilo_canvas::Point>) {
1517 let mut to_dismiss = Vec::new();
1518 let candidates: Vec<(usize, Option<crate::overlay::OverlayId>)> = self
1519 .tooltips
1520 .iter()
1521 .enumerate()
1522 .map(|(i, e)| (i, if e.is_sticky { None } else { e.overlay_id }))
1523 .collect();
1524 for (index, overlay_id) in candidates {
1525 // Content rect only — deliberately NOT `pointer_inside_overlay_region`,
1526 // which also counts the anchor (it exists to keep the tip alive
1527 // while the pointer crosses the gap). A press on the *anchor* is
1528 // exactly the case that must dismiss.
1529 let inside = match (overlay_id, at) {
1530 (Some(oid), Some(pos)) => self
1531 .overlay_content_bounds(oid)
1532 .is_some_and(|rect| rect.contains(pos)),
1533 _ => false,
1534 };
1535 if inside {
1536 continue;
1537 }
1538 let entry = &mut self.tooltips[index];
1539 entry.hover_start = None;
1540 entry.real_hover_start = None;
1541 entry.hover_origin = None;
1542 if let Some(oid) = overlay_id {
1543 to_dismiss.push(oid);
1544 }
1545 }
1546 for overlay_id in to_dismiss {
1547 self.dismiss_overlay(overlay_id);
1548 }
1549 }
1550
1551 /// Retire shown tooltips on **Escape**, without consuming the key.
1552 ///
1553 /// WCAG 1.4.13 (Content on Hover or Focus) requires that hover content can
1554 /// be dismissed without moving the pointer or focus, and Escape is the
1555 /// mechanism everyone expects. What it does **not** require is that the
1556 /// keystroke stop there — and stopping there is a bug with real teeth,
1557 /// because a tooltip is up far more often than anyone realises. A writer
1558 /// renaming a table cell, pointer resting where they clicked, presses
1559 /// Escape to abandon the rename: the tip they were not looking at silently
1560 /// goes away and the editor stays open. Press it again and it works. It
1561 /// reads as "Escape does nothing", and it is why an Escape-cancels
1562 /// affordance anywhere in an app is unreliable rather than plainly broken.
1563 ///
1564 /// So this runs *before* the overlay stack's own Escape walk and returns no
1565 /// verdict: the tips go, and the key carries on to whatever the user
1566 /// actually meant it for — a menu, a popover, or the focused widget.
1567 /// Retiring them first also means that walk can no longer pick a tooltip as
1568 /// its target, which is what used to swallow the press.
1569 ///
1570 /// Sticky tooltips are untouched, exactly as on a pointer press: the user
1571 /// deliberately pinned those, and they own their own Escape handling.
1572 pub(super) fn tooltip_escape_pressed(&mut self) {
1573 // No position — the same "every non-sticky tip goes" case as a window
1574 // deactivation. A key press has nowhere to be "inside".
1575 self.tooltip_pointer_press(None);
1576 }
1577
1578 /// Retire hover tooltips when the window stops being active.
1579 ///
1580 /// Same shape as [`tooltip_pointer_press`](Self::tooltip_pointer_press) —
1581 /// pending dwells cancelled, shown non-sticky tips dismissed, pinned
1582 /// stickies preserved — but triggered by the window losing focus rather
1583 /// than by a click.
1584 pub(super) fn tooltip_window_deactivated(&mut self) {
1585 // No position: the window is going away, so every non-sticky tip goes
1586 // with it regardless of where the pointer happened to be.
1587 self.tooltip_pointer_press(None);
1588 }
1589
1590 /// Cancel every pending (not-yet-shown) tooltip dwell.
1591 ///
1592 /// Called when a drag session starts: the pointer is now carrying
1593 /// something, and a tooltip that pops mid-drag is a stray overlay in the
1594 /// user's way. `process_tooltips_real` runs from the layout pass, which a
1595 /// drag keeps driving, so the dwell would otherwise mature and show even
1596 /// though the pointer-move path is short-circuited for the drag.
1597 pub(crate) fn tooltip_cancel_pending_dwell(&mut self) {
1598 for entry in &mut self.tooltips {
1599 if entry.overlay_id.is_none() {
1600 entry.hover_start = None;
1601 entry.real_hover_start = None;
1602 entry.hover_origin = None;
1603 }
1604 }
1605 }
1606
1607 /// Restart a pending tooltip's delay when the pointer keeps moving
1608 /// inside the same anchor beyond the stationary slop. Called from
1609 /// pointer-move when the hover target has not changed.
1610 pub(super) fn tooltip_pointer_moved(
1611 &mut self,
1612 widget_id: WidgetId,
1613 position: teksilo_canvas::Point,
1614 ) {
1615 let matching: Vec<usize> = self
1616 .tooltips
1617 .iter()
1618 .enumerate()
1619 .filter(|(_, entry)| {
1620 entry.overlay_id.is_none()
1621 && entry.hover_start.is_some()
1622 && !entry.armed_by_focus
1623 && self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id)
1624 })
1625 .map(|(index, _)| index)
1626 .collect();
1627 if matching.is_empty() {
1628 return;
1629 }
1630 let now = self.sim_clock;
1631 let real_now = std::time::Instant::now();
1632 let slop = super::TOOLTIP_STATIONARY_SLOP;
1633 let slop_sq = slop * slop;
1634 for index in matching {
1635 let origin = match self.tooltips[index].hover_origin {
1636 Some(o) => o,
1637 None => {
1638 // Enter happened without a recorded position (tests that
1639 // call `tooltip_pointer_enter` directly) — adopt this
1640 // position as the origin without restarting the timer.
1641 self.tooltips[index].hover_origin = Some(position);
1642 continue;
1643 }
1644 };
1645 let dx = position.x - origin.x;
1646 let dy = position.y - origin.y;
1647 if dx * dx + dy * dy <= slop_sq {
1648 continue;
1649 }
1650 // Pointer has moved meaningfully since hover began — restart.
1651 self.tooltips[index].hover_start = Some(now);
1652 self.tooltips[index].real_hover_start = Some(real_now);
1653 self.tooltips[index].hover_origin = Some(position);
1654 }
1655 }
1656
1657 /// Promote a shown tooltip from "ephemeral hover" to "sticky".
1658 ///
1659 /// - Flags the tooltip entry as sticky so
1660 /// `tooltip_pointer_leave`
1661 /// no longer auto-dismisses it,
1662 /// - Swaps the overlay's dismiss behavior to
1663 /// `EscapeOrClickOutside` so clicking anywhere off the tooltip
1664 /// (or pressing Escape) closes it.
1665 ///
1666 /// The entry is **not** removed: when the user later dismisses
1667 /// the sticky overlay, `dormant_dismissed_content` resets the
1668 /// entry back to its initial state so a future hover re-shows
1669 /// the tooltip from scratch.
1670 ///
1671 /// Called from `RichTooltipWidget` (or by the auto-promote
1672 /// sweep) once the dwell timer reaches its threshold.
1673 pub fn promote_tooltip_to_sticky(&mut self, content_id: WidgetId) {
1674 let Some(entry) = self
1675 .tooltips
1676 .iter_mut()
1677 .find(|entry| entry.content_id == content_id)
1678 else {
1679 return;
1680 };
1681 if entry.is_sticky {
1682 return;
1683 }
1684 entry.is_sticky = true;
1685 let overlay_id = entry.overlay_id;
1686 if let Some(overlay_id) = overlay_id {
1687 self.overlay_manager.set_dismiss(
1688 overlay_id,
1689 crate::overlay::DismissBehavior::EscapeOrClickOutside,
1690 );
1691 }
1692 }
1693
1694 pub(super) fn tooltip_pointer_leave(
1695 &mut self,
1696 widget_id: WidgetId,
1697 _ops: &mut dyn crate::window::WindowOps,
1698 ) {
1699 let matching: Vec<usize> = self
1700 .tooltips
1701 .iter()
1702 .enumerate()
1703 .filter(|(_, entry)| self.is_descendant_of(widget_id, entry.anchor_id))
1704 .map(|(index, _)| index)
1705 .collect();
1706 for index in matching {
1707 // Cancel any pending (not-yet-shown) dwell so re-hovering restarts
1708 // the delay timer.
1709 self.tooltips[index].hover_start = None;
1710 self.tooltips[index].real_hover_start = None;
1711 self.tooltips[index].hover_origin = None;
1712 // Audit G12 (WCAG 1.4.13 Hoverable): do NOT dismiss a *shown*
1713 // tooltip here on anchor-leave — that killed it the instant the
1714 // pointer crossed the 8px gap toward the tooltip. Dismissal of a
1715 // shown (non-sticky) tooltip is owned by the overlay stack's
1716 // `PointerLeave { 100ms }` machinery (process_pointer_leave_overlays_real,
1717 // run every frame), whose `pointer_inside_overlay_region` keeps the
1718 // overlay alive while the pointer is over EITHER the anchor or the
1719 // tooltip and dismisses only after 100ms outside both. Sticky
1720 // tooltips are dismissed via Escape / click-outside. The stale
1721 // overlay_id left when that machinery dismisses the overlay is
1722 // reconciled at the top of `process_tooltips_impl`.
1723 }
1724 }
1725
1726 /// Returns the earliest deadline for a pending tooltip or delayed overlay (if any).
1727 pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
1728 let now = std::time::Instant::now();
1729 let session_active = self.tooltip_session_active_real(now);
1730 let tooltip_deadline = self
1731 .tooltips
1732 .iter()
1733 .filter(|entry| entry.overlay_id.is_none())
1734 .filter_map(|entry| {
1735 let start = entry.real_hover_start?;
1736 let delay = self.effective_tooltip_delay(entry.delay, session_active);
1737 Some(start + delay)
1738 })
1739 .min();
1740
1741 // Sticky-on-dwell wake-ups: once a rich tooltip is shown, wake once per
1742 // indicator step (500 ms for the default 2 s promotion) so the step
1743 // indicator advances and the promotion fires, even with the pointer
1744 // held still. Without these deadlines the loop would only wake on user
1745 // input, freezing the dwell counter. The step is derived per entry from
1746 // its own `sticky_after` rather than hardcoded, so a caller with a
1747 // non-default promotion window still gets evenly-spaced wake-ups.
1748 //
1749 // The step boundary is rounded off `last_frame_time` (the last rendered
1750 // frame) — NOT `Instant::now()`. The app's `request_redraw_due`
1751 // re-derives this deadline at each timer wake and only redraws windows
1752 // whose `deadline <= now`. If we rounded off `now`, then at the instant
1753 // a 500 ms boundary's wake fires, `elapsed` has just crossed it and the
1754 // boundary would already have rolled forward to the NEXT step (a future
1755 // instant) — so `deadline <= now` would never hold and the window would
1756 // never redraw. The dwell then only advanced when some unrelated input
1757 // event happened to redraw the window (the "only updates on mouse move"
1758 // bug). Pinning the boundary to `last_frame_time` keeps the deadline
1759 // `<= now` at its own wake until a render actually advances the frame
1760 // time to the next step — one redraw per 500 ms boundary, no free-run.
1761 let ref_time = self.last_frame_time.unwrap_or_else(std::time::Instant::now);
1762 let dwell_tooltip_deadline = self
1763 .tooltips
1764 .iter()
1765 .filter_map(|entry| {
1766 let sticky_after = entry.sticky_after?;
1767 let shown_at = entry.shown_at_real?;
1768 if entry.overlay_id.is_none() || entry.is_sticky {
1769 return None;
1770 }
1771 let dwell_step = sticky_after / super::TOOLTIP_DWELL_STEPS;
1772 if dwell_step.is_zero() {
1773 return None;
1774 }
1775 let elapsed = ref_time.saturating_duration_since(shown_at);
1776 if elapsed >= sticky_after {
1777 return None;
1778 }
1779 // Round up to the next step boundary so each wake-up lands on a
1780 // 500 ms / 1 s / 1.5 s / 2 s mark (measured at the last frame).
1781 let steps_passed = (elapsed.as_millis() / dwell_step.as_millis()) as u32;
1782 let next_step_at = shown_at + dwell_step * (steps_passed + 1);
1783 Some(next_step_at.min(shown_at + sticky_after))
1784 })
1785 .min();
1786 let delayed_overlay_deadline = self
1787 .pending_delayed_overlays
1788 .iter()
1789 .map(|pending| pending.real_requested_at + pending.delay)
1790 .min();
1791 let auto_dismiss_deadline = self.overlay_manager.next_auto_dismiss_deadline();
1792 // Hover-opened overlays (every shown tooltip, hover submenus) dismiss
1793 // on a `PointerLeave { delay }` grace that only advances when a frame
1794 // runs. The pointer's last motion event is the *start* of that grace,
1795 // not a reason to wake at its end — so without this term a tooltip the
1796 // user has walked away from stays on screen for as long as the app
1797 // stays idle.
1798 let pointer_leave_deadline = self.overlay_manager.next_pointer_leave_deadline();
1799 // A fade-out defers the overlay's removal by the tween's duration, and
1800 // that removal is what parks the content. The tween's own scheduler
1801 // deadline usually wakes the loop at the same instant, but only while
1802 // the tween is registered — a fade that is cancelled, completed early
1803 // or never scheduled (reduced motion) leaves nothing else to wake for,
1804 // and the surface stays on screen until unrelated input arrives.
1805 let fade_dismiss_deadline = self.overlay_manager.next_fade_dismiss_deadline();
1806 let animation_deadline = self
1807 .animation_scheduler
1808 .next_deadline(&self.arena, self.paint_epoch);
1809 // Same pattern for the shader-driven animated-quad registry —
1810 // without this the event loop sleeps between frame intervals
1811 // and shader-driven animations only advance on unrelated
1812 // wakes (mouse move, scroll), producing a visible staircase.
1813 let animated_quad_deadline = self
1814 .animated_quads
1815 .next_deadline(&self.arena, self.paint_epoch);
1816 // Every deadline the input layer owns — a pending long press, a
1817 // press-feedback delay, a live fling simulation — folded into the one
1818 // `WaitUntil` over the one clock. Without the fling term a coast would
1819 // only advance on unrelated wakes, which is a list that scrolls when
1820 // the mouse happens to move.
1821 let gesture_deadline = self.next_input_deadline();
1822 let wake_at_deadline = self.pending_wake_at.get();
1823 // Per-frame-effect path (Pulse / Cycle / caret blink / drag
1824 // auto-scroll): a fixed 60 Hz deadline instead of the old
1825 // `ControlFlow::Poll` free-run, so continuous animations render
1826 // at 60 Hz regardless of the display's refresh rate. See
1827 // `frame_tick_deadline`.
1828 let frame_tick_deadline = self.frame_tick_deadline();
1829
1830 [
1831 tooltip_deadline,
1832 dwell_tooltip_deadline,
1833 delayed_overlay_deadline,
1834 auto_dismiss_deadline,
1835 pointer_leave_deadline,
1836 fade_dismiss_deadline,
1837 animation_deadline,
1838 animated_quad_deadline,
1839 gesture_deadline,
1840 wake_at_deadline,
1841 frame_tick_deadline,
1842 ]
1843 .into_iter()
1844 .flatten()
1845 .min()
1846 }
1847
1848 pub fn overlay_manager(&self) -> &crate::overlay::OverlayManager {
1849 &self.overlay_manager
1850 }
1851
1852 /// Mutable access to the overlay manager. Used by the
1853 /// modal-presentation pipeline to wire up cascade-dismissal
1854 /// between paired overlays (e.g. the dialog scrim and the modal
1855 /// panel) via `OverlayManager::set_parent_overlay`.
1856 pub fn overlay_manager_mut(&mut self) -> &mut crate::overlay::OverlayManager {
1857 &mut self.overlay_manager
1858 }
1859
1860 pub fn active_overlays(&self) -> Vec<crate::overlay::OverlayId> {
1861 self.overlay_manager.active_ids()
1862 }
1863
1864 /// Laid-out bounds of an open overlay's content surface.
1865 ///
1866 /// This is the size the overlay pass actually measured — taken with an
1867 /// *unbounded* proposal, independent of the host tree's own proposal — so
1868 /// it is the right thing to assert against for content that must cap or
1869 /// wrap itself (tooltips against `TOOLTIP_MAX_WIDTH`, popovers against
1870 /// their max height). Reading `bounds(content_id)` instead would report
1871 /// whatever the surrounding layout handed the widget.
1872 pub fn overlay_content_bounds(
1873 &self,
1874 id: crate::overlay::OverlayId,
1875 ) -> Option<teksilo_canvas::Rect> {
1876 self.overlay_manager
1877 .stack
1878 .iter()
1879 .find(|o| o.id == id)
1880 .map(|o| o.bounds)
1881 }
1882
1883 pub fn show_overlay(
1884 &mut self,
1885 request: crate::overlay::OverlayRequest,
1886 ) -> crate::overlay::OverlayId {
1887 let fade_duration = request.fade_duration;
1888 let content_id = request.content_id;
1889 let is_modal = matches!(
1890 request.placement,
1891 crate::overlay::OverlayPlacement::Centered
1892 );
1893 let id = self.overlay_manager.show(request);
1894 self.cancel_pointers_for_modal(is_modal);
1895 // The overlay's content subtree just entered the active set;
1896 // the AT tree shape changed and the cached snapshot must be
1897 // rebuilt. The dismiss path already flips this; we must mirror
1898 // it here, otherwise a popup show + read AT sequence returns
1899 // the pre-popup snapshot. The unconditional `a11y_dirty = true`
1900 // in `layout()` previously masked this gap; now this explicit
1901 // set is required.
1902 self.a11y_dirty = true;
1903 if let Some(duration) = fade_duration {
1904 self.attach_overlay_fade(id, content_id, duration);
1905 }
1906 id
1907 }
1908
1909 /// Show an overlay in an explicit z-band.
1910 ///
1911 /// [`show_overlay`](Self::show_overlay) is this with
1912 /// [`OverlayBand::Standard`](crate::overlay::OverlayBand::Standard). The
1913 /// other band is for the touch text affordances — selection handles, the
1914 /// magnifier, the selection toolbar — which must render above the editor's
1915 /// `clips_children` ancestor, below every menu, and outside the
1916 /// outside-press dismissal that every caret-moving tap would otherwise
1917 /// trigger. See [`crate::overlay::text_affordance`].
1918 pub fn show_overlay_in_band(
1919 &mut self,
1920 request: crate::overlay::OverlayRequest,
1921 band: crate::overlay::OverlayBand,
1922 ) -> crate::overlay::OverlayId {
1923 let fade_duration = request.fade_duration;
1924 let content_id = request.content_id;
1925 let is_modal = matches!(
1926 request.placement,
1927 crate::overlay::OverlayPlacement::Centered
1928 );
1929 let id = self.overlay_manager.show_in_band(request, band);
1930 self.cancel_pointers_for_modal(is_modal);
1931 self.a11y_dirty = true;
1932 if let Some(duration) = fade_duration {
1933 self.attach_overlay_fade(id, content_id, duration);
1934 }
1935 id
1936 }
1937
1938 /// Show an overlay relative to a source widget, inheriting the source
1939 /// overlay ancestry and focus-restore behavior used during event dispatch.
1940 pub fn show_overlay_from_source(
1941 &mut self,
1942 source_widget: WidgetId,
1943 mut request: crate::overlay::OverlayRequest,
1944 ) -> crate::overlay::OverlayId {
1945 if request.parent_overlay.is_none() {
1946 request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1947 }
1948 if let Some(existing) = self.overlay_manager.find_by_content(request.content_id) {
1949 return existing;
1950 }
1951
1952 let fade_duration = request.fade_duration;
1953 let content_id = request.content_id;
1954 let is_modal = matches!(
1955 request.placement,
1956 crate::overlay::OverlayPlacement::Centered
1957 );
1958 let current_focus = self.focused;
1959 let id = self.overlay_manager.show(request);
1960 self.cancel_pointers_for_modal(is_modal);
1961 self.a11y_dirty = true;
1962 if let Some(focus_id) = current_focus {
1963 self.overlay_manager.set_top_focus_restore(focus_id);
1964 }
1965 if let Some(duration) = fade_duration {
1966 self.attach_overlay_fade(id, content_id, duration);
1967 }
1968 id
1969 }
1970
1971 pub fn show_overlay_for(
1972 &mut self,
1973 request: crate::overlay::OverlayRequest,
1974 duration: std::time::Duration,
1975 ) -> crate::overlay::OverlayId {
1976 let fade_duration = request.fade_duration;
1977 let content_id = request.content_id;
1978 let is_modal = matches!(
1979 request.placement,
1980 crate::overlay::OverlayPlacement::Centered
1981 );
1982 let id = self.overlay_manager.show_for(request, duration);
1983 self.cancel_pointers_for_modal(is_modal);
1984 self.overlay_manager.set_shown_at_sim(id, self.sim_clock);
1985 self.a11y_dirty = true;
1986 if let Some(fade) = fade_duration {
1987 self.attach_overlay_fade(id, content_id, fade);
1988 }
1989 id
1990 }
1991
1992 /// Internal: install an animated opacity scope on `content_id`,
1993 /// kick off the 0→1 fade-in tween, and register the signal with
1994 /// the overlay manager so the matching fade-out plays on
1995 /// dismiss. Owner of the animated signal is `content_id` itself
1996 /// — the visibility-gate fix from the scheduler ensures the
1997 /// fade-in still ticks even when the content is freshly inserted
1998 /// and not yet stamped with a paint epoch.
1999 fn attach_overlay_fade(
2000 &mut self,
2001 overlay_id: crate::overlay::OverlayId,
2002 content_id: WidgetId,
2003 duration: std::time::Duration,
2004 ) {
2005 let opacity = crate::signal::Signal::<f32>::new_animated(0.0);
2006 self.register_animated_signal(&opacity, content_id);
2007 self.set_opacity(content_id, opacity.clone());
2008 // Audit G16 (WCAG 2.3.3 / EN 301 549 11.7): honour reduced motion —
2009 // snap the overlay to fully visible with no fade-in tween, and register
2010 // a zero-duration fade so dismissal snaps to 0 as well.
2011 if self.prefers_reduced_motion() {
2012 opacity.set(1.0);
2013 self.overlay_manager
2014 .attach_fade(overlay_id, opacity, std::time::Duration::ZERO);
2015 return;
2016 }
2017 let _ = opacity.try_animate_with_options(crate::animation::AnimationRequest {
2018 target: 1.0,
2019 duration,
2020 easing: teksilo_tokens::Easing::EaseOut,
2021 frame_interval: None,
2022 looping: false,
2023 epsilon: 0.0,
2024 max_duration: None,
2025 });
2026 self.overlay_manager
2027 .attach_fade(overlay_id, opacity, duration);
2028 }
2029
2030 /// Dismiss an overlay programmatically. Uses
2031 /// [`NoopWindowOps`](crate::window::NoopWindowOps) for any
2032 /// focus-loss handlers it triggers — user code fires these from
2033 /// outside a dispatch.
2034 pub fn dismiss_overlay(&mut self, id: crate::overlay::OverlayId) {
2035 let mut noop = crate::window::NoopWindowOps;
2036 self.dismiss_overlay_with_ops(id, &mut noop);
2037 }
2038
2039 /// Dispatch-path variant that threads `ops` through to the
2040 /// focus-loss handler fired during dismissal.
2041 pub fn dismiss_overlay_with_ops(
2042 &mut self,
2043 id: crate::overlay::OverlayId,
2044 ops: &mut dyn crate::window::WindowOps,
2045 ) {
2046 let dismissed = self.overlay_manager.dismiss(id);
2047 self.dormant_dismissed_content(&dismissed, &mut *ops);
2048 }
2049
2050 /// A modal just opened. Every live pointer loses its interaction: the
2051 /// surface it was working on is now behind a scrim it cannot reach, and
2052 /// the `Up` that would have completed the press lands on the modal
2053 /// instead.
2054 ///
2055 /// A modal is a `Centered` overlay — the same discriminator
2056 /// [`modal_overlay_for_widget`](Self::modal_overlay_for_widget) uses, so
2057 /// the two cannot drift. Every other placement (a menu, a popover, a
2058 /// tooltip, a drag preview) opens *over* an interaction that legitimately
2059 /// continues, and must not cancel anything.
2060 fn cancel_pointers_for_modal(&mut self, is_modal: bool) {
2061 if !is_modal {
2062 return;
2063 }
2064 let mut noop = crate::window::NoopWindowOps;
2065 self.cancel_all_pointers(crate::pointer::CancelReason::ModalOpened, &mut noop);
2066 }
2067
2068 pub(super) fn dormant_dismissed_content(
2069 &mut self,
2070 content_ids: &[WidgetId],
2071 ops: &mut dyn crate::window::WindowOps,
2072 ) {
2073 // Reset any tooltip entries that match a dismissed content
2074 // id so the next hover starts fresh — without this, sticky
2075 // tooltips dismissed via Escape/click-outside would keep
2076 // their `is_sticky` flag and stale `overlay_id`, and the
2077 // next hover would never re-show them.
2078 let mut any_tooltip_dismissed = false;
2079 for &id in content_ids {
2080 if let Some(entry) = self.tooltips.iter_mut().find(|e| e.content_id == id) {
2081 // A focus-promoted tip is dismissed while the focus that
2082 // summoned it is still in scope, and the restore below hands
2083 // that focus straight back to the anchor — which re-enters
2084 // `tooltip_focus_enter` on an entry whose `overlay_id` this
2085 // very line has just cleared. Mute it until focus genuinely
2086 // leaves, or Escape would close and reopen in one keystroke.
2087 entry.suppressed_until_focus_leaves = entry.promoted_by_focus;
2088 entry.overlay_id = None;
2089 entry.is_sticky = false;
2090 entry.hover_start = None;
2091 entry.real_hover_start = None;
2092 entry.hover_origin = None;
2093 entry.shown_at_sim = None;
2094 entry.shown_at_real = None;
2095 entry.promoted_by_focus = false;
2096 entry.armed_by_focus = false;
2097 entry.armed_by_hold = false;
2098 if let Some(sink) = entry.shown_at_sink.as_ref() {
2099 sink.set(None);
2100 }
2101 any_tooltip_dismissed = true;
2102 }
2103 }
2104 if any_tooltip_dismissed {
2105 let grace = super::TOOLTIP_SESSION_GRACE;
2106 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
2107 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
2108 }
2109 for &id in content_ids {
2110 let focused_in_subtree = self
2111 .focused
2112 .filter(|focused| self.is_descendant_of(*focused, id));
2113 let hovered_in_subtree = self
2114 .hovered_id()
2115 .filter(|hovered| self.is_descendant_of(*hovered, id));
2116
2117 if let Some(focused) = focused_in_subtree {
2118 self.dispatch_to_widget(focused, &WidgetEvent::FocusLost, &mut *ops);
2119 if self
2120 .focused
2121 .is_some_and(|current| self.is_descendant_of(current, id))
2122 {
2123 let old = self.focused;
2124 self.set_focused(None);
2125 self.focus_origin = None;
2126 self.update_focus_within_signals(old, None);
2127 self.update_view_focus_signals(old, None);
2128 }
2129 }
2130
2131 // A pointer anchored inside the overlay about to be parked would
2132 // be stranded on a widget that no longer takes events. Cancel it —
2133 // but only if there is still a press to revoke, which is what lets
2134 // a tap on a menu item whose own handler closes that menu complete
2135 // instead of cancelling itself. See `press_is_revocable`.
2136 self.cancel_pointers_in_subtree(
2137 id,
2138 crate::pointer::CancelReason::OverlayDismissed,
2139 &mut *ops,
2140 );
2141 let _parked = self.arena.set_dormant(id);
2142
2143 if hovered_in_subtree.is_some() {
2144 let old = self.hovered_id();
2145 self.set_hovered(None);
2146 self.update_hover_within_signals(old, None);
2147 }
2148 }
2149 if !content_ids.is_empty() {
2150 self.cached_frame = None;
2151 self.a11y_dirty = true;
2152 }
2153 }
2154
2155 pub fn is_visible(&self, id: WidgetId) -> bool {
2156 self.arena.is_active(id)
2157 }
2158}
2159
2160#[cfg(test)]
2161mod tests {
2162 use super::*;
2163 use crate::test_widgets::FillWidget;
2164
2165 #[test]
2166 fn is_visible_reflects_dormancy() {
2167 let mut tree = WidgetTree::new();
2168 let widget = tree.add(FillWidget::new());
2169 tree.layout(SizeProposal::exact(100.0, 50.0));
2170
2171 assert!(tree.is_visible(widget));
2172 tree.set_dormant(widget);
2173 assert!(!tree.is_visible(widget));
2174 tree.activate(widget);
2175 assert!(tree.is_visible(widget));
2176 }
2177
2178 #[test]
2179 fn frame_tick_deadline_caps_per_frame_effects_at_60hz() {
2180 let mut tree = WidgetTree::new();
2181 // Not armed → no per-frame-effect deadline.
2182 assert!(tree.frame_tick_deadline().is_none());
2183
2184 // Arm the per-frame-effect path (what Pulse / Cycle / caret blink
2185 // / drag auto-scroll do via `request_frame` or the render re-arm).
2186 tree.request_frame();
2187 // Pace from a known frame time so the interval is assertable.
2188 let t0 = std::time::Instant::now();
2189 tree.last_frame_time = Some(t0);
2190
2191 let deadline = tree
2192 .frame_tick_deadline()
2193 .expect("an armed per-frame effect must publish a deadline");
2194 let interval = deadline.saturating_duration_since(t0);
2195 // 60 Hz == 16.667 ms; the cap replaces the old ControlFlow::Poll
2196 // free-run (which rendered at the display's full refresh rate).
2197 assert!(
2198 interval >= std::time::Duration::from_micros(16_000)
2199 && interval <= std::time::Duration::from_micros(17_500),
2200 "per-frame effects must pace at ~60 Hz (got {interval:?})"
2201 );
2202
2203 // It must flow through next_timer_deadline so the event loop uses
2204 // WaitUntil rather than the removed Poll free-run.
2205 assert_eq!(
2206 tree.next_timer_deadline(),
2207 Some(deadline),
2208 "frame-tick deadline must be surfaced by next_timer_deadline"
2209 );
2210 }
2211
2212 #[test]
2213 fn throttled_subscriber_stretches_deadline_but_per_frame_wins_min() {
2214 use crate::test_widgets::FillWidget;
2215 let mut tree = WidgetTree::new();
2216 let w = tree.add(FillWidget::new());
2217 // Cycle-style throttled subscription: wake at most once per 1.5 s.
2218 let _throttled =
2219 tree.subscribe_frame_tick_throttled(w, std::time::Duration::from_millis(1500));
2220 tree.request_frame();
2221 let t0 = std::time::Instant::now();
2222 tree.last_frame_time = Some(t0);
2223
2224 // paint_epoch == 0 (never rendered) → the sentinel treats the
2225 // subscriber as visible, so its throttled interval governs.
2226 let d = tree.frame_tick_deadline().expect("armed");
2227 let dt = d.saturating_duration_since(t0);
2228 assert!(
2229 dt >= std::time::Duration::from_millis(1490)
2230 && dt <= std::time::Duration::from_millis(1510),
2231 "a lone throttled subscriber must pace at its interval (~1.5 s), got {dt:?}"
2232 );
2233
2234 // A per-frame subscriber pulls the *shared* deadline back to 60 Hz:
2235 // the deadline is the minimum interval across visible subscribers,
2236 // so a Cycle sharing a tree with a Pulse rides the Pulse's cadence.
2237 let w2 = tree.add(FillWidget::new());
2238 let _per_frame = tree.subscribe_frame_tick(w2);
2239 let d2 = tree.frame_tick_deadline().expect("armed");
2240 let dt2 = d2.saturating_duration_since(t0);
2241 assert!(
2242 dt2 <= std::time::Duration::from_millis(20),
2243 "a per-frame subscriber must pull the shared deadline to 60 Hz, got {dt2:?}"
2244 );
2245 }
2246
2247 #[test]
2248 fn show_and_dismiss_overlay() {
2249 let mut tree = WidgetTree::new();
2250 let anchor = tree.add(FillWidget::new());
2251 let content = tree.add(FillWidget::new().label("Overlay"));
2252 tree.layout(SizeProposal::exact(200.0, 100.0));
2253
2254 assert!(tree.active_overlays().is_empty());
2255
2256 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2257 content_id: content,
2258 anchor,
2259 placement: crate::overlay::OverlayPlacement::Below,
2260 dismiss: crate::overlay::DismissBehavior::Manual,
2261 layer: crate::overlay::OverlayLayer::InTree,
2262 parent_overlay: None,
2263 on_dismiss: None,
2264 fade_duration: None,
2265 });
2266
2267 assert_eq!(tree.active_overlays().len(), 1);
2268
2269 tree.dismiss_overlay(id);
2270 assert!(tree.active_overlays().is_empty());
2271 assert!(!tree.is_visible(content));
2272 }
2273
2274 /// A modal (a `Centered` overlay) must count as a host surface so a
2275 /// `ComboBox` / menu / popover opened *inside* it — which closes via
2276 /// `dismiss_all_except_hosts` / `dismiss_self_overlay_chain_for_source` —
2277 /// dismisses only its own cascade and never tears down the hosting modal.
2278 #[test]
2279 fn modal_centered_overlay_is_a_host_surface() {
2280 let mut tree = WidgetTree::new();
2281 let anchor = tree.add(FillWidget::new());
2282 let modal_content = tree.add(FillWidget::new().label("Modal"));
2283 let dropdown_content = tree.add(FillWidget::new().label("Dropdown"));
2284 tree.layout(SizeProposal::exact(200.0, 100.0));
2285
2286 let modal = tree.show_overlay(crate::overlay::OverlayRequest {
2287 content_id: modal_content,
2288 anchor,
2289 placement: crate::overlay::OverlayPlacement::Centered,
2290 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2291 layer: crate::overlay::OverlayLayer::InTree,
2292 parent_overlay: None,
2293 on_dismiss: None,
2294 fade_duration: None,
2295 });
2296 let dropdown = tree.show_overlay(crate::overlay::OverlayRequest {
2297 content_id: dropdown_content,
2298 anchor,
2299 placement: crate::overlay::OverlayPlacement::Below,
2300 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2301 layer: crate::overlay::OverlayLayer::InTree,
2302 parent_overlay: Some(modal),
2303 on_dismiss: None,
2304 fade_duration: None,
2305 });
2306
2307 assert!(
2308 tree.overlay_is_host_surface(modal),
2309 "a Centered modal overlay must be treated as a host surface"
2310 );
2311 assert!(
2312 !tree.overlay_is_host_surface(dropdown),
2313 "a plain dropdown overlay is not a host surface"
2314 );
2315 }
2316
2317 #[test]
2318 fn escape_dismisses_topmost_overlay() {
2319 let mut tree = WidgetTree::new();
2320 let anchor = tree.add(FillWidget::new().focusable());
2321 let content = tree.add(FillWidget::new());
2322 tree.layout(SizeProposal::exact(200.0, 100.0));
2323 tree.focus(anchor);
2324
2325 tree.show_overlay(crate::overlay::OverlayRequest {
2326 content_id: content,
2327 anchor,
2328 placement: crate::overlay::OverlayPlacement::Below,
2329 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2330 layer: crate::overlay::OverlayLayer::InTree,
2331 parent_overlay: None,
2332 on_dismiss: None,
2333 fade_duration: None,
2334 });
2335
2336 assert_eq!(tree.active_overlays().len(), 1);
2337
2338 tree.press_key(Key::Escape, Modifiers::NONE);
2339 assert!(tree.active_overlays().is_empty());
2340 assert!(!tree.is_visible(content));
2341 }
2342
2343 /// Build two stacked overlays (a submenu over its parent menu) so the
2344 /// nested-overlay "back" key path (`overlay_manager.len() > 1`) is live.
2345 fn show_two_nested_overlays(tree: &mut WidgetTree) -> (WidgetId, WidgetId) {
2346 let anchor = tree.add(FillWidget::new());
2347 let c1 = tree.add(FillWidget::new());
2348 let c2 = tree.add(FillWidget::new());
2349 tree.layout(SizeProposal::exact(200.0, 100.0));
2350 let o1 = tree.show_overlay(crate::overlay::OverlayRequest {
2351 content_id: c1,
2352 anchor,
2353 placement: crate::overlay::OverlayPlacement::Below,
2354 dismiss: crate::overlay::DismissBehavior::Manual,
2355 layer: crate::overlay::OverlayLayer::InTree,
2356 parent_overlay: None,
2357 on_dismiss: None,
2358 fade_duration: None,
2359 });
2360 tree.show_overlay(crate::overlay::OverlayRequest {
2361 content_id: c2,
2362 anchor: c1,
2363 placement: crate::overlay::OverlayPlacement::Below,
2364 dismiss: crate::overlay::DismissBehavior::Manual,
2365 layer: crate::overlay::OverlayLayer::InTree,
2366 parent_overlay: Some(o1),
2367 on_dismiss: None,
2368 fade_duration: None,
2369 });
2370 (c1, c2)
2371 }
2372
2373 #[test]
2374 fn nested_overlay_back_key_dismisses_with_arrow_left_under_ltr() {
2375 let mut tree = WidgetTree::new();
2376 let _ = show_two_nested_overlays(&mut tree);
2377 assert_eq!(tree.active_overlays().len(), 2);
2378
2379 // Wrong-direction arrow under LTR leaves both overlays open.
2380 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2381 assert_eq!(tree.active_overlays().len(), 2);
2382
2383 // ArrowLeft (inline-start under LTR) closes the top nested overlay.
2384 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2385 assert_eq!(tree.active_overlays().len(), 1);
2386 }
2387
2388 #[test]
2389 fn nested_overlay_back_key_flips_to_arrow_right_under_rtl() {
2390 let mut tree = WidgetTree::new();
2391 tree.set_layout_direction(crate::environment::LayoutDirection::RightToLeft);
2392 let _ = show_two_nested_overlays(&mut tree);
2393 assert_eq!(tree.active_overlays().len(), 2);
2394
2395 // Under RTL, ArrowLeft navigates *into* a submenu — it must NOT
2396 // dismiss the top overlay.
2397 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2398 assert_eq!(tree.active_overlays().len(), 2);
2399
2400 // ArrowRight is the inline-start ("back toward parent") key in RTL.
2401 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2402 assert_eq!(tree.active_overlays().len(), 1);
2403 }
2404
2405 /// The back key navigates *menu* cascades only — it must never close a
2406 /// dialog/alert/modal on top. A modal is a scrim+panel overlay pair, so two
2407 /// stacked modals put two (non-host) scrims in the stack, which inflates the
2408 /// "nested menu" count; guard on the *topmost* overlay being back-navigable.
2409 #[test]
2410 fn back_key_does_not_dismiss_a_dialog_on_top_of_a_modal() {
2411 let mut tree = WidgetTree::new();
2412 let anchor = tree.add(FillWidget::new());
2413 let scrim1 = tree.add(FillWidget::new());
2414 let scrim2 = tree.add(FillWidget::new());
2415 let dialog = tree.add(FillWidget::new());
2416 tree.layout(SizeProposal::exact(200.0, 100.0));
2417 // Two non-host "scrim" overlays (as the two modals' scrims would be)…
2418 for c in [scrim1, scrim2] {
2419 tree.show_overlay(crate::overlay::OverlayRequest {
2420 content_id: c,
2421 anchor,
2422 placement: crate::overlay::OverlayPlacement::Below,
2423 dismiss: crate::overlay::DismissBehavior::Manual,
2424 layer: crate::overlay::OverlayLayer::InTree,
2425 parent_overlay: None,
2426 on_dismiss: None,
2427 fade_duration: None,
2428 });
2429 }
2430 // …with a Centered (host) dialog panel on top.
2431 tree.show_overlay(crate::overlay::OverlayRequest {
2432 content_id: dialog,
2433 anchor,
2434 placement: crate::overlay::OverlayPlacement::Centered,
2435 dismiss: crate::overlay::DismissBehavior::Manual,
2436 layer: crate::overlay::OverlayLayer::InTree,
2437 parent_overlay: None,
2438 on_dismiss: None,
2439 fade_duration: None,
2440 });
2441 assert_eq!(tree.active_overlays().len(), 3);
2442
2443 // The nested-menu count is 2 (the scrims), but the top is a host dialog,
2444 // so the back key must leave it alone (Escape / its buttons dismiss it).
2445 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2446 assert_eq!(
2447 tree.active_overlays().len(),
2448 3,
2449 "the back key must not close a dialog sitting on top of a modal"
2450 );
2451 }
2452
2453 #[test]
2454 fn escape_does_not_dismiss_manual_overlay() {
2455 let mut tree = WidgetTree::new();
2456 let anchor = tree.add(FillWidget::new().focusable());
2457 let content = tree.add(FillWidget::new());
2458 tree.layout(SizeProposal::exact(200.0, 100.0));
2459 tree.focus(anchor);
2460
2461 tree.show_overlay(crate::overlay::OverlayRequest {
2462 content_id: content,
2463 anchor,
2464 placement: crate::overlay::OverlayPlacement::Below,
2465 dismiss: crate::overlay::DismissBehavior::Manual,
2466 layer: crate::overlay::OverlayLayer::InTree,
2467 parent_overlay: None,
2468 on_dismiss: None,
2469 fade_duration: None,
2470 });
2471
2472 assert_eq!(tree.active_overlays().len(), 1);
2473
2474 tree.press_key(Key::Escape, Modifiers::NONE);
2475 // Manual overlays should NOT be dismissed by Escape
2476 assert_eq!(tree.active_overlays().len(), 1);
2477 }
2478
2479 #[test]
2480 fn click_outside_dismisses_overlay() {
2481 let mut tree = WidgetTree::new();
2482 let anchor = tree.add(FillWidget::new());
2483 let content = tree.add(FillWidget::new());
2484 tree.layout(SizeProposal::exact(200.0, 100.0));
2485
2486 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2487 content_id: content,
2488 anchor,
2489 placement: crate::overlay::OverlayPlacement::Below,
2490 dismiss: crate::overlay::DismissBehavior::ClickOutside,
2491 layer: crate::overlay::OverlayLayer::InTree,
2492 parent_overlay: None,
2493 on_dismiss: None,
2494 fade_duration: None,
2495 });
2496
2497 tree.overlay_manager
2498 .set_content_bounds(overlay, teksilo_canvas::Size::new(100.0, 50.0));
2499
2500 assert_eq!(tree.active_overlays().len(), 1);
2501
2502 tree.dispatch_event(WidgetEvent::pointer_down(
2503 Point::new(500.0, 500.0),
2504 PointerButton::Primary,
2505 Modifiers::NONE,
2506 ));
2507 assert!(tree.active_overlays().is_empty());
2508 assert!(!tree.is_visible(content));
2509 }
2510
2511 #[test]
2512 fn cascade_dismissal() {
2513 let mut tree = WidgetTree::new();
2514 let anchor = tree.add(FillWidget::new());
2515 let content_a = tree.add(FillWidget::new());
2516 let content_b = tree.add(FillWidget::new());
2517 tree.layout(SizeProposal::exact(200.0, 100.0));
2518
2519 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2520 content_id: content_a,
2521 anchor,
2522 placement: crate::overlay::OverlayPlacement::Below,
2523 dismiss: crate::overlay::DismissBehavior::Manual,
2524 layer: crate::overlay::OverlayLayer::InTree,
2525 parent_overlay: None,
2526 on_dismiss: None,
2527 fade_duration: None,
2528 });
2529 tree.show_overlay(crate::overlay::OverlayRequest {
2530 content_id: content_b,
2531 anchor: content_a,
2532 placement: crate::overlay::OverlayPlacement::TrailingEdge,
2533 dismiss: crate::overlay::DismissBehavior::Manual,
2534 layer: crate::overlay::OverlayLayer::InTree,
2535 parent_overlay: Some(parent),
2536 on_dismiss: None,
2537 fade_duration: None,
2538 });
2539
2540 assert_eq!(tree.active_overlays().len(), 2);
2541
2542 tree.dismiss_overlay(parent);
2543 assert!(tree.active_overlays().is_empty());
2544 assert!(!tree.is_visible(content_a));
2545 assert!(!tree.is_visible(content_b));
2546 }
2547
2548 #[test]
2549 fn dismissed_overlay_content_is_dormant_and_invisible() {
2550 let mut tree = WidgetTree::new();
2551 let anchor = tree.add(FillWidget::new());
2552 let content = tree.add(FillWidget::new());
2553 tree.layout(SizeProposal::exact(800.0, 600.0));
2554
2555 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2556 content_id: content,
2557 anchor,
2558 placement: crate::overlay::OverlayPlacement::Below,
2559 dismiss: crate::overlay::DismissBehavior::Manual,
2560 layer: crate::overlay::OverlayLayer::InTree,
2561 parent_overlay: None,
2562 on_dismiss: None,
2563 fade_duration: None,
2564 });
2565
2566 tree.layout(SizeProposal::exact(800.0, 600.0));
2567
2568 tree.dismiss_overlay(id);
2569 assert!(!tree.is_visible(content));
2570
2571 tree.layout(SizeProposal::exact(800.0, 600.0));
2572
2573 let center = tree.bounds(content).center();
2574 let hit = tree.hit_test(center);
2575 assert_ne!(hit, Some(content));
2576
2577 let _frame = tree.render();
2578 assert!(!tree.is_visible(content));
2579 }
2580
2581 #[test]
2582 fn tooltip_appears_after_delay() {
2583 let mut tree = WidgetTree::new();
2584 let anchor = tree.add(FillWidget::new());
2585 let tooltip = tree.add(FillWidget::new().label("Tooltip text"));
2586 tree.layout(SizeProposal::exact(200.0, 100.0));
2587
2588 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2589
2590 let center = tree.bounds(anchor).center();
2591 tree.pointer_move(center);
2592 assert!(tree.active_overlays().is_empty());
2593
2594 tree.advance_time(std::time::Duration::from_millis(600));
2595
2596 assert_eq!(tree.active_overlays().len(), 1);
2597 assert!(tree.find_by_label("Tooltip text").is_some());
2598 }
2599
2600 #[test]
2601 fn tooltip_survives_theme_switch() {
2602 // Regression: switching themes used to wipe the tooltip
2603 // registry, so subsequent hovers found nothing to show. Theme
2604 // changes don't rebuild widgets (they only update the theme
2605 // signal), so the registry must be preserved.
2606 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
2607 let anchor = tree.add(FillWidget::new());
2608 let tooltip = tree.add(FillWidget::new().label("Tip"));
2609 tree.layout(SizeProposal::exact(200.0, 100.0));
2610
2611 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2612
2613 tree.set_theme(crate::presets::intui::dark());
2614
2615 tree.pointer_move(tree.bounds(anchor).center());
2616 tree.advance_time(std::time::Duration::from_millis(600));
2617
2618 assert_eq!(tree.active_overlays().len(), 1);
2619 assert!(tree.find_by_label("Tip").is_some());
2620 }
2621
2622 #[test]
2623 fn tooltip_suppressed_when_hovering_anchor_owned_overlay_content() {
2624 // Regression: a tooltip attached to an anchor (a ComboBox, a
2625 // PopoverButton, a menu trigger, …) must not re-trigger while
2626 // the pointer is over content the anchor opened as an overlay.
2627 // Those overlays keep their content as an arena child of the
2628 // anchor (for hit-test / a11y / teardown), so a plain
2629 // descendant walk would treat hovering a dropdown row as
2630 // hovering the anchor's own chrome.
2631 let mut tree = WidgetTree::new();
2632 let anchor = tree.add(FillWidget::new());
2633 // Overlay content + a row inside it, both arena children of the
2634 // anchor — exactly the ComboBox dropdown shape.
2635 let panel = tree.add_child(anchor, FillWidget::new());
2636 let row = tree.add_child(panel, FillWidget::new());
2637 let tip = tree.add(FillWidget::new().label("Tip"));
2638 tree.layout(SizeProposal::exact(200.0, 100.0));
2639
2640 let delay = std::time::Duration::from_millis(100);
2641 tree.attach_tooltip(anchor, tip, delay);
2642
2643 // Sanity: hovering the anchor's own chrome starts the timer and
2644 // the tooltip appears.
2645 tree.tooltip_pointer_enter(anchor);
2646 tree.advance_time(delay + std::time::Duration::from_millis(50));
2647 assert_eq!(
2648 tree.active_overlays().len(),
2649 1,
2650 "tooltip should appear when hovering the anchor itself"
2651 );
2652 // Dismiss the first tooltip before the next scenario: move the pointer
2653 // away and let the 100ms hoverable grace (audit G12) expire.
2654 tree.pointer_move(Point::new(500.0, 500.0));
2655 tree.advance_time(std::time::Duration::from_millis(150));
2656 assert!(tree.active_overlays().is_empty());
2657
2658 // Open the panel as an overlay anchored to the anchor.
2659 tree.show_overlay(crate::overlay::OverlayRequest {
2660 content_id: panel,
2661 anchor,
2662 placement: crate::overlay::OverlayPlacement::Below,
2663 dismiss: crate::overlay::DismissBehavior::Manual,
2664 layer: crate::overlay::OverlayLayer::InTree,
2665 parent_overlay: None,
2666 on_dismiss: None,
2667 fade_duration: None,
2668 });
2669 assert_eq!(tree.active_overlays().len(), 1);
2670
2671 // Hovering a row inside the overlay must NOT start the anchor's
2672 // tooltip: the hover lands on overlay content, not anchor chrome.
2673 tree.tooltip_pointer_enter(row);
2674 tree.advance_time(delay + std::time::Duration::from_millis(50));
2675 assert_eq!(
2676 tree.active_overlays().len(),
2677 1,
2678 "anchor tooltip must not leak onto its own overlay's rows"
2679 );
2680 }
2681
2682 #[test]
2683 fn tooltip_inside_overlay_still_fires() {
2684 // The overlay gate must not over-reach: a tooltip whose anchor
2685 // is *itself* inside the overlay (a dropdown row with its own
2686 // tooltip) still fires when that row is hovered.
2687 let mut tree = WidgetTree::new();
2688 let host = tree.add(FillWidget::new());
2689 let panel = tree.add_child(host, FillWidget::new());
2690 let row = tree.add_child(panel, FillWidget::new());
2691 let tip = tree.add(FillWidget::new().label("Row tip"));
2692 tree.layout(SizeProposal::exact(200.0, 100.0));
2693
2694 let delay = std::time::Duration::from_millis(100);
2695 // Anchor is the row, which lives inside the overlay's content.
2696 tree.attach_tooltip(row, tip, delay);
2697
2698 tree.show_overlay(crate::overlay::OverlayRequest {
2699 content_id: panel,
2700 anchor: host,
2701 placement: crate::overlay::OverlayPlacement::Below,
2702 dismiss: crate::overlay::DismissBehavior::Manual,
2703 layer: crate::overlay::OverlayLayer::InTree,
2704 parent_overlay: None,
2705 on_dismiss: None,
2706 fade_duration: None,
2707 });
2708 assert_eq!(tree.active_overlays().len(), 1);
2709
2710 tree.tooltip_pointer_enter(row);
2711 tree.advance_time(delay + std::time::Duration::from_millis(50));
2712 assert_eq!(
2713 tree.active_overlays().len(),
2714 2,
2715 "a row's own tooltip should still fire inside an overlay"
2716 );
2717 }
2718
2719 #[test]
2720 fn tooltip_dismissed_on_pointer_leave() {
2721 let mut tree = WidgetTree::new();
2722 let anchor = tree.add(FillWidget::new());
2723 let tooltip = tree.add(FillWidget::new().label("Tip"));
2724 tree.layout(SizeProposal::exact(200.0, 100.0));
2725
2726 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2727
2728 tree.pointer_move(tree.bounds(anchor).center());
2729 tree.advance_time(std::time::Duration::from_millis(600));
2730 assert_eq!(tree.active_overlays().len(), 1);
2731
2732 // WCAG 1.4.13 (Hoverable, audit G12): leaving the anchor no longer
2733 // dismisses instantly — the pointer might be heading toward the
2734 // tooltip. The overlay stack's 100ms PointerLeave grace owns dismissal
2735 // once the pointer is outside BOTH the anchor and the tooltip.
2736 tree.pointer_move(Point::new(500.0, 500.0));
2737 assert_eq!(
2738 tree.active_overlays().len(),
2739 1,
2740 "tooltip persists briefly after anchor-leave (hoverable grace)"
2741 );
2742 tree.advance_time(std::time::Duration::from_millis(150));
2743 assert!(
2744 tree.active_overlays().is_empty(),
2745 "tooltip dismissed after the 100ms grace outside anchor+overlay"
2746 );
2747 }
2748
2749 #[test]
2750 fn tooltip_not_shown_if_pointer_leaves_before_delay() {
2751 let mut tree = WidgetTree::new();
2752 let anchor = tree.add(FillWidget::new());
2753 let tooltip = tree.add(FillWidget::new().label("Tip"));
2754 tree.layout(SizeProposal::exact(200.0, 100.0));
2755
2756 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2757
2758 tree.pointer_move(tree.bounds(anchor).center());
2759 tree.advance_time(std::time::Duration::from_millis(200));
2760 tree.pointer_move(Point::new(500.0, 500.0));
2761
2762 tree.advance_time(std::time::Duration::from_millis(500));
2763 assert!(tree.active_overlays().is_empty());
2764 }
2765
2766 #[test]
2767 fn tooltip_reshow_uses_short_delay_after_prior_tip() {
2768 // Windows TTDT_RESHOW: while a tip is open (or just dismissed), the
2769 // next anchor pays the short reshow delay, not the full initial delay.
2770 let mut tree = WidgetTree::new();
2771 let a = tree.add(FillWidget::new());
2772 let b = tree.add(FillWidget::new());
2773 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2774 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2775 tree.layout(SizeProposal::exact(400.0, 200.0));
2776
2777 let delay = std::time::Duration::from_millis(500);
2778 tree.attach_tooltip(a, tip_a, delay);
2779 tree.attach_tooltip(b, tip_b, delay);
2780
2781 // First tip: full initial delay (drive via enter so anchors may
2782 // share layout bounds without hit-test ambiguity).
2783 tree.tooltip_pointer_enter(a);
2784 tree.advance_time(std::time::Duration::from_millis(150));
2785 assert!(
2786 tree.active_overlays().is_empty(),
2787 "must not appear before full initial delay"
2788 );
2789 tree.advance_time(std::time::Duration::from_millis(400));
2790 assert_eq!(
2791 tree.active_overlays().len(),
2792 1,
2793 "first tip after initial delay"
2794 );
2795 assert!(tree.find_by_label("Tip A").is_some());
2796
2797 // While A is still shown the reshow session is active — B uses 100 ms.
2798 let mut noop = crate::window::NoopWindowOps;
2799 tree.tooltip_pointer_leave(a, &mut noop);
2800 tree.tooltip_pointer_enter(b);
2801 tree.advance_time(std::time::Duration::from_millis(120));
2802 assert!(
2803 tree.find_by_label("Tip B").is_some(),
2804 "second tip should use the short reshow delay while session is warm"
2805 );
2806 }
2807
2808 #[test]
2809 fn reshow_delay_reverts_to_full_after_the_session_grace_expires() {
2810 // The reshow session is a *session*: once the last tip has been gone
2811 // for TOOLTIP_SESSION_GRACE, a fresh hover is a new deliberate act and
2812 // pays the full delay again. Regression guard for a grace that is
2813 // never cleared (permanent 100 ms flash on every control) or cleared
2814 // too eagerly (the toolbar sweep loses its snappiness).
2815 let mut tree = WidgetTree::new();
2816 // Reduced motion removes the fade-out, so the dismissal — and with it
2817 // the start of the grace window — lands on the pass that dismisses
2818 // rather than on the one that finishes the tween.
2819 tree.set_accessibility_preferences(false, true, 1.0);
2820 let a = tree.add(FillWidget::new());
2821 let b = tree.add(FillWidget::new());
2822 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2823 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2824 tree.layout(SizeProposal::exact(400.0, 200.0));
2825
2826 let delay = std::time::Duration::from_millis(500);
2827 tree.attach_tooltip(a, tip_a, delay);
2828 tree.attach_tooltip(b, tip_b, delay);
2829
2830 // Warm the session, then close A and let the grace run out.
2831 tree.tooltip_pointer_enter(a);
2832 tree.advance_time(std::time::Duration::from_millis(550));
2833 assert!(tree.find_by_label("Tip A").is_some(), "first tip shown");
2834
2835 tree.pointer_move(Point::new(900.0, 900.0));
2836 tree.advance_time(std::time::Duration::from_millis(150));
2837 assert!(tree.active_overlays().is_empty(), "A dismissed on leave");
2838
2839 // Past the 1 s grace with nothing shown, the session is cold.
2840 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
2841
2842 tree.tooltip_pointer_enter(b);
2843 tree.advance_time(std::time::Duration::from_millis(150));
2844 assert!(
2845 tree.active_overlays().is_empty(),
2846 "session went cold — B must pay the FULL delay, not the 100 ms reshow"
2847 );
2848 tree.advance_time(std::time::Duration::from_millis(400));
2849 assert!(
2850 tree.find_by_label("Tip B").is_some(),
2851 "B still appears once its full delay elapses"
2852 );
2853 }
2854
2855 #[test]
2856 fn warm_reshow_scales_the_delay_rather_than_flattening_every_tier() {
2857 // The reshow shortcut is proportional (Windows TTDT_RESHOW =
2858 // TTDT_INITIAL / 5), not an absolute floor. A *heavy* 700 ms entry
2859 // exists because its content needs a longer statement of intent, so on
2860 // the warm path it must reshow at 140 ms — not collapse to the light
2861 // tier's 100 ms.
2862 let mut tree = WidgetTree::new();
2863 let light = tree.add(FillWidget::new());
2864 let heavy = tree.add(FillWidget::new());
2865 let tip_light = tree.add(FillWidget::new().label("Light"));
2866 let tip_heavy = tree.add(FillWidget::new().label("Heavy"));
2867 tree.layout(SizeProposal::exact(400.0, 200.0));
2868
2869 tree.attach_tooltip(light, tip_light, std::time::Duration::from_millis(500));
2870 tree.attach_tooltip(heavy, tip_heavy, std::time::Duration::from_millis(700));
2871
2872 // Warm the session with the light tip and leave it open.
2873 tree.tooltip_pointer_enter(light);
2874 tree.advance_time(std::time::Duration::from_millis(550));
2875 assert!(tree.find_by_label("Light").is_some(), "session warm");
2876
2877 let mut noop = crate::window::NoopWindowOps;
2878 tree.tooltip_pointer_leave(light, &mut noop);
2879 tree.tooltip_pointer_enter(heavy);
2880
2881 // 120 ms would have been enough under the old flat 100 ms clamp.
2882 tree.advance_time(std::time::Duration::from_millis(120));
2883 assert!(
2884 tree.find_by_label("Heavy").is_none(),
2885 "a heavy tooltip must not fire at the light tier's reshow delay"
2886 );
2887 // 700 * (100/500) = 140 ms.
2888 tree.advance_time(std::time::Duration::from_millis(40));
2889 assert!(
2890 tree.find_by_label("Heavy").is_some(),
2891 "heavy reshow is the scaled 140 ms"
2892 );
2893 }
2894
2895 #[test]
2896 fn a_pinned_sticky_tooltip_does_not_hold_the_session_warm() {
2897 // A sticky tip survives pointer-leave and stays up until Escape or a
2898 // click outside. Counting it as an active session would put every
2899 // other anchor on the 100 ms path for as long as it is pinned.
2900 let mut tree = WidgetTree::new();
2901 tree.set_accessibility_preferences(false, true, 1.0); // no fade deferral
2902 let a = tree.add(FillWidget::new());
2903 let b = tree.add(FillWidget::new());
2904 let tip_a = tree.add(FillWidget::new().label("Pinned"));
2905 let tip_b = tree.add(FillWidget::new().label("Other"));
2906 tree.layout(SizeProposal::exact(400.0, 200.0));
2907
2908 tree.attach_tooltip_with_sticky(
2909 a,
2910 tip_a,
2911 std::time::Duration::from_millis(500),
2912 Some(std::time::Duration::from_millis(100)),
2913 );
2914 tree.attach_tooltip(b, tip_b, std::time::Duration::from_millis(500));
2915
2916 tree.tooltip_pointer_enter(a);
2917 tree.advance_time(std::time::Duration::from_millis(550));
2918 assert!(tree.find_by_label("Pinned").is_some());
2919 tree.promote_tooltip_to_sticky(tip_a);
2920
2921 // Let the dismiss-grace from nothing elapse, then hover B.
2922 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
2923 tree.tooltip_pointer_enter(b);
2924 tree.advance_time(std::time::Duration::from_millis(150));
2925 assert!(
2926 tree.find_by_label("Other").is_none(),
2927 "a pinned sticky must not keep every other anchor on the reshow path"
2928 );
2929 }
2930
2931 #[test]
2932 fn reattaching_a_tooltip_does_not_grow_the_entry_table() {
2933 // `attach_tooltip*` is called from `build()`, so it re-runs on every
2934 // rebuild. An anchor owns at most one tooltip: without retirement the
2935 // table gains a dead row (and an orphaned, parentless content node) per
2936 // rebuild, forever — and it is scanned on every pointer move, four
2937 // times per layout pass, and once per widget in the a11y walk.
2938 let mut tree = WidgetTree::new();
2939 let anchor = tree.add(FillWidget::new());
2940 tree.layout(SizeProposal::exact(200.0, 100.0));
2941
2942 let delay = std::time::Duration::from_millis(100);
2943 for _ in 0..25 {
2944 // Each "rebuild" mints a fresh content widget, as `ctx.add` does.
2945 let tip = tree.add(FillWidget::new().label("Tip"));
2946 tree.attach_tooltip(anchor, tip, delay);
2947 assert_eq!(
2948 tree.tooltip_entry_count(),
2949 1,
2950 "an anchor must own exactly one tooltip entry across rebuilds"
2951 );
2952 }
2953
2954 // The surviving entry is the newest one and still works.
2955 tree.tooltip_pointer_enter(anchor);
2956 tree.advance_time(delay + std::time::Duration::from_millis(50));
2957 assert_eq!(
2958 tree.active_overlays().len(),
2959 1,
2960 "latest tooltip still shows"
2961 );
2962 }
2963
2964 #[test]
2965 fn destroying_an_anchor_reaps_its_tooltip_entry() {
2966 // The content widget is parentless (`ctx.add`), so the anchor's own
2967 // subtree teardown never reaches it.
2968 let mut tree = WidgetTree::new();
2969 let host = tree.add(FillWidget::new());
2970 let anchor = tree.add_child(host, FillWidget::new());
2971 let tip = tree.add(FillWidget::new().label("Tip"));
2972 tree.layout(SizeProposal::exact(200.0, 100.0));
2973
2974 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2975 assert_eq!(tree.tooltip_entry_count(), 1);
2976
2977 tree.destroy_subtree(anchor);
2978 assert_eq!(
2979 tree.tooltip_entry_count(),
2980 0,
2981 "destroying the anchor must reap its entry and content node"
2982 );
2983 }
2984
2985 #[test]
2986 fn a_leaving_tooltip_schedules_a_wake_for_its_dismissal() {
2987 // The pointer's last motion event only *starts* the PointerLeave
2988 // grace. Without a deadline for its end the loop parks in
2989 // `ControlFlow::Wait` and the tooltip hangs on screen until unrelated
2990 // input redraws the window.
2991 let mut tree = WidgetTree::new();
2992 tree.set_accessibility_preferences(false, true, 1.0); // no fade deadline
2993 let anchor = tree.add(FillWidget::new());
2994 let tip = tree.add(FillWidget::new().label("Tip"));
2995 tree.layout(SizeProposal::exact(400.0, 200.0));
2996
2997 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2998 tree.pointer_move(tree.bounds(anchor).center());
2999 tree.advance_time(std::time::Duration::from_millis(150));
3000 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
3001
3002 // Shown, pointer still inside: nothing pending, so no deadline.
3003 assert!(
3004 tree.next_timer_deadline().is_none(),
3005 "a settled tooltip under the pointer schedules nothing"
3006 );
3007
3008 // Pointer leaves — now the 100 ms grace is running and MUST be a wake
3009 // source, or nothing will ever dismiss the tooltip on an idle app.
3010 tree.pointer_move(Point::new(900.0, 900.0));
3011 assert!(
3012 tree.next_timer_deadline().is_some(),
3013 "the PointerLeave grace must contribute a wake deadline"
3014 );
3015 }
3016
3017 /// A fading-out overlay must be a wake source in its own right.
3018 ///
3019 /// Its removal is deferred by the tween's duration and fires from
3020 /// `process_overlay_fade_dismissals_*`, which only runs when something
3021 /// wakes the loop. The tween's own scheduler deadline usually is that
3022 /// something — but the scheduler withholds a deadline for an animation
3023 /// whose owner is not being painted, and an overlay dismissed before it was
3024 /// ever rendered is exactly that. Without a term of its own the surface
3025 /// then stays on the stack, its content held active, until unrelated input
3026 /// happens to redraw the window.
3027 #[test]
3028 fn a_fading_out_overlay_schedules_a_wake_for_its_deferred_removal() {
3029 let mut tree = WidgetTree::new();
3030 let anchor = tree.add(FillWidget::new());
3031 let content = tree.add(FillWidget::new().label("Faded"));
3032 tree.layout(SizeProposal::exact(200.0, 100.0));
3033
3034 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3035 content_id: content,
3036 anchor,
3037 placement: crate::overlay::OverlayPlacement::Below,
3038 dismiss: crate::overlay::DismissBehavior::Manual,
3039 layer: crate::overlay::OverlayLayer::InTree,
3040 parent_overlay: None,
3041 on_dismiss: None,
3042 fade_duration: Some(std::time::Duration::from_millis(200)),
3043 });
3044 tree.dismiss_overlay(id);
3045 assert!(
3046 tree.is_visible(content),
3047 "precondition: the removal is deferred, so there is something to wake for"
3048 );
3049
3050 // `is_some()` would pass on any of the eleven terms the fold carries,
3051 // so assert the *value*: the deadline the loop will sleep to is this
3052 // overlay's, at its fade start plus its duration. The tween's own
3053 // scheduler term is absent here — the content has never been painted,
3054 // which is the case this term exists for.
3055 let expected = tree.overlay_manager.next_fade_dismiss_deadline();
3056 assert!(
3057 expected.is_some(),
3058 "precondition: the fade start was stamped, so there is a deadline to compare against"
3059 );
3060 assert_eq!(
3061 tree.next_timer_deadline(),
3062 expected,
3063 "the wake deadline must be the deferred removal's own, not merely some deadline"
3064 );
3065 }
3066
3067 #[test]
3068 fn pressing_cancels_a_pending_dwell_and_dismisses_a_shown_tooltip() {
3069 let mut tree = WidgetTree::new();
3070 let anchor = tree.add(FillWidget::new());
3071 let tip = tree.add(FillWidget::new().label("Tip"));
3072 tree.layout(SizeProposal::exact(400.0, 200.0));
3073
3074 let delay = std::time::Duration::from_millis(500);
3075 tree.attach_tooltip(anchor, tip, delay);
3076
3077 // Press partway through the dwell: the user has answered their own
3078 // question, so the tip must not arrive afterwards.
3079 tree.pointer_move(tree.bounds(anchor).center());
3080 tree.advance_time(std::time::Duration::from_millis(300));
3081 tree.tooltip_pointer_press(None);
3082 tree.advance_time(std::time::Duration::from_millis(400));
3083 assert!(
3084 tree.active_overlays().is_empty(),
3085 "a press must cancel the pending dwell, not merely delay it"
3086 );
3087
3088 // And a press while one is shown retires it rather than leaving it
3089 // covering the control that was just clicked.
3090 tree.tooltip_pointer_enter(anchor);
3091 tree.advance_time(delay + std::time::Duration::from_millis(50));
3092 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown again");
3093 tree.tooltip_pointer_press(Some(tree.bounds(anchor).center()));
3094 assert!(
3095 tree.active_overlays().is_empty(),
3096 "a press must dismiss the shown tooltip"
3097 );
3098 }
3099
3100 #[test]
3101 fn window_deactivation_retires_hover_tooltips() {
3102 let mut tree = WidgetTree::new();
3103 let anchor = tree.add(FillWidget::new());
3104 let tip = tree.add(FillWidget::new().label("Tip"));
3105 tree.layout(SizeProposal::exact(400.0, 200.0));
3106
3107 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3108 tree.pointer_move(tree.bounds(anchor).center());
3109 tree.advance_time(std::time::Duration::from_millis(150));
3110 assert_eq!(tree.active_overlays().len(), 1);
3111
3112 tree.set_window_active(false);
3113 assert!(
3114 tree.active_overlays().is_empty(),
3115 "a tooltip must not float over another window's chrome"
3116 );
3117 }
3118
3119 #[test]
3120 fn only_the_innermost_anchor_arms_its_dwell() {
3121 // A row inside a panel, both with tooltips. Arming both would mature
3122 // two tips and stack them on top of each other.
3123 let mut tree = WidgetTree::new();
3124 let panel = tree.add(FillWidget::new());
3125 let row = tree.add_child(panel, FillWidget::new());
3126 let panel_tip = tree.add(FillWidget::new().label("Panel"));
3127 let row_tip = tree.add(FillWidget::new().label("Row"));
3128 tree.layout(SizeProposal::exact(400.0, 200.0));
3129
3130 let delay = std::time::Duration::from_millis(100);
3131 tree.attach_tooltip(panel, panel_tip, delay);
3132 tree.attach_tooltip(row, row_tip, delay);
3133
3134 tree.tooltip_pointer_enter(row);
3135 tree.advance_time(delay + std::time::Duration::from_millis(50));
3136
3137 assert_eq!(
3138 tree.active_overlays().len(),
3139 1,
3140 "exactly one tooltip may open for a hover"
3141 );
3142 assert!(
3143 tree.find_by_label("Row").is_some(),
3144 "the innermost anchor wins"
3145 );
3146 }
3147
3148 #[test]
3149 fn escape_dismisses_a_hover_tooltip_and_falls_through_to_the_menu_below() {
3150 // WCAG 2.2 SC 1.4.13(a): hover content must be dismissible without
3151 // moving the pointer. And a tooltip raised over an open menu must not
3152 // swallow the Escape meant for the menu underneath.
3153 let mut tree = WidgetTree::new();
3154 tree.set_accessibility_preferences(false, true, 1.0); // no fade deferral
3155 let anchor = tree.add(FillWidget::new());
3156 let menu = tree.add(FillWidget::new());
3157 let tip = tree.add(FillWidget::new().label("Tip"));
3158 tree.layout(SizeProposal::exact(400.0, 200.0));
3159
3160 let menu_overlay = tree.show_overlay(crate::overlay::OverlayRequest {
3161 content_id: menu,
3162 anchor,
3163 placement: crate::overlay::OverlayPlacement::Below,
3164 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
3165 layer: crate::overlay::OverlayLayer::InTree,
3166 parent_overlay: None,
3167 on_dismiss: None,
3168 fade_duration: None,
3169 });
3170 assert_eq!(tree.active_overlays().len(), 1);
3171
3172 // Raise a tooltip on top of the menu.
3173 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3174 tree.tooltip_pointer_enter(anchor);
3175 tree.advance_time(std::time::Duration::from_millis(150));
3176 assert_eq!(
3177 tree.active_overlays().len(),
3178 2,
3179 "tooltip sits above the menu"
3180 );
3181
3182 // First Escape takes the tooltip...
3183 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3184 assert!(dismissed.is_some(), "Escape must dismiss the hover tooltip");
3185 assert_eq!(tree.active_overlays().len(), 1);
3186
3187 // ...the second reaches the menu, which was previously unreachable.
3188 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3189 assert_eq!(
3190 dismissed.map(|(id, _, _)| id),
3191 Some(menu_overlay),
3192 "Escape must then reach the menu underneath"
3193 );
3194 assert!(tree.active_overlays().is_empty());
3195 }
3196
3197 #[test]
3198 fn tooltip_timer_restarts_when_pointer_keeps_moving() {
3199 // Stationary-pointer filter: travel beyond ~4 px from hover origin
3200 // restarts the delay so a sweeping cursor does not pop tips.
3201 let mut tree = WidgetTree::new();
3202 let anchor = tree.add(FillWidget::new());
3203 let tooltip = tree.add(FillWidget::new().label("Tip"));
3204 tree.layout(SizeProposal::exact(200.0, 100.0));
3205
3206 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3207
3208 let start = tree.bounds(anchor).center();
3209 tree.pointer_move(start);
3210 tree.advance_time(std::time::Duration::from_millis(300));
3211 // Still pending; move well past the 4 px slop inside the same anchor.
3212 tree.pointer_move(Point::new(start.x + 20.0, start.y));
3213 // Another 300 ms would have completed the *original* 500 ms timer,
3214 // but the restart means we need a full 500 ms from the move.
3215 tree.advance_time(std::time::Duration::from_millis(300));
3216 assert!(
3217 tree.active_overlays().is_empty(),
3218 "moving past stationary slop must restart the delay"
3219 );
3220 tree.advance_time(std::time::Duration::from_millis(250));
3221 assert_eq!(
3222 tree.active_overlays().len(),
3223 1,
3224 "tooltip appears after a full delay of stillness"
3225 );
3226 }
3227
3228 #[test]
3229 fn timed_overlay_auto_dismisses_after_duration() {
3230 let mut tree = WidgetTree::new();
3231 let anchor = tree.add(FillWidget::new());
3232 let content = tree.add(FillWidget::new().label("Toast"));
3233 tree.layout(SizeProposal::exact(200.0, 100.0));
3234
3235 tree.show_overlay_for(
3236 crate::overlay::OverlayRequest {
3237 content_id: content,
3238 anchor,
3239 placement: crate::overlay::OverlayPlacement::Below,
3240 dismiss: crate::overlay::DismissBehavior::Manual,
3241 layer: crate::overlay::OverlayLayer::InTree,
3242 parent_overlay: None,
3243 on_dismiss: None,
3244 fade_duration: None,
3245 },
3246 std::time::Duration::from_millis(300),
3247 );
3248
3249 assert_eq!(tree.active_overlays().len(), 1);
3250
3251 tree.advance_time(std::time::Duration::from_millis(200));
3252 assert_eq!(tree.active_overlays().len(), 1);
3253
3254 tree.advance_time(std::time::Duration::from_millis(150));
3255 assert!(tree.active_overlays().is_empty());
3256 assert!(!tree.is_visible(content));
3257 }
3258
3259 #[test]
3260 fn fade_dismiss_keeps_overlay_off_active_list_immediately() {
3261 // The user-facing `active_overlays()` accessor reports a
3262 // dismissing-with-fade overlay as gone the moment dismiss is
3263 // requested — even though the fade-out tween is still
3264 // playing under the hood. Caller code asking "is this
3265 // overlay still up?" gets the expected answer; the framework
3266 // reaps the actual content on the next layout pass past the
3267 // tween deadline.
3268 let mut tree = WidgetTree::new();
3269 let anchor = tree.add(FillWidget::new());
3270 let content = tree.add(FillWidget::new().label("Faded"));
3271 tree.layout(SizeProposal::exact(200.0, 100.0));
3272
3273 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3274 content_id: content,
3275 anchor,
3276 placement: crate::overlay::OverlayPlacement::Below,
3277 dismiss: crate::overlay::DismissBehavior::Manual,
3278 layer: crate::overlay::OverlayLayer::InTree,
3279 parent_overlay: None,
3280 on_dismiss: None,
3281 fade_duration: Some(std::time::Duration::from_millis(100)),
3282 });
3283 assert_eq!(tree.active_overlays().len(), 1);
3284
3285 tree.dismiss_overlay(id);
3286 // Reported as gone immediately, even though the content
3287 // widget is still active and painting the fade-out tween —
3288 // the deferred removal happens later in
3289 // process_overlay_fade_dismissals_real.
3290 assert!(tree.active_overlays().is_empty());
3291 }
3292
3293 #[test]
3294 fn fade_dismiss_defers_content_dormancy_until_sim_tween_completes() {
3295 // Sim-clock variant of the fade-defer contract: dismiss kicks
3296 // off the fade-out tween and stamps both real- and sim-time
3297 // start markers. `advance_time` (sim-clock) past the tween
3298 // duration flushes the deferred removal via
3299 // `process_overlay_fade_dismissals_sim`.
3300 let mut tree = WidgetTree::new();
3301 let anchor = tree.add(FillWidget::new());
3302 let content = tree.add(FillWidget::new().label("Faded"));
3303 tree.layout(SizeProposal::exact(200.0, 100.0));
3304
3305 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3306 content_id: content,
3307 anchor,
3308 placement: crate::overlay::OverlayPlacement::Below,
3309 dismiss: crate::overlay::DismissBehavior::Manual,
3310 layer: crate::overlay::OverlayLayer::InTree,
3311 parent_overlay: None,
3312 on_dismiss: None,
3313 fade_duration: Some(std::time::Duration::from_millis(100)),
3314 });
3315 // Move the simulated clock **before** the dismiss. Without this the
3316 // manager's mirror — seeded with `Instant::now()` at construction —
3317 // happens to agree with the tree's sim clock, and the fade start is
3318 // stamped correctly whether or not `advance_time` ever mirrors it.
3319 // A second of virtual time is what makes the mirror load-bearing.
3320 tree.advance_time(std::time::Duration::from_secs(1));
3321
3322 tree.dismiss_overlay(id);
3323 assert!(
3324 tree.is_visible(content),
3325 "content stays active during fade-out"
3326 );
3327
3328 // Less than the tween: a stale mirror would have stamped the start a
3329 // whole second in the past, and this advance would reap the content
3330 // instead of leaving it up.
3331 tree.advance_time(std::time::Duration::from_millis(60));
3332 assert!(
3333 tree.is_visible(content),
3334 "60 ms into a 100 ms tween the content is still up"
3335 );
3336
3337 tree.advance_time(std::time::Duration::from_millis(90));
3338 assert!(
3339 !tree.is_visible(content),
3340 "after sim-time past the tween window, deferred removal fires"
3341 );
3342 }
3343
3344 /// The sim-clock mirror is refreshed **before** the dismissing passes run,
3345 /// not after them.
3346 ///
3347 /// The test above pins that `advance_time` mirrors the clock at all; this
3348 /// one pins *where in the call* it does it. An overlay dismissed from
3349 /// inside `advance_time` — by its own auto-dismiss timer — reads the
3350 /// mirror as it stands at that moment. Refresh it after the dismissing
3351 /// passes and the fade start is stamped one whole advance in the past, so
3352 /// a fade longer than nothing is over before it began: the surface is
3353 /// reaped in the same virtual frame that started fading it, and the tween
3354 /// the caller asked for never plays.
3355 #[test]
3356 fn an_auto_dismissed_fade_starts_at_the_instant_the_dismiss_ran() {
3357 let mut tree = WidgetTree::new();
3358 let anchor = tree.add(FillWidget::new());
3359 let content = tree.add(FillWidget::new().label("Toast"));
3360 tree.layout(SizeProposal::exact(200.0, 100.0));
3361
3362 tree.show_overlay_for(
3363 crate::overlay::OverlayRequest {
3364 content_id: content,
3365 anchor,
3366 placement: crate::overlay::OverlayPlacement::Below,
3367 dismiss: crate::overlay::DismissBehavior::Manual,
3368 layer: crate::overlay::OverlayLayer::InTree,
3369 parent_overlay: None,
3370 on_dismiss: None,
3371 fade_duration: Some(std::time::Duration::from_millis(100)),
3372 },
3373 std::time::Duration::from_millis(500),
3374 );
3375 assert_eq!(tree.active_overlays().len(), 1);
3376
3377 // One advance, well past the auto-dismiss deadline: the auto-dismiss
3378 // pass fires the dismiss, and the fade pass right after it must find a
3379 // tween that started *this* instant and has 100 ms to run.
3380 tree.advance_time(std::time::Duration::from_millis(600));
3381 assert!(
3382 tree.active_overlays().is_empty(),
3383 "precondition: the auto-dismiss fired inside this advance"
3384 );
3385 assert!(
3386 tree.is_visible(content),
3387 "the fade must start at the instant the dismiss ran, so the \
3388 content survives the frame that dismissed it"
3389 );
3390
3391 tree.advance_time(std::time::Duration::from_millis(150));
3392 assert!(
3393 !tree.is_visible(content),
3394 "and is reaped once the tween's own window has passed"
3395 );
3396 }
3397}