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_press`
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 by the auto-promote sweep in `process_tooltips_impl` once the
1672 /// dwell timer reaches its threshold, and reachable from a widget through
1673 /// `BuildContext::promote_tooltip_to_sticky`.
1674 pub fn promote_tooltip_to_sticky(&mut self, content_id: WidgetId) {
1675 let Some(entry) = self
1676 .tooltips
1677 .iter_mut()
1678 .find(|entry| entry.content_id == content_id)
1679 else {
1680 return;
1681 };
1682 if entry.is_sticky {
1683 return;
1684 }
1685 entry.is_sticky = true;
1686 let overlay_id = entry.overlay_id;
1687 if let Some(overlay_id) = overlay_id {
1688 self.overlay_manager.set_dismiss(
1689 overlay_id,
1690 crate::overlay::DismissBehavior::EscapeOrClickOutside,
1691 );
1692 }
1693 }
1694
1695 pub(super) fn tooltip_pointer_leave(
1696 &mut self,
1697 widget_id: WidgetId,
1698 _ops: &mut dyn crate::window::WindowOps,
1699 ) {
1700 let matching: Vec<usize> = self
1701 .tooltips
1702 .iter()
1703 .enumerate()
1704 .filter(|(_, entry)| self.is_descendant_of(widget_id, entry.anchor_id))
1705 .map(|(index, _)| index)
1706 .collect();
1707 for index in matching {
1708 // Cancel any pending (not-yet-shown) dwell so re-hovering restarts
1709 // the delay timer.
1710 self.tooltips[index].hover_start = None;
1711 self.tooltips[index].real_hover_start = None;
1712 self.tooltips[index].hover_origin = None;
1713 // Audit G12 (WCAG 1.4.13 Hoverable): do NOT dismiss a *shown*
1714 // tooltip here on anchor-leave — that killed it the instant the
1715 // pointer crossed the 8px gap toward the tooltip. Dismissal of a
1716 // shown (non-sticky) tooltip is owned by the overlay stack's
1717 // `PointerLeave { 100ms }` machinery (process_pointer_leave_overlays_real,
1718 // run every frame), whose `pointer_inside_overlay_region` keeps the
1719 // overlay alive while the pointer is over EITHER the anchor or the
1720 // tooltip and dismisses only after 100ms outside both. Sticky
1721 // tooltips are dismissed via Escape / click-outside. The stale
1722 // overlay_id left when that machinery dismisses the overlay is
1723 // reconciled at the top of `process_tooltips_impl`.
1724 }
1725 }
1726
1727 /// Returns the earliest of every deadline the tree owes the event loop —
1728 /// pending tooltips and their dwell steps, delayed overlays, overlay
1729 /// auto-dismiss / pointer-leave / fade removal, animations, animated
1730 /// quads, input gestures, an explicit wake-at and the per-frame tick
1731 /// (if any).
1732 pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
1733 let now = std::time::Instant::now();
1734 let session_active = self.tooltip_session_active_real(now);
1735 let tooltip_deadline = self
1736 .tooltips
1737 .iter()
1738 .filter(|entry| entry.overlay_id.is_none())
1739 .filter_map(|entry| {
1740 let start = entry.real_hover_start?;
1741 let delay = self.effective_tooltip_delay(entry.delay, session_active);
1742 Some(start + delay)
1743 })
1744 .min();
1745
1746 // Sticky-on-dwell wake-ups: once a rich tooltip is shown, wake once per
1747 // indicator step (500 ms for the default 2 s promotion) so the step
1748 // indicator advances and the promotion fires, even with the pointer
1749 // held still. Without these deadlines the loop would only wake on user
1750 // input, freezing the dwell counter. The step is derived per entry from
1751 // its own `sticky_after` rather than hardcoded, so a caller with a
1752 // non-default promotion window still gets evenly-spaced wake-ups.
1753 //
1754 // The step boundary is rounded off `last_frame_time` (the last rendered
1755 // frame) — NOT `Instant::now()`. The app's `request_redraw_due`
1756 // re-derives this deadline at each timer wake and only redraws windows
1757 // whose `deadline <= now`. If we rounded off `now`, then at the instant
1758 // a 500 ms boundary's wake fires, `elapsed` has just crossed it and the
1759 // boundary would already have rolled forward to the NEXT step (a future
1760 // instant) — so `deadline <= now` would never hold and the window would
1761 // never redraw. The dwell then only advanced when some unrelated input
1762 // event happened to redraw the window (the "only updates on mouse move"
1763 // bug). Pinning the boundary to `last_frame_time` keeps the deadline
1764 // `<= now` at its own wake until a render actually advances the frame
1765 // time to the next step — one redraw per 500 ms boundary, no free-run.
1766 let ref_time = self.last_frame_time.unwrap_or_else(std::time::Instant::now);
1767 let dwell_tooltip_deadline = self
1768 .tooltips
1769 .iter()
1770 .filter_map(|entry| {
1771 let sticky_after = entry.sticky_after?;
1772 let shown_at = entry.shown_at_real?;
1773 if entry.overlay_id.is_none() || entry.is_sticky {
1774 return None;
1775 }
1776 let dwell_step = sticky_after / super::TOOLTIP_DWELL_STEPS;
1777 if dwell_step.is_zero() {
1778 return None;
1779 }
1780 let elapsed = ref_time.saturating_duration_since(shown_at);
1781 if elapsed >= sticky_after {
1782 return None;
1783 }
1784 // Round up to the next step boundary so each wake-up lands on a
1785 // 500 ms / 1 s / 1.5 s / 2 s mark (measured at the last frame).
1786 let steps_passed = (elapsed.as_millis() / dwell_step.as_millis()) as u32;
1787 let next_step_at = shown_at + dwell_step * (steps_passed + 1);
1788 Some(next_step_at.min(shown_at + sticky_after))
1789 })
1790 .min();
1791 let delayed_overlay_deadline = self
1792 .pending_delayed_overlays
1793 .iter()
1794 .map(|pending| pending.real_requested_at + pending.delay)
1795 .min();
1796 let auto_dismiss_deadline = self.overlay_manager.next_auto_dismiss_deadline();
1797 // Hover-opened overlays (every shown tooltip, hover submenus) dismiss
1798 // on a `PointerLeave { delay }` grace that only advances when a frame
1799 // runs. The pointer's last motion event is the *start* of that grace,
1800 // not a reason to wake at its end — so without this term a tooltip the
1801 // user has walked away from stays on screen for as long as the app
1802 // stays idle.
1803 let pointer_leave_deadline = self.overlay_manager.next_pointer_leave_deadline();
1804 // A fade-out defers the overlay's removal by the tween's duration, and
1805 // that removal is what parks the content. The tween's own scheduler
1806 // deadline usually wakes the loop at the same instant, but only while
1807 // the tween is registered — a fade that is cancelled, completed early
1808 // or never scheduled (reduced motion) leaves nothing else to wake for,
1809 // and the surface stays on screen until unrelated input arrives.
1810 let fade_dismiss_deadline = self.overlay_manager.next_fade_dismiss_deadline();
1811 let animation_deadline = self
1812 .animation_scheduler
1813 .next_deadline(&self.arena, self.paint_epoch);
1814 // Same pattern for the shader-driven animated-quad registry —
1815 // without this the event loop sleeps between frame intervals
1816 // and shader-driven animations only advance on unrelated
1817 // wakes (mouse move, scroll), producing a visible staircase.
1818 let animated_quad_deadline = self
1819 .animated_quads
1820 .next_deadline(&self.arena, self.paint_epoch);
1821 // Every deadline the input layer owns — a pending long press, a
1822 // press-feedback delay, a live fling simulation — folded into the one
1823 // `WaitUntil` over the one clock. Without the fling term a coast would
1824 // only advance on unrelated wakes, which is a list that scrolls when
1825 // the mouse happens to move.
1826 let gesture_deadline = self.next_input_deadline();
1827 let wake_at_deadline = self.pending_wake_at.get();
1828 // Per-frame-effect path (Pulse / Cycle / caret blink / drag
1829 // auto-scroll): a fixed 60 Hz deadline instead of the old
1830 // `ControlFlow::Poll` free-run, so continuous animations render
1831 // at 60 Hz regardless of the display's refresh rate. See
1832 // `frame_tick_deadline`.
1833 let frame_tick_deadline = self.frame_tick_deadline();
1834
1835 [
1836 tooltip_deadline,
1837 dwell_tooltip_deadline,
1838 delayed_overlay_deadline,
1839 auto_dismiss_deadline,
1840 pointer_leave_deadline,
1841 fade_dismiss_deadline,
1842 animation_deadline,
1843 animated_quad_deadline,
1844 gesture_deadline,
1845 wake_at_deadline,
1846 frame_tick_deadline,
1847 ]
1848 .into_iter()
1849 .flatten()
1850 .min()
1851 }
1852
1853 pub fn overlay_manager(&self) -> &crate::overlay::OverlayManager {
1854 &self.overlay_manager
1855 }
1856
1857 /// Mutable access to the overlay manager. Used by the
1858 /// modal-presentation pipeline to wire up cascade-dismissal
1859 /// between paired overlays (e.g. the dialog scrim and the modal
1860 /// panel) via `OverlayManager::set_parent_overlay`.
1861 pub fn overlay_manager_mut(&mut self) -> &mut crate::overlay::OverlayManager {
1862 &mut self.overlay_manager
1863 }
1864
1865 pub fn active_overlays(&self) -> Vec<crate::overlay::OverlayId> {
1866 self.overlay_manager.active_ids()
1867 }
1868
1869 /// Laid-out bounds of an open overlay's content surface.
1870 ///
1871 /// This is the size the overlay pass actually measured — taken with an
1872 /// *unbounded* proposal, independent of the host tree's own proposal — so
1873 /// it is the right thing to assert against for content that must cap or
1874 /// wrap itself (tooltips against `TOOLTIP_MAX_WIDTH`, popovers against
1875 /// their max height). Reading `bounds(content_id)` instead would report
1876 /// whatever the surrounding layout handed the widget.
1877 pub fn overlay_content_bounds(
1878 &self,
1879 id: crate::overlay::OverlayId,
1880 ) -> Option<teksilo_canvas::Rect> {
1881 self.overlay_manager
1882 .stack
1883 .iter()
1884 .find(|o| o.id == id)
1885 .map(|o| o.bounds)
1886 }
1887
1888 pub fn show_overlay(
1889 &mut self,
1890 request: crate::overlay::OverlayRequest,
1891 ) -> crate::overlay::OverlayId {
1892 let fade_duration = request.fade_duration;
1893 let content_id = request.content_id;
1894 let is_modal = matches!(
1895 request.placement,
1896 crate::overlay::OverlayPlacement::Centered
1897 );
1898 let id = self.overlay_manager.show(request);
1899 self.cancel_pointers_for_modal(is_modal);
1900 // The overlay's content subtree just entered the active set;
1901 // the AT tree shape changed and the cached snapshot must be
1902 // rebuilt. The dismiss path already flips this; we must mirror
1903 // it here, otherwise a popup show + read AT sequence returns
1904 // the pre-popup snapshot. The unconditional `a11y_dirty = true`
1905 // in `layout()` previously masked this gap; now this explicit
1906 // set is required.
1907 self.a11y_dirty = true;
1908 if let Some(duration) = fade_duration {
1909 self.attach_overlay_fade(id, content_id, duration);
1910 }
1911 id
1912 }
1913
1914 /// Show an overlay in an explicit z-band.
1915 ///
1916 /// [`show_overlay`](Self::show_overlay) is this with
1917 /// [`OverlayBand::Standard`](crate::overlay::OverlayBand::Standard). The
1918 /// other band is for the touch text affordances — selection handles, the
1919 /// magnifier, the selection toolbar — which must render above the editor's
1920 /// `clips_children` ancestor, below every menu, and outside the
1921 /// outside-press dismissal that every caret-moving tap would otherwise
1922 /// trigger. See [`crate::overlay::text_affordance`].
1923 pub fn show_overlay_in_band(
1924 &mut self,
1925 request: crate::overlay::OverlayRequest,
1926 band: crate::overlay::OverlayBand,
1927 ) -> crate::overlay::OverlayId {
1928 let fade_duration = request.fade_duration;
1929 let content_id = request.content_id;
1930 let is_modal = matches!(
1931 request.placement,
1932 crate::overlay::OverlayPlacement::Centered
1933 );
1934 let id = self.overlay_manager.show_in_band(request, band);
1935 self.cancel_pointers_for_modal(is_modal);
1936 self.a11y_dirty = true;
1937 if let Some(duration) = fade_duration {
1938 self.attach_overlay_fade(id, content_id, duration);
1939 }
1940 id
1941 }
1942
1943 /// Show an overlay relative to a source widget, inheriting the source
1944 /// overlay ancestry and focus-restore behavior used during event dispatch.
1945 pub fn show_overlay_from_source(
1946 &mut self,
1947 source_widget: WidgetId,
1948 mut request: crate::overlay::OverlayRequest,
1949 ) -> crate::overlay::OverlayId {
1950 if request.parent_overlay.is_none() {
1951 request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1952 }
1953 if let Some(existing) = self.overlay_manager.find_by_content(request.content_id) {
1954 return existing;
1955 }
1956
1957 let fade_duration = request.fade_duration;
1958 let content_id = request.content_id;
1959 let is_modal = matches!(
1960 request.placement,
1961 crate::overlay::OverlayPlacement::Centered
1962 );
1963 let current_focus = self.focused;
1964 let id = self.overlay_manager.show(request);
1965 self.cancel_pointers_for_modal(is_modal);
1966 self.a11y_dirty = true;
1967 if let Some(focus_id) = current_focus {
1968 self.overlay_manager.set_top_focus_restore(focus_id);
1969 }
1970 if let Some(duration) = fade_duration {
1971 self.attach_overlay_fade(id, content_id, duration);
1972 }
1973 id
1974 }
1975
1976 pub fn show_overlay_for(
1977 &mut self,
1978 request: crate::overlay::OverlayRequest,
1979 duration: std::time::Duration,
1980 ) -> crate::overlay::OverlayId {
1981 let fade_duration = request.fade_duration;
1982 let content_id = request.content_id;
1983 let is_modal = matches!(
1984 request.placement,
1985 crate::overlay::OverlayPlacement::Centered
1986 );
1987 let id = self.overlay_manager.show_for(request, duration);
1988 self.cancel_pointers_for_modal(is_modal);
1989 self.overlay_manager.set_shown_at_sim(id, self.sim_clock);
1990 self.a11y_dirty = true;
1991 if let Some(fade) = fade_duration {
1992 self.attach_overlay_fade(id, content_id, fade);
1993 }
1994 id
1995 }
1996
1997 /// Internal: install an animated opacity scope on `content_id`,
1998 /// kick off the 0→1 fade-in tween, and register the signal with
1999 /// the overlay manager so the matching fade-out plays on
2000 /// dismiss. Owner of the animated signal is `content_id` itself
2001 /// — the visibility-gate fix from the scheduler ensures the
2002 /// fade-in still ticks even when the content is freshly inserted
2003 /// and not yet stamped with a paint epoch.
2004 fn attach_overlay_fade(
2005 &mut self,
2006 overlay_id: crate::overlay::OverlayId,
2007 content_id: WidgetId,
2008 duration: std::time::Duration,
2009 ) {
2010 let opacity = crate::signal::Signal::<f32>::new_animated(0.0);
2011 self.register_animated_signal(&opacity, content_id);
2012 self.set_opacity(content_id, opacity.clone());
2013 // Audit G16 (WCAG 2.3.3 / EN 301 549 11.7): honour reduced motion —
2014 // snap the overlay to fully visible with no fade-in tween, and register
2015 // a zero-duration fade so dismissal snaps to 0 as well.
2016 if self.prefers_reduced_motion() {
2017 opacity.set(1.0);
2018 self.overlay_manager
2019 .attach_fade(overlay_id, opacity, std::time::Duration::ZERO);
2020 return;
2021 }
2022 let _ = opacity.try_animate_with_options(crate::animation::AnimationRequest {
2023 target: 1.0,
2024 duration,
2025 easing: teksilo_tokens::Easing::EaseOut,
2026 frame_interval: None,
2027 looping: false,
2028 epsilon: 0.0,
2029 max_duration: None,
2030 });
2031 self.overlay_manager
2032 .attach_fade(overlay_id, opacity, duration);
2033 }
2034
2035 /// Dismiss an overlay programmatically. Uses
2036 /// [`NoopWindowOps`](crate::window::NoopWindowOps) for any
2037 /// focus-loss handlers it triggers — user code fires these from
2038 /// outside a dispatch.
2039 pub fn dismiss_overlay(&mut self, id: crate::overlay::OverlayId) {
2040 let mut noop = crate::window::NoopWindowOps;
2041 self.dismiss_overlay_with_ops(id, &mut noop);
2042 }
2043
2044 /// Dispatch-path variant that threads `ops` through to the
2045 /// focus-loss handler fired during dismissal.
2046 pub fn dismiss_overlay_with_ops(
2047 &mut self,
2048 id: crate::overlay::OverlayId,
2049 ops: &mut dyn crate::window::WindowOps,
2050 ) {
2051 let dismissed = self.overlay_manager.dismiss(id);
2052 self.dormant_dismissed_content(&dismissed, &mut *ops);
2053 }
2054
2055 /// A modal just opened. Every live pointer loses its interaction: the
2056 /// surface it was working on is now behind a scrim it cannot reach, and
2057 /// the `Up` that would have completed the press lands on the modal
2058 /// instead.
2059 ///
2060 /// A modal is a `Centered` overlay — the same discriminator
2061 /// [`modal_overlay_for_widget`](Self::modal_overlay_for_widget) uses, so
2062 /// the two cannot drift. Every other placement (a menu, a popover, a
2063 /// tooltip, a drag preview) opens *over* an interaction that legitimately
2064 /// continues, and must not cancel anything.
2065 fn cancel_pointers_for_modal(&mut self, is_modal: bool) {
2066 if !is_modal {
2067 return;
2068 }
2069 let mut noop = crate::window::NoopWindowOps;
2070 self.cancel_all_pointers(crate::pointer::CancelReason::ModalOpened, &mut noop);
2071 }
2072
2073 /// Run the dismissal callbacks the overlay manager parked.
2074 ///
2075 /// The manager cannot run them itself: they take an `EventContext` and it
2076 /// has no tree. It is called from `dormant_dismissed_content`, which every
2077 /// dismissal path reaches while still holding `ops` and *before* focus
2078 /// returns to the trigger — so `on_dismiss` keeps the ordering its callers
2079 /// document and depend on (`present_in_tree_modal_request` restores the
2080 /// pre-modal `:focus-visible` modality in one, and the trigger must paint
2081 /// with the restored value).
2082 pub(super) fn run_pending_dismiss_callbacks(&mut self, ops: &mut dyn crate::window::WindowOps) {
2083 // A callback is allowed to dismiss another overlay, which parks more
2084 // and re-enters here through that overlay's own teardown. Bail on the
2085 // inner call and let the outer loop collect them: one level deep by
2086 // construction, not by luck.
2087 if self.draining_dismiss.replace(true) {
2088 return;
2089 }
2090 // Bounded rather than `while`: a pair of callbacks that dismiss each
2091 // other's overlay would otherwise spin. Eight is far above any real
2092 // cascade and the excess stays parked rather than being lost.
2093 for _ in 0..8 {
2094 let pending = self.overlay_manager.take_pending_dismiss();
2095 if pending.is_empty() {
2096 break;
2097 }
2098 for (cb, reason) in pending {
2099 self.run_with_event_context(&mut *ops, |ctx| cb(reason, ctx));
2100 }
2101 }
2102 self.draining_dismiss.set(false);
2103 }
2104
2105 pub(super) fn dormant_dismissed_content(
2106 &mut self,
2107 content_ids: &[WidgetId],
2108 ops: &mut dyn crate::window::WindowOps,
2109 ) {
2110 // Before anything is parked: the callback may still want to read the
2111 // content it is being told about, and its documented position is
2112 // during dismissal, ahead of the focus restore below.
2113 self.run_pending_dismiss_callbacks(&mut *ops);
2114 // Reset any tooltip entries that match a dismissed content
2115 // id so the next hover starts fresh — without this, sticky
2116 // tooltips dismissed via Escape/click-outside would keep
2117 // their `is_sticky` flag and stale `overlay_id`, and the
2118 // next hover would never re-show them.
2119 let mut any_tooltip_dismissed = false;
2120 for &id in content_ids {
2121 if let Some(entry) = self.tooltips.iter_mut().find(|e| e.content_id == id) {
2122 // A focus-promoted tip is dismissed while the focus that
2123 // summoned it is still in scope, and the restore below hands
2124 // that focus straight back to the anchor — which re-enters
2125 // `tooltip_focus_enter` on an entry whose `overlay_id` this
2126 // very line has just cleared. Mute it until focus genuinely
2127 // leaves, or Escape would close and reopen in one keystroke.
2128 entry.suppressed_until_focus_leaves = entry.promoted_by_focus;
2129 entry.overlay_id = None;
2130 entry.is_sticky = false;
2131 entry.hover_start = None;
2132 entry.real_hover_start = None;
2133 entry.hover_origin = None;
2134 entry.shown_at_sim = None;
2135 entry.shown_at_real = None;
2136 entry.promoted_by_focus = false;
2137 entry.armed_by_focus = false;
2138 entry.armed_by_hold = false;
2139 if let Some(sink) = entry.shown_at_sink.as_ref() {
2140 sink.set(None);
2141 }
2142 any_tooltip_dismissed = true;
2143 }
2144 }
2145 if any_tooltip_dismissed {
2146 let grace = super::TOOLTIP_SESSION_GRACE;
2147 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
2148 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
2149 }
2150 for &id in content_ids {
2151 let focused_in_subtree = self
2152 .focused
2153 .filter(|focused| self.is_descendant_of(*focused, id));
2154 let hovered_in_subtree = self
2155 .hovered_id()
2156 .filter(|hovered| self.is_descendant_of(*hovered, id));
2157
2158 if let Some(focused) = focused_in_subtree {
2159 self.dispatch_to_widget(focused, &WidgetEvent::FocusLost, &mut *ops);
2160 if self
2161 .focused
2162 .is_some_and(|current| self.is_descendant_of(current, id))
2163 {
2164 let old = self.focused;
2165 self.set_focused(None);
2166 self.focus_origin = None;
2167 self.update_focus_within_signals(old, None);
2168 self.update_view_focus_signals(old, None);
2169 }
2170 }
2171
2172 // A pointer anchored inside the overlay about to be parked would
2173 // be stranded on a widget that no longer takes events. Cancel it —
2174 // but only if there is still a press to revoke, which is what lets
2175 // a tap on a menu item whose own handler closes that menu complete
2176 // instead of cancelling itself. See `press_is_revocable`.
2177 self.cancel_pointers_in_subtree(
2178 id,
2179 crate::pointer::CancelReason::OverlayDismissed,
2180 &mut *ops,
2181 );
2182 let _parked = self.arena.set_dormant(id);
2183
2184 if hovered_in_subtree.is_some() {
2185 let old = self.hovered_id();
2186 self.set_hovered(None);
2187 self.update_hover_within_signals(old, None);
2188 }
2189 }
2190 if !content_ids.is_empty() {
2191 self.cached_frame = None;
2192 self.a11y_dirty = true;
2193 }
2194 }
2195
2196 pub fn is_visible(&self, id: WidgetId) -> bool {
2197 self.arena.is_active(id)
2198 }
2199}
2200
2201#[cfg(test)]
2202mod tests {
2203 use super::*;
2204 use crate::test_widgets::FillWidget;
2205
2206 #[test]
2207 fn is_visible_reflects_dormancy() {
2208 let mut tree = WidgetTree::new();
2209 let widget = tree.add(FillWidget::new());
2210 tree.layout(SizeProposal::exact(100.0, 50.0));
2211
2212 assert!(tree.is_visible(widget));
2213 tree.set_dormant(widget);
2214 assert!(!tree.is_visible(widget));
2215 tree.activate(widget);
2216 assert!(tree.is_visible(widget));
2217 }
2218
2219 #[test]
2220 fn frame_tick_deadline_caps_per_frame_effects_at_60hz() {
2221 let mut tree = WidgetTree::new();
2222 // Not armed → no per-frame-effect deadline.
2223 assert!(tree.frame_tick_deadline().is_none());
2224
2225 // Arm the per-frame-effect path (what Pulse / Cycle / caret blink
2226 // / drag auto-scroll do via `request_frame` or the render re-arm).
2227 tree.request_frame();
2228 // Pace from a known frame time so the interval is assertable.
2229 let t0 = std::time::Instant::now();
2230 tree.last_frame_time = Some(t0);
2231
2232 let deadline = tree
2233 .frame_tick_deadline()
2234 .expect("an armed per-frame effect must publish a deadline");
2235 let interval = deadline.saturating_duration_since(t0);
2236 // 60 Hz == 16.667 ms; the cap replaces the old ControlFlow::Poll
2237 // free-run (which rendered at the display's full refresh rate).
2238 assert!(
2239 interval >= std::time::Duration::from_micros(16_000)
2240 && interval <= std::time::Duration::from_micros(17_500),
2241 "per-frame effects must pace at ~60 Hz (got {interval:?})"
2242 );
2243
2244 // It must flow through next_timer_deadline so the event loop uses
2245 // WaitUntil rather than the removed Poll free-run.
2246 assert_eq!(
2247 tree.next_timer_deadline(),
2248 Some(deadline),
2249 "frame-tick deadline must be surfaced by next_timer_deadline"
2250 );
2251 }
2252
2253 #[test]
2254 fn throttled_subscriber_stretches_deadline_but_per_frame_wins_min() {
2255 use crate::test_widgets::FillWidget;
2256 let mut tree = WidgetTree::new();
2257 let w = tree.add(FillWidget::new());
2258 // Cycle-style throttled subscription: wake at most once per 1.5 s.
2259 let _throttled =
2260 tree.subscribe_frame_tick_throttled(w, std::time::Duration::from_millis(1500));
2261 tree.request_frame();
2262 let t0 = std::time::Instant::now();
2263 tree.last_frame_time = Some(t0);
2264
2265 // paint_epoch == 0 (never rendered) → the sentinel treats the
2266 // subscriber as visible, so its throttled interval governs.
2267 let d = tree.frame_tick_deadline().expect("armed");
2268 let dt = d.saturating_duration_since(t0);
2269 assert!(
2270 dt >= std::time::Duration::from_millis(1490)
2271 && dt <= std::time::Duration::from_millis(1510),
2272 "a lone throttled subscriber must pace at its interval (~1.5 s), got {dt:?}"
2273 );
2274
2275 // A per-frame subscriber pulls the *shared* deadline back to 60 Hz:
2276 // the deadline is the minimum interval across visible subscribers,
2277 // so a Cycle sharing a tree with a Pulse rides the Pulse's cadence.
2278 let w2 = tree.add(FillWidget::new());
2279 let _per_frame = tree.subscribe_frame_tick(w2);
2280 let d2 = tree.frame_tick_deadline().expect("armed");
2281 let dt2 = d2.saturating_duration_since(t0);
2282 assert!(
2283 dt2 <= std::time::Duration::from_millis(20),
2284 "a per-frame subscriber must pull the shared deadline to 60 Hz, got {dt2:?}"
2285 );
2286 }
2287
2288 #[test]
2289 fn show_and_dismiss_overlay() {
2290 let mut tree = WidgetTree::new();
2291 let anchor = tree.add(FillWidget::new());
2292 let content = tree.add(FillWidget::new().label("Overlay"));
2293 tree.layout(SizeProposal::exact(200.0, 100.0));
2294
2295 assert!(tree.active_overlays().is_empty());
2296
2297 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2298 content_id: content,
2299 anchor,
2300 placement: crate::overlay::OverlayPlacement::Below,
2301 dismiss: crate::overlay::DismissBehavior::Manual,
2302 layer: crate::overlay::OverlayLayer::InTree,
2303 parent_overlay: None,
2304 on_dismiss: None,
2305 fade_duration: None,
2306 });
2307
2308 assert_eq!(tree.active_overlays().len(), 1);
2309
2310 tree.dismiss_overlay(id);
2311 assert!(tree.active_overlays().is_empty());
2312 assert!(!tree.is_visible(content));
2313 }
2314
2315 /// A modal (a `Centered` overlay) must count as a host surface so a
2316 /// `ComboBox` / menu / popover opened *inside* it — which closes via
2317 /// `dismiss_all_except_hosts` / `dismiss_self_overlay_chain_for_source` —
2318 /// dismisses only its own cascade and never tears down the hosting modal.
2319 #[test]
2320 fn modal_centered_overlay_is_a_host_surface() {
2321 let mut tree = WidgetTree::new();
2322 let anchor = tree.add(FillWidget::new());
2323 let modal_content = tree.add(FillWidget::new().label("Modal"));
2324 let dropdown_content = tree.add(FillWidget::new().label("Dropdown"));
2325 tree.layout(SizeProposal::exact(200.0, 100.0));
2326
2327 let modal = tree.show_overlay(crate::overlay::OverlayRequest {
2328 content_id: modal_content,
2329 anchor,
2330 placement: crate::overlay::OverlayPlacement::Centered,
2331 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2332 layer: crate::overlay::OverlayLayer::InTree,
2333 parent_overlay: None,
2334 on_dismiss: None,
2335 fade_duration: None,
2336 });
2337 let dropdown = tree.show_overlay(crate::overlay::OverlayRequest {
2338 content_id: dropdown_content,
2339 anchor,
2340 placement: crate::overlay::OverlayPlacement::Below,
2341 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2342 layer: crate::overlay::OverlayLayer::InTree,
2343 parent_overlay: Some(modal),
2344 on_dismiss: None,
2345 fade_duration: None,
2346 });
2347
2348 assert!(
2349 tree.overlay_is_host_surface(modal),
2350 "a Centered modal overlay must be treated as a host surface"
2351 );
2352 assert!(
2353 !tree.overlay_is_host_surface(dropdown),
2354 "a plain dropdown overlay is not a host surface"
2355 );
2356 }
2357
2358 #[test]
2359 fn escape_dismisses_topmost_overlay() {
2360 let mut tree = WidgetTree::new();
2361 let anchor = tree.add(FillWidget::new().focusable());
2362 let content = tree.add(FillWidget::new());
2363 tree.layout(SizeProposal::exact(200.0, 100.0));
2364 tree.focus(anchor);
2365
2366 tree.show_overlay(crate::overlay::OverlayRequest {
2367 content_id: content,
2368 anchor,
2369 placement: crate::overlay::OverlayPlacement::Below,
2370 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2371 layer: crate::overlay::OverlayLayer::InTree,
2372 parent_overlay: None,
2373 on_dismiss: None,
2374 fade_duration: None,
2375 });
2376
2377 assert_eq!(tree.active_overlays().len(), 1);
2378
2379 tree.press_key(Key::Escape, Modifiers::NONE);
2380 assert!(tree.active_overlays().is_empty());
2381 assert!(!tree.is_visible(content));
2382 }
2383
2384 /// Build two stacked overlays (a submenu over its parent menu) so the
2385 /// nested-overlay "back" key path (`overlay_manager.len() > 1`) is live.
2386 fn show_two_nested_overlays(tree: &mut WidgetTree) -> (WidgetId, WidgetId) {
2387 let anchor = tree.add(FillWidget::new());
2388 let c1 = tree.add(FillWidget::new());
2389 let c2 = tree.add(FillWidget::new());
2390 tree.layout(SizeProposal::exact(200.0, 100.0));
2391 let o1 = tree.show_overlay(crate::overlay::OverlayRequest {
2392 content_id: c1,
2393 anchor,
2394 placement: crate::overlay::OverlayPlacement::Below,
2395 dismiss: crate::overlay::DismissBehavior::Manual,
2396 layer: crate::overlay::OverlayLayer::InTree,
2397 parent_overlay: None,
2398 on_dismiss: None,
2399 fade_duration: None,
2400 });
2401 tree.show_overlay(crate::overlay::OverlayRequest {
2402 content_id: c2,
2403 anchor: c1,
2404 placement: crate::overlay::OverlayPlacement::Below,
2405 dismiss: crate::overlay::DismissBehavior::Manual,
2406 layer: crate::overlay::OverlayLayer::InTree,
2407 parent_overlay: Some(o1),
2408 on_dismiss: None,
2409 fade_duration: None,
2410 });
2411 (c1, c2)
2412 }
2413
2414 #[test]
2415 fn nested_overlay_back_key_dismisses_with_arrow_left_under_ltr() {
2416 let mut tree = WidgetTree::new();
2417 let _ = show_two_nested_overlays(&mut tree);
2418 assert_eq!(tree.active_overlays().len(), 2);
2419
2420 // Wrong-direction arrow under LTR leaves both overlays open.
2421 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2422 assert_eq!(tree.active_overlays().len(), 2);
2423
2424 // ArrowLeft (inline-start under LTR) closes the top nested overlay.
2425 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2426 assert_eq!(tree.active_overlays().len(), 1);
2427 }
2428
2429 #[test]
2430 fn nested_overlay_back_key_flips_to_arrow_right_under_rtl() {
2431 let mut tree = WidgetTree::new();
2432 tree.set_layout_direction(crate::environment::LayoutDirection::RightToLeft);
2433 let _ = show_two_nested_overlays(&mut tree);
2434 assert_eq!(tree.active_overlays().len(), 2);
2435
2436 // Under RTL, ArrowLeft navigates *into* a submenu — it must NOT
2437 // dismiss the top overlay.
2438 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2439 assert_eq!(tree.active_overlays().len(), 2);
2440
2441 // ArrowRight is the inline-start ("back toward parent") key in RTL.
2442 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2443 assert_eq!(tree.active_overlays().len(), 1);
2444 }
2445
2446 /// A focusable leaf that records the keys it is handed, so a test can ask
2447 /// the question the writer actually asks: did the keystroke reach me?
2448 #[derive(Debug)]
2449 struct KeyProbe {
2450 seen: std::rc::Rc<std::cell::RefCell<Vec<Key>>>,
2451 }
2452
2453 impl Widget for KeyProbe {
2454 fn layout_response(
2455 &self,
2456 proposal: SizeProposal,
2457 _ctx: &crate::widget::LayoutContext,
2458 ) -> crate::widget::LayoutResponse {
2459 proposal.resolve(0.0, 0.0).into()
2460 }
2461
2462 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2463 let seen = self.seen.clone();
2464 ctx.apply_self_handlers(
2465 crate::widget_builder::HandlerSet::new()
2466 .focusable(true)
2467 .on_key(move |event, _ctx| match event {
2468 WidgetEvent::KeyDown { key, .. } => {
2469 seen.borrow_mut().push(*key);
2470 crate::event::EventResponse::Handled
2471 }
2472 _ => crate::event::EventResponse::Ignored,
2473 }),
2474 );
2475 Vec::new()
2476 }
2477 }
2478
2479 /// ⚠ The regression this exists for. Every mounted text editor keeps one
2480 /// full-viewport affordance host alive in the `TextAffordance` band for its
2481 /// selection handles. Counting those alongside menus made **two editors on
2482 /// one page** — a manuscript column and the synopsis beside it — read as a
2483 /// submenu over its parent menu, so the back key dismissed an affordance
2484 /// host and returned: ArrowLeft stopped reaching any editor in the window,
2485 /// and the writer could no longer step the caret left at all.
2486 ///
2487 /// A text affordance is not a cascade level, which is exactly what its own
2488 /// band says — the same distinction `dismissed_by_outside_press` already
2489 /// makes for presses.
2490 #[test]
2491 fn back_key_ignores_text_affordance_overlays_and_reaches_the_editor() {
2492 let mut tree = WidgetTree::new();
2493 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2494 let editor = tree.add(KeyProbe { seen: seen.clone() });
2495 let handles_a = tree.add(FillWidget::new());
2496 let handles_b = tree.add(FillWidget::new());
2497 tree.layout(SizeProposal::exact(200.0, 100.0));
2498 tree.focus(editor);
2499
2500 // Two editors on one page: one affordance host apiece.
2501 for content in [handles_a, handles_b] {
2502 tree.show_overlay_in_band(
2503 crate::overlay::OverlayRequest {
2504 content_id: content,
2505 anchor: editor,
2506 placement: crate::overlay::OverlayPlacement::FullViewport,
2507 dismiss: crate::overlay::DismissBehavior::Manual,
2508 layer: crate::overlay::OverlayLayer::InTree,
2509 parent_overlay: None,
2510 on_dismiss: None,
2511 fade_duration: None,
2512 },
2513 crate::overlay::OverlayBand::TextAffordance,
2514 );
2515 }
2516 assert_eq!(tree.active_overlays().len(), 2);
2517
2518 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2519
2520 assert_eq!(
2521 tree.active_overlays().len(),
2522 2,
2523 "a text affordance is not a menu cascade level — the back key must \
2524 leave both hosts standing"
2525 );
2526 assert_eq!(
2527 seen.borrow().as_slice(),
2528 &[Key::ArrowLeft],
2529 "ArrowLeft must reach the focused editor; swallowing it is the bug \
2530 that left the caret unable to step left in any dual-pane tab"
2531 );
2532 }
2533
2534 /// The complement: a real menu cascade still navigates back, even while
2535 /// affordance hosts are mounted beneath it. The band filter must narrow the
2536 /// count, not disable the key.
2537 #[test]
2538 fn back_key_still_closes_a_submenu_over_a_mounted_text_affordance() {
2539 let mut tree = WidgetTree::new();
2540 let affordance = tree.add(FillWidget::new());
2541 let anchor = tree.add(FillWidget::new());
2542 let menu = tree.add(FillWidget::new());
2543 let submenu = tree.add(FillWidget::new());
2544 tree.layout(SizeProposal::exact(200.0, 100.0));
2545 tree.show_overlay_in_band(
2546 crate::overlay::OverlayRequest {
2547 content_id: affordance,
2548 anchor,
2549 placement: crate::overlay::OverlayPlacement::FullViewport,
2550 dismiss: crate::overlay::DismissBehavior::Manual,
2551 layer: crate::overlay::OverlayLayer::InTree,
2552 parent_overlay: None,
2553 on_dismiss: None,
2554 fade_duration: None,
2555 },
2556 crate::overlay::OverlayBand::TextAffordance,
2557 );
2558 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2559 content_id: menu,
2560 anchor,
2561 placement: crate::overlay::OverlayPlacement::Below,
2562 dismiss: crate::overlay::DismissBehavior::Manual,
2563 layer: crate::overlay::OverlayLayer::InTree,
2564 parent_overlay: None,
2565 on_dismiss: None,
2566 fade_duration: None,
2567 });
2568 tree.show_overlay(crate::overlay::OverlayRequest {
2569 content_id: submenu,
2570 anchor: menu,
2571 placement: crate::overlay::OverlayPlacement::Below,
2572 dismiss: crate::overlay::DismissBehavior::Manual,
2573 layer: crate::overlay::OverlayLayer::InTree,
2574 parent_overlay: Some(parent),
2575 on_dismiss: None,
2576 fade_duration: None,
2577 });
2578 assert_eq!(tree.active_overlays().len(), 3);
2579
2580 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2581 assert_eq!(
2582 tree.active_overlays().len(),
2583 2,
2584 "the submenu must still close: the affordance host narrows the \
2585 count, it does not disarm the back key"
2586 );
2587 }
2588
2589 /// The back key navigates *menu* cascades only — it must never close a
2590 /// dialog/alert/modal on top. A modal is a scrim+panel overlay pair, so two
2591 /// stacked modals put two (non-host) scrims in the stack, which inflates the
2592 /// "nested menu" count; guard on the *topmost* overlay being back-navigable.
2593 #[test]
2594 fn back_key_does_not_dismiss_a_dialog_on_top_of_a_modal() {
2595 let mut tree = WidgetTree::new();
2596 let anchor = tree.add(FillWidget::new());
2597 let scrim1 = tree.add(FillWidget::new());
2598 let scrim2 = tree.add(FillWidget::new());
2599 let dialog = tree.add(FillWidget::new());
2600 tree.layout(SizeProposal::exact(200.0, 100.0));
2601 // Two non-host "scrim" overlays (as the two modals' scrims would be)…
2602 for c in [scrim1, scrim2] {
2603 tree.show_overlay(crate::overlay::OverlayRequest {
2604 content_id: c,
2605 anchor,
2606 placement: crate::overlay::OverlayPlacement::Below,
2607 dismiss: crate::overlay::DismissBehavior::Manual,
2608 layer: crate::overlay::OverlayLayer::InTree,
2609 parent_overlay: None,
2610 on_dismiss: None,
2611 fade_duration: None,
2612 });
2613 }
2614 // …with a Centered (host) dialog panel on top.
2615 tree.show_overlay(crate::overlay::OverlayRequest {
2616 content_id: dialog,
2617 anchor,
2618 placement: crate::overlay::OverlayPlacement::Centered,
2619 dismiss: crate::overlay::DismissBehavior::Manual,
2620 layer: crate::overlay::OverlayLayer::InTree,
2621 parent_overlay: None,
2622 on_dismiss: None,
2623 fade_duration: None,
2624 });
2625 assert_eq!(tree.active_overlays().len(), 3);
2626
2627 // The nested-menu count is 2 (the scrims), but the top is a host dialog,
2628 // so the back key must leave it alone (Escape / its buttons dismiss it).
2629 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2630 assert_eq!(
2631 tree.active_overlays().len(),
2632 3,
2633 "the back key must not close a dialog sitting on top of a modal"
2634 );
2635 }
2636
2637 #[test]
2638 fn escape_does_not_dismiss_manual_overlay() {
2639 let mut tree = WidgetTree::new();
2640 let anchor = tree.add(FillWidget::new().focusable());
2641 let content = tree.add(FillWidget::new());
2642 tree.layout(SizeProposal::exact(200.0, 100.0));
2643 tree.focus(anchor);
2644
2645 tree.show_overlay(crate::overlay::OverlayRequest {
2646 content_id: content,
2647 anchor,
2648 placement: crate::overlay::OverlayPlacement::Below,
2649 dismiss: crate::overlay::DismissBehavior::Manual,
2650 layer: crate::overlay::OverlayLayer::InTree,
2651 parent_overlay: None,
2652 on_dismiss: None,
2653 fade_duration: None,
2654 });
2655
2656 assert_eq!(tree.active_overlays().len(), 1);
2657
2658 tree.press_key(Key::Escape, Modifiers::NONE);
2659 // Manual overlays should NOT be dismissed by Escape
2660 assert_eq!(tree.active_overlays().len(), 1);
2661 }
2662
2663 #[test]
2664 fn click_outside_dismisses_overlay() {
2665 let mut tree = WidgetTree::new();
2666 let anchor = tree.add(FillWidget::new());
2667 let content = tree.add(FillWidget::new());
2668 tree.layout(SizeProposal::exact(200.0, 100.0));
2669
2670 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2671 content_id: content,
2672 anchor,
2673 placement: crate::overlay::OverlayPlacement::Below,
2674 dismiss: crate::overlay::DismissBehavior::ClickOutside,
2675 layer: crate::overlay::OverlayLayer::InTree,
2676 parent_overlay: None,
2677 on_dismiss: None,
2678 fade_duration: None,
2679 });
2680
2681 tree.overlay_manager
2682 .set_content_bounds(overlay, teksilo_canvas::Size::new(100.0, 50.0));
2683
2684 assert_eq!(tree.active_overlays().len(), 1);
2685
2686 tree.dispatch_event(WidgetEvent::pointer_down(
2687 Point::new(500.0, 500.0),
2688 PointerButton::Primary,
2689 Modifiers::NONE,
2690 ));
2691 assert!(tree.active_overlays().is_empty());
2692 assert!(!tree.is_visible(content));
2693 }
2694
2695 #[test]
2696 fn cascade_dismissal() {
2697 let mut tree = WidgetTree::new();
2698 let anchor = tree.add(FillWidget::new());
2699 let content_a = tree.add(FillWidget::new());
2700 let content_b = tree.add(FillWidget::new());
2701 tree.layout(SizeProposal::exact(200.0, 100.0));
2702
2703 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2704 content_id: content_a,
2705 anchor,
2706 placement: crate::overlay::OverlayPlacement::Below,
2707 dismiss: crate::overlay::DismissBehavior::Manual,
2708 layer: crate::overlay::OverlayLayer::InTree,
2709 parent_overlay: None,
2710 on_dismiss: None,
2711 fade_duration: None,
2712 });
2713 tree.show_overlay(crate::overlay::OverlayRequest {
2714 content_id: content_b,
2715 anchor: content_a,
2716 placement: crate::overlay::OverlayPlacement::TrailingEdge,
2717 dismiss: crate::overlay::DismissBehavior::Manual,
2718 layer: crate::overlay::OverlayLayer::InTree,
2719 parent_overlay: Some(parent),
2720 on_dismiss: None,
2721 fade_duration: None,
2722 });
2723
2724 assert_eq!(tree.active_overlays().len(), 2);
2725
2726 tree.dismiss_overlay(parent);
2727 assert!(tree.active_overlays().is_empty());
2728 assert!(!tree.is_visible(content_a));
2729 assert!(!tree.is_visible(content_b));
2730 }
2731
2732 #[test]
2733 fn dismissed_overlay_content_is_dormant_and_invisible() {
2734 let mut tree = WidgetTree::new();
2735 let anchor = tree.add(FillWidget::new());
2736 let content = tree.add(FillWidget::new());
2737 tree.layout(SizeProposal::exact(800.0, 600.0));
2738
2739 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2740 content_id: content,
2741 anchor,
2742 placement: crate::overlay::OverlayPlacement::Below,
2743 dismiss: crate::overlay::DismissBehavior::Manual,
2744 layer: crate::overlay::OverlayLayer::InTree,
2745 parent_overlay: None,
2746 on_dismiss: None,
2747 fade_duration: None,
2748 });
2749
2750 tree.layout(SizeProposal::exact(800.0, 600.0));
2751
2752 tree.dismiss_overlay(id);
2753 assert!(!tree.is_visible(content));
2754
2755 tree.layout(SizeProposal::exact(800.0, 600.0));
2756
2757 let center = tree.bounds(content).center();
2758 let hit = tree.hit_test(center);
2759 assert_ne!(hit, Some(content));
2760
2761 let _frame = tree.render();
2762 assert!(!tree.is_visible(content));
2763 }
2764
2765 #[test]
2766 fn tooltip_appears_after_delay() {
2767 let mut tree = WidgetTree::new();
2768 let anchor = tree.add(FillWidget::new());
2769 let tooltip = tree.add(FillWidget::new().label("Tooltip text"));
2770 tree.layout(SizeProposal::exact(200.0, 100.0));
2771
2772 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2773
2774 let center = tree.bounds(anchor).center();
2775 tree.pointer_move(center);
2776 assert!(tree.active_overlays().is_empty());
2777
2778 tree.advance_time(std::time::Duration::from_millis(600));
2779
2780 assert_eq!(tree.active_overlays().len(), 1);
2781 assert!(tree.find_by_label("Tooltip text").is_some());
2782 }
2783
2784 #[test]
2785 fn tooltip_survives_theme_switch() {
2786 // Regression: switching themes used to wipe the tooltip
2787 // registry, so subsequent hovers found nothing to show. Theme
2788 // changes don't rebuild widgets (they only update the theme
2789 // signal), so the registry must be preserved.
2790 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
2791 let anchor = tree.add(FillWidget::new());
2792 let tooltip = tree.add(FillWidget::new().label("Tip"));
2793 tree.layout(SizeProposal::exact(200.0, 100.0));
2794
2795 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2796
2797 tree.set_theme(crate::presets::intui::dark());
2798
2799 tree.pointer_move(tree.bounds(anchor).center());
2800 tree.advance_time(std::time::Duration::from_millis(600));
2801
2802 assert_eq!(tree.active_overlays().len(), 1);
2803 assert!(tree.find_by_label("Tip").is_some());
2804 }
2805
2806 #[test]
2807 fn tooltip_suppressed_when_hovering_anchor_owned_overlay_content() {
2808 // Regression: a tooltip attached to an anchor (a ComboBox, a
2809 // PopoverButton, a menu trigger, …) must not re-trigger while
2810 // the pointer is over content the anchor opened as an overlay.
2811 // Those overlays keep their content as an arena child of the
2812 // anchor (for hit-test / a11y / teardown), so a plain
2813 // descendant walk would treat hovering a dropdown row as
2814 // hovering the anchor's own chrome.
2815 let mut tree = WidgetTree::new();
2816 let anchor = tree.add(FillWidget::new());
2817 // Overlay content + a row inside it, both arena children of the
2818 // anchor — exactly the ComboBox dropdown shape.
2819 let panel = tree.add_child(anchor, FillWidget::new());
2820 let row = tree.add_child(panel, FillWidget::new());
2821 let tip = tree.add(FillWidget::new().label("Tip"));
2822 tree.layout(SizeProposal::exact(200.0, 100.0));
2823
2824 let delay = std::time::Duration::from_millis(100);
2825 tree.attach_tooltip(anchor, tip, delay);
2826
2827 // Sanity: hovering the anchor's own chrome starts the timer and
2828 // the tooltip appears.
2829 tree.tooltip_pointer_enter(anchor);
2830 tree.advance_time(delay + std::time::Duration::from_millis(50));
2831 assert_eq!(
2832 tree.active_overlays().len(),
2833 1,
2834 "tooltip should appear when hovering the anchor itself"
2835 );
2836 // Dismiss the first tooltip before the next scenario: move the pointer
2837 // away and let the 100ms hoverable grace (audit G12) expire.
2838 tree.pointer_move(Point::new(500.0, 500.0));
2839 tree.advance_time(std::time::Duration::from_millis(150));
2840 assert!(tree.active_overlays().is_empty());
2841
2842 // Open the panel as an overlay anchored to the anchor.
2843 tree.show_overlay(crate::overlay::OverlayRequest {
2844 content_id: panel,
2845 anchor,
2846 placement: crate::overlay::OverlayPlacement::Below,
2847 dismiss: crate::overlay::DismissBehavior::Manual,
2848 layer: crate::overlay::OverlayLayer::InTree,
2849 parent_overlay: None,
2850 on_dismiss: None,
2851 fade_duration: None,
2852 });
2853 assert_eq!(tree.active_overlays().len(), 1);
2854
2855 // Hovering a row inside the overlay must NOT start the anchor's
2856 // tooltip: the hover lands on overlay content, not anchor chrome.
2857 tree.tooltip_pointer_enter(row);
2858 tree.advance_time(delay + std::time::Duration::from_millis(50));
2859 assert_eq!(
2860 tree.active_overlays().len(),
2861 1,
2862 "anchor tooltip must not leak onto its own overlay's rows"
2863 );
2864 }
2865
2866 #[test]
2867 fn tooltip_inside_overlay_still_fires() {
2868 // The overlay gate must not over-reach: a tooltip whose anchor
2869 // is *itself* inside the overlay (a dropdown row with its own
2870 // tooltip) still fires when that row is hovered.
2871 let mut tree = WidgetTree::new();
2872 let host = tree.add(FillWidget::new());
2873 let panel = tree.add_child(host, FillWidget::new());
2874 let row = tree.add_child(panel, FillWidget::new());
2875 let tip = tree.add(FillWidget::new().label("Row tip"));
2876 tree.layout(SizeProposal::exact(200.0, 100.0));
2877
2878 let delay = std::time::Duration::from_millis(100);
2879 // Anchor is the row, which lives inside the overlay's content.
2880 tree.attach_tooltip(row, tip, delay);
2881
2882 tree.show_overlay(crate::overlay::OverlayRequest {
2883 content_id: panel,
2884 anchor: host,
2885 placement: crate::overlay::OverlayPlacement::Below,
2886 dismiss: crate::overlay::DismissBehavior::Manual,
2887 layer: crate::overlay::OverlayLayer::InTree,
2888 parent_overlay: None,
2889 on_dismiss: None,
2890 fade_duration: None,
2891 });
2892 assert_eq!(tree.active_overlays().len(), 1);
2893
2894 tree.tooltip_pointer_enter(row);
2895 tree.advance_time(delay + std::time::Duration::from_millis(50));
2896 assert_eq!(
2897 tree.active_overlays().len(),
2898 2,
2899 "a row's own tooltip should still fire inside an overlay"
2900 );
2901 }
2902
2903 #[test]
2904 fn tooltip_dismissed_on_pointer_leave() {
2905 let mut tree = WidgetTree::new();
2906 let anchor = tree.add(FillWidget::new());
2907 let tooltip = tree.add(FillWidget::new().label("Tip"));
2908 tree.layout(SizeProposal::exact(200.0, 100.0));
2909
2910 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2911
2912 tree.pointer_move(tree.bounds(anchor).center());
2913 tree.advance_time(std::time::Duration::from_millis(600));
2914 assert_eq!(tree.active_overlays().len(), 1);
2915
2916 // WCAG 1.4.13 (Hoverable, audit G12): leaving the anchor no longer
2917 // dismisses instantly — the pointer might be heading toward the
2918 // tooltip. The overlay stack's 100ms PointerLeave grace owns dismissal
2919 // once the pointer is outside BOTH the anchor and the tooltip.
2920 tree.pointer_move(Point::new(500.0, 500.0));
2921 assert_eq!(
2922 tree.active_overlays().len(),
2923 1,
2924 "tooltip persists briefly after anchor-leave (hoverable grace)"
2925 );
2926 tree.advance_time(std::time::Duration::from_millis(150));
2927 assert!(
2928 tree.active_overlays().is_empty(),
2929 "tooltip dismissed after the 100ms grace outside anchor+overlay"
2930 );
2931 }
2932
2933 #[test]
2934 fn tooltip_not_shown_if_pointer_leaves_before_delay() {
2935 let mut tree = WidgetTree::new();
2936 let anchor = tree.add(FillWidget::new());
2937 let tooltip = tree.add(FillWidget::new().label("Tip"));
2938 tree.layout(SizeProposal::exact(200.0, 100.0));
2939
2940 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2941
2942 tree.pointer_move(tree.bounds(anchor).center());
2943 tree.advance_time(std::time::Duration::from_millis(200));
2944 tree.pointer_move(Point::new(500.0, 500.0));
2945
2946 tree.advance_time(std::time::Duration::from_millis(500));
2947 assert!(tree.active_overlays().is_empty());
2948 }
2949
2950 #[test]
2951 fn tooltip_reshow_uses_short_delay_after_prior_tip() {
2952 // Windows TTDT_RESHOW: while a tip is open (or just dismissed), the
2953 // next anchor pays the short reshow delay, not the full initial delay.
2954 let mut tree = WidgetTree::new();
2955 let a = tree.add(FillWidget::new());
2956 let b = tree.add(FillWidget::new());
2957 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2958 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2959 tree.layout(SizeProposal::exact(400.0, 200.0));
2960
2961 let delay = std::time::Duration::from_millis(500);
2962 tree.attach_tooltip(a, tip_a, delay);
2963 tree.attach_tooltip(b, tip_b, delay);
2964
2965 // First tip: full initial delay (drive via enter so anchors may
2966 // share layout bounds without hit-test ambiguity).
2967 tree.tooltip_pointer_enter(a);
2968 tree.advance_time(std::time::Duration::from_millis(150));
2969 assert!(
2970 tree.active_overlays().is_empty(),
2971 "must not appear before full initial delay"
2972 );
2973 tree.advance_time(std::time::Duration::from_millis(400));
2974 assert_eq!(
2975 tree.active_overlays().len(),
2976 1,
2977 "first tip after initial delay"
2978 );
2979 assert!(tree.find_by_label("Tip A").is_some());
2980
2981 // While A is still shown the reshow session is active — B uses 100 ms.
2982 let mut noop = crate::window::NoopWindowOps;
2983 tree.tooltip_pointer_leave(a, &mut noop);
2984 tree.tooltip_pointer_enter(b);
2985 tree.advance_time(std::time::Duration::from_millis(120));
2986 assert!(
2987 tree.find_by_label("Tip B").is_some(),
2988 "second tip should use the short reshow delay while session is warm"
2989 );
2990 }
2991
2992 #[test]
2993 fn reshow_delay_reverts_to_full_after_the_session_grace_expires() {
2994 // The reshow session is a *session*: once the last tip has been gone
2995 // for TOOLTIP_SESSION_GRACE, a fresh hover is a new deliberate act and
2996 // pays the full delay again. Regression guard for a grace that is
2997 // never cleared (permanent 100 ms flash on every control) or cleared
2998 // too eagerly (the toolbar sweep loses its snappiness).
2999 let mut tree = WidgetTree::new();
3000 // Reduced motion removes the fade-out, so the dismissal — and with it
3001 // the start of the grace window — lands on the pass that dismisses
3002 // rather than on the one that finishes the tween.
3003 tree.set_accessibility_preferences(false, true, 1.0);
3004 let a = tree.add(FillWidget::new());
3005 let b = tree.add(FillWidget::new());
3006 let tip_a = tree.add(FillWidget::new().label("Tip A"));
3007 let tip_b = tree.add(FillWidget::new().label("Tip B"));
3008 tree.layout(SizeProposal::exact(400.0, 200.0));
3009
3010 let delay = std::time::Duration::from_millis(500);
3011 tree.attach_tooltip(a, tip_a, delay);
3012 tree.attach_tooltip(b, tip_b, delay);
3013
3014 // Warm the session, then close A and let the grace run out.
3015 tree.tooltip_pointer_enter(a);
3016 tree.advance_time(std::time::Duration::from_millis(550));
3017 assert!(tree.find_by_label("Tip A").is_some(), "first tip shown");
3018
3019 tree.pointer_move(Point::new(900.0, 900.0));
3020 tree.advance_time(std::time::Duration::from_millis(150));
3021 assert!(tree.active_overlays().is_empty(), "A dismissed on leave");
3022
3023 // Past the 1 s grace with nothing shown, the session is cold.
3024 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
3025
3026 tree.tooltip_pointer_enter(b);
3027 tree.advance_time(std::time::Duration::from_millis(150));
3028 assert!(
3029 tree.active_overlays().is_empty(),
3030 "session went cold — B must pay the FULL delay, not the 100 ms reshow"
3031 );
3032 tree.advance_time(std::time::Duration::from_millis(400));
3033 assert!(
3034 tree.find_by_label("Tip B").is_some(),
3035 "B still appears once its full delay elapses"
3036 );
3037 }
3038
3039 #[test]
3040 fn warm_reshow_scales_the_delay_rather_than_flattening_every_tier() {
3041 // The reshow shortcut is proportional (Windows TTDT_RESHOW =
3042 // TTDT_INITIAL / 5), not an absolute floor. A *heavy* 700 ms entry
3043 // exists because its content needs a longer statement of intent, so on
3044 // the warm path it must reshow at 140 ms — not collapse to the light
3045 // tier's 100 ms.
3046 let mut tree = WidgetTree::new();
3047 let light = tree.add(FillWidget::new());
3048 let heavy = tree.add(FillWidget::new());
3049 let tip_light = tree.add(FillWidget::new().label("Light"));
3050 let tip_heavy = tree.add(FillWidget::new().label("Heavy"));
3051 tree.layout(SizeProposal::exact(400.0, 200.0));
3052
3053 tree.attach_tooltip(light, tip_light, std::time::Duration::from_millis(500));
3054 tree.attach_tooltip(heavy, tip_heavy, std::time::Duration::from_millis(700));
3055
3056 // Warm the session with the light tip and leave it open.
3057 tree.tooltip_pointer_enter(light);
3058 tree.advance_time(std::time::Duration::from_millis(550));
3059 assert!(tree.find_by_label("Light").is_some(), "session warm");
3060
3061 let mut noop = crate::window::NoopWindowOps;
3062 tree.tooltip_pointer_leave(light, &mut noop);
3063 tree.tooltip_pointer_enter(heavy);
3064
3065 // 120 ms would have been enough under the old flat 100 ms clamp.
3066 tree.advance_time(std::time::Duration::from_millis(120));
3067 assert!(
3068 tree.find_by_label("Heavy").is_none(),
3069 "a heavy tooltip must not fire at the light tier's reshow delay"
3070 );
3071 // 700 * (100/500) = 140 ms.
3072 tree.advance_time(std::time::Duration::from_millis(40));
3073 assert!(
3074 tree.find_by_label("Heavy").is_some(),
3075 "heavy reshow is the scaled 140 ms"
3076 );
3077 }
3078
3079 #[test]
3080 fn a_pinned_sticky_tooltip_does_not_hold_the_session_warm() {
3081 // A sticky tip survives pointer-leave and stays up until Escape or a
3082 // click outside. Counting it as an active session would put every
3083 // other anchor on the 100 ms path for as long as it is pinned.
3084 let mut tree = WidgetTree::new();
3085 tree.set_accessibility_preferences(false, true, 1.0); // no fade deferral
3086 let a = tree.add(FillWidget::new());
3087 let b = tree.add(FillWidget::new());
3088 let tip_a = tree.add(FillWidget::new().label("Pinned"));
3089 let tip_b = tree.add(FillWidget::new().label("Other"));
3090 tree.layout(SizeProposal::exact(400.0, 200.0));
3091
3092 tree.attach_tooltip_with_sticky(
3093 a,
3094 tip_a,
3095 std::time::Duration::from_millis(500),
3096 Some(std::time::Duration::from_millis(100)),
3097 );
3098 tree.attach_tooltip(b, tip_b, std::time::Duration::from_millis(500));
3099
3100 tree.tooltip_pointer_enter(a);
3101 tree.advance_time(std::time::Duration::from_millis(550));
3102 assert!(tree.find_by_label("Pinned").is_some());
3103 tree.promote_tooltip_to_sticky(tip_a);
3104
3105 // Let the dismiss-grace from nothing elapse, then hover B.
3106 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
3107 tree.tooltip_pointer_enter(b);
3108 tree.advance_time(std::time::Duration::from_millis(150));
3109 assert!(
3110 tree.find_by_label("Other").is_none(),
3111 "a pinned sticky must not keep every other anchor on the reshow path"
3112 );
3113 }
3114
3115 #[test]
3116 fn reattaching_a_tooltip_does_not_grow_the_entry_table() {
3117 // `attach_tooltip*` is called from `build()`, so it re-runs on every
3118 // rebuild. An anchor owns at most one tooltip: without retirement the
3119 // table gains a dead row (and an orphaned, parentless content node) per
3120 // rebuild, forever — and it is scanned on every pointer move, four
3121 // times per layout pass, and once per widget in the a11y walk.
3122 let mut tree = WidgetTree::new();
3123 let anchor = tree.add(FillWidget::new());
3124 tree.layout(SizeProposal::exact(200.0, 100.0));
3125
3126 let delay = std::time::Duration::from_millis(100);
3127 for _ in 0..25 {
3128 // Each "rebuild" mints a fresh content widget, as `ctx.add` does.
3129 let tip = tree.add(FillWidget::new().label("Tip"));
3130 tree.attach_tooltip(anchor, tip, delay);
3131 assert_eq!(
3132 tree.tooltip_entry_count(),
3133 1,
3134 "an anchor must own exactly one tooltip entry across rebuilds"
3135 );
3136 }
3137
3138 // The surviving entry is the newest one and still works.
3139 tree.tooltip_pointer_enter(anchor);
3140 tree.advance_time(delay + std::time::Duration::from_millis(50));
3141 assert_eq!(
3142 tree.active_overlays().len(),
3143 1,
3144 "latest tooltip still shows"
3145 );
3146 }
3147
3148 #[test]
3149 fn destroying_an_anchor_reaps_its_tooltip_entry() {
3150 // The content widget is parentless (`ctx.add`), so the anchor's own
3151 // subtree teardown never reaches it.
3152 let mut tree = WidgetTree::new();
3153 let host = tree.add(FillWidget::new());
3154 let anchor = tree.add_child(host, FillWidget::new());
3155 let tip = tree.add(FillWidget::new().label("Tip"));
3156 tree.layout(SizeProposal::exact(200.0, 100.0));
3157
3158 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3159 assert_eq!(tree.tooltip_entry_count(), 1);
3160
3161 tree.destroy_subtree(anchor);
3162 assert_eq!(
3163 tree.tooltip_entry_count(),
3164 0,
3165 "destroying the anchor must reap its entry and content node"
3166 );
3167 }
3168
3169 #[test]
3170 fn a_leaving_tooltip_schedules_a_wake_for_its_dismissal() {
3171 // The pointer's last motion event only *starts* the PointerLeave
3172 // grace. Without a deadline for its end the loop parks in
3173 // `ControlFlow::Wait` and the tooltip hangs on screen until unrelated
3174 // input redraws the window.
3175 let mut tree = WidgetTree::new();
3176 tree.set_accessibility_preferences(false, true, 1.0); // no fade deadline
3177 let anchor = tree.add(FillWidget::new());
3178 let tip = tree.add(FillWidget::new().label("Tip"));
3179 tree.layout(SizeProposal::exact(400.0, 200.0));
3180
3181 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3182 tree.pointer_move(tree.bounds(anchor).center());
3183 tree.advance_time(std::time::Duration::from_millis(150));
3184 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
3185
3186 // Shown, pointer still inside: nothing pending, so no deadline.
3187 assert!(
3188 tree.next_timer_deadline().is_none(),
3189 "a settled tooltip under the pointer schedules nothing"
3190 );
3191
3192 // Pointer leaves — now the 100 ms grace is running and MUST be a wake
3193 // source, or nothing will ever dismiss the tooltip on an idle app.
3194 tree.pointer_move(Point::new(900.0, 900.0));
3195 assert!(
3196 tree.next_timer_deadline().is_some(),
3197 "the PointerLeave grace must contribute a wake deadline"
3198 );
3199 }
3200
3201 /// A fading-out overlay must be a wake source in its own right.
3202 ///
3203 /// Its removal is deferred by the tween's duration and fires from
3204 /// `process_overlay_fade_dismissals_*`, which only runs when something
3205 /// wakes the loop. The tween's own scheduler deadline usually is that
3206 /// something — but the scheduler withholds a deadline for an animation
3207 /// whose owner is not being painted, and an overlay dismissed before it was
3208 /// ever rendered is exactly that. Without a term of its own the surface
3209 /// then stays on the stack, its content held active, until unrelated input
3210 /// happens to redraw the window.
3211 #[test]
3212 fn a_fading_out_overlay_schedules_a_wake_for_its_deferred_removal() {
3213 let mut tree = WidgetTree::new();
3214 let anchor = tree.add(FillWidget::new());
3215 let content = tree.add(FillWidget::new().label("Faded"));
3216 tree.layout(SizeProposal::exact(200.0, 100.0));
3217
3218 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3219 content_id: content,
3220 anchor,
3221 placement: crate::overlay::OverlayPlacement::Below,
3222 dismiss: crate::overlay::DismissBehavior::Manual,
3223 layer: crate::overlay::OverlayLayer::InTree,
3224 parent_overlay: None,
3225 on_dismiss: None,
3226 fade_duration: Some(std::time::Duration::from_millis(200)),
3227 });
3228 tree.dismiss_overlay(id);
3229 assert!(
3230 tree.is_visible(content),
3231 "precondition: the removal is deferred, so there is something to wake for"
3232 );
3233
3234 // `is_some()` would pass on any of the eleven terms the fold carries,
3235 // so assert the *value*: the deadline the loop will sleep to is this
3236 // overlay's, at its fade start plus its duration. The tween's own
3237 // scheduler term is absent here — the content has never been painted,
3238 // which is the case this term exists for.
3239 let expected = tree.overlay_manager.next_fade_dismiss_deadline();
3240 assert!(
3241 expected.is_some(),
3242 "precondition: the fade start was stamped, so there is a deadline to compare against"
3243 );
3244 assert_eq!(
3245 tree.next_timer_deadline(),
3246 expected,
3247 "the wake deadline must be the deferred removal's own, not merely some deadline"
3248 );
3249 }
3250
3251 #[test]
3252 fn pressing_cancels_a_pending_dwell_and_dismisses_a_shown_tooltip() {
3253 let mut tree = WidgetTree::new();
3254 let anchor = tree.add(FillWidget::new());
3255 let tip = tree.add(FillWidget::new().label("Tip"));
3256 tree.layout(SizeProposal::exact(400.0, 200.0));
3257
3258 let delay = std::time::Duration::from_millis(500);
3259 tree.attach_tooltip(anchor, tip, delay);
3260
3261 // Press partway through the dwell: the user has answered their own
3262 // question, so the tip must not arrive afterwards.
3263 tree.pointer_move(tree.bounds(anchor).center());
3264 tree.advance_time(std::time::Duration::from_millis(300));
3265 tree.tooltip_pointer_press(None);
3266 tree.advance_time(std::time::Duration::from_millis(400));
3267 assert!(
3268 tree.active_overlays().is_empty(),
3269 "a press must cancel the pending dwell, not merely delay it"
3270 );
3271
3272 // And a press while one is shown retires it rather than leaving it
3273 // covering the control that was just clicked.
3274 tree.tooltip_pointer_enter(anchor);
3275 tree.advance_time(delay + std::time::Duration::from_millis(50));
3276 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown again");
3277 tree.tooltip_pointer_press(Some(tree.bounds(anchor).center()));
3278 assert!(
3279 tree.active_overlays().is_empty(),
3280 "a press must dismiss the shown tooltip"
3281 );
3282 }
3283
3284 #[test]
3285 fn window_deactivation_retires_hover_tooltips() {
3286 let mut tree = WidgetTree::new();
3287 let anchor = tree.add(FillWidget::new());
3288 let tip = tree.add(FillWidget::new().label("Tip"));
3289 tree.layout(SizeProposal::exact(400.0, 200.0));
3290
3291 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3292 tree.pointer_move(tree.bounds(anchor).center());
3293 tree.advance_time(std::time::Duration::from_millis(150));
3294 assert_eq!(tree.active_overlays().len(), 1);
3295
3296 tree.set_window_active(false);
3297 assert!(
3298 tree.active_overlays().is_empty(),
3299 "a tooltip must not float over another window's chrome"
3300 );
3301 }
3302
3303 #[test]
3304 fn only_the_innermost_anchor_arms_its_dwell() {
3305 // A row inside a panel, both with tooltips. Arming both would mature
3306 // two tips and stack them on top of each other.
3307 let mut tree = WidgetTree::new();
3308 let panel = tree.add(FillWidget::new());
3309 let row = tree.add_child(panel, FillWidget::new());
3310 let panel_tip = tree.add(FillWidget::new().label("Panel"));
3311 let row_tip = tree.add(FillWidget::new().label("Row"));
3312 tree.layout(SizeProposal::exact(400.0, 200.0));
3313
3314 let delay = std::time::Duration::from_millis(100);
3315 tree.attach_tooltip(panel, panel_tip, delay);
3316 tree.attach_tooltip(row, row_tip, delay);
3317
3318 tree.tooltip_pointer_enter(row);
3319 tree.advance_time(delay + std::time::Duration::from_millis(50));
3320
3321 assert_eq!(
3322 tree.active_overlays().len(),
3323 1,
3324 "exactly one tooltip may open for a hover"
3325 );
3326 assert!(
3327 tree.find_by_label("Row").is_some(),
3328 "the innermost anchor wins"
3329 );
3330 }
3331
3332 #[test]
3333 fn escape_dismisses_a_hover_tooltip_and_falls_through_to_the_menu_below() {
3334 // WCAG 2.2 SC 1.4.13(a): hover content must be dismissible without
3335 // moving the pointer. And a tooltip raised over an open menu must not
3336 // swallow the Escape meant for the menu underneath.
3337 let mut tree = WidgetTree::new();
3338 tree.set_accessibility_preferences(false, true, 1.0); // no fade deferral
3339 let anchor = tree.add(FillWidget::new());
3340 let menu = tree.add(FillWidget::new());
3341 let tip = tree.add(FillWidget::new().label("Tip"));
3342 tree.layout(SizeProposal::exact(400.0, 200.0));
3343
3344 let menu_overlay = tree.show_overlay(crate::overlay::OverlayRequest {
3345 content_id: menu,
3346 anchor,
3347 placement: crate::overlay::OverlayPlacement::Below,
3348 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
3349 layer: crate::overlay::OverlayLayer::InTree,
3350 parent_overlay: None,
3351 on_dismiss: None,
3352 fade_duration: None,
3353 });
3354 assert_eq!(tree.active_overlays().len(), 1);
3355
3356 // Raise a tooltip on top of the menu.
3357 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3358 tree.tooltip_pointer_enter(anchor);
3359 tree.advance_time(std::time::Duration::from_millis(150));
3360 assert_eq!(
3361 tree.active_overlays().len(),
3362 2,
3363 "tooltip sits above the menu"
3364 );
3365
3366 // First Escape takes the tooltip...
3367 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3368 assert!(dismissed.is_some(), "Escape must dismiss the hover tooltip");
3369 assert_eq!(tree.active_overlays().len(), 1);
3370
3371 // ...the second reaches the menu, which was previously unreachable.
3372 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3373 assert_eq!(
3374 dismissed.map(|(id, _, _)| id),
3375 Some(menu_overlay),
3376 "Escape must then reach the menu underneath"
3377 );
3378 assert!(tree.active_overlays().is_empty());
3379 }
3380
3381 #[test]
3382 fn tooltip_timer_restarts_when_pointer_keeps_moving() {
3383 // Stationary-pointer filter: travel beyond ~4 px from hover origin
3384 // restarts the delay so a sweeping cursor does not pop tips.
3385 let mut tree = WidgetTree::new();
3386 let anchor = tree.add(FillWidget::new());
3387 let tooltip = tree.add(FillWidget::new().label("Tip"));
3388 tree.layout(SizeProposal::exact(200.0, 100.0));
3389
3390 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3391
3392 let start = tree.bounds(anchor).center();
3393 tree.pointer_move(start);
3394 tree.advance_time(std::time::Duration::from_millis(300));
3395 // Still pending; move well past the 4 px slop inside the same anchor.
3396 tree.pointer_move(Point::new(start.x + 20.0, start.y));
3397 // Another 300 ms would have completed the *original* 500 ms timer,
3398 // but the restart means we need a full 500 ms from the move.
3399 tree.advance_time(std::time::Duration::from_millis(300));
3400 assert!(
3401 tree.active_overlays().is_empty(),
3402 "moving past stationary slop must restart the delay"
3403 );
3404 tree.advance_time(std::time::Duration::from_millis(250));
3405 assert_eq!(
3406 tree.active_overlays().len(),
3407 1,
3408 "tooltip appears after a full delay of stillness"
3409 );
3410 }
3411
3412 #[test]
3413 fn timed_overlay_auto_dismisses_after_duration() {
3414 let mut tree = WidgetTree::new();
3415 let anchor = tree.add(FillWidget::new());
3416 let content = tree.add(FillWidget::new().label("Toast"));
3417 tree.layout(SizeProposal::exact(200.0, 100.0));
3418
3419 tree.show_overlay_for(
3420 crate::overlay::OverlayRequest {
3421 content_id: content,
3422 anchor,
3423 placement: crate::overlay::OverlayPlacement::Below,
3424 dismiss: crate::overlay::DismissBehavior::Manual,
3425 layer: crate::overlay::OverlayLayer::InTree,
3426 parent_overlay: None,
3427 on_dismiss: None,
3428 fade_duration: None,
3429 },
3430 std::time::Duration::from_millis(300),
3431 );
3432
3433 assert_eq!(tree.active_overlays().len(), 1);
3434
3435 tree.advance_time(std::time::Duration::from_millis(200));
3436 assert_eq!(tree.active_overlays().len(), 1);
3437
3438 tree.advance_time(std::time::Duration::from_millis(150));
3439 assert!(tree.active_overlays().is_empty());
3440 assert!(!tree.is_visible(content));
3441 }
3442
3443 #[test]
3444 fn fade_dismiss_keeps_overlay_off_active_list_immediately() {
3445 // The user-facing `active_overlays()` accessor reports a
3446 // dismissing-with-fade overlay as gone the moment dismiss is
3447 // requested — even though the fade-out tween is still
3448 // playing under the hood. Caller code asking "is this
3449 // overlay still up?" gets the expected answer; the framework
3450 // reaps the actual content on the next layout pass past the
3451 // tween deadline.
3452 let mut tree = WidgetTree::new();
3453 let anchor = tree.add(FillWidget::new());
3454 let content = tree.add(FillWidget::new().label("Faded"));
3455 tree.layout(SizeProposal::exact(200.0, 100.0));
3456
3457 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3458 content_id: content,
3459 anchor,
3460 placement: crate::overlay::OverlayPlacement::Below,
3461 dismiss: crate::overlay::DismissBehavior::Manual,
3462 layer: crate::overlay::OverlayLayer::InTree,
3463 parent_overlay: None,
3464 on_dismiss: None,
3465 fade_duration: Some(std::time::Duration::from_millis(100)),
3466 });
3467 assert_eq!(tree.active_overlays().len(), 1);
3468
3469 tree.dismiss_overlay(id);
3470 // Reported as gone immediately, even though the content
3471 // widget is still active and painting the fade-out tween —
3472 // the deferred removal happens later in
3473 // process_overlay_fade_dismissals_real.
3474 assert!(tree.active_overlays().is_empty());
3475 }
3476
3477 #[test]
3478 fn fade_dismiss_defers_content_dormancy_until_sim_tween_completes() {
3479 // Sim-clock variant of the fade-defer contract: dismiss kicks
3480 // off the fade-out tween and stamps both real- and sim-time
3481 // start markers. `advance_time` (sim-clock) past the tween
3482 // duration flushes the deferred removal via
3483 // `process_overlay_fade_dismissals_sim`.
3484 let mut tree = WidgetTree::new();
3485 let anchor = tree.add(FillWidget::new());
3486 let content = tree.add(FillWidget::new().label("Faded"));
3487 tree.layout(SizeProposal::exact(200.0, 100.0));
3488
3489 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3490 content_id: content,
3491 anchor,
3492 placement: crate::overlay::OverlayPlacement::Below,
3493 dismiss: crate::overlay::DismissBehavior::Manual,
3494 layer: crate::overlay::OverlayLayer::InTree,
3495 parent_overlay: None,
3496 on_dismiss: None,
3497 fade_duration: Some(std::time::Duration::from_millis(100)),
3498 });
3499 // Move the simulated clock **before** the dismiss. Without this the
3500 // manager's mirror — seeded with `Instant::now()` at construction —
3501 // happens to agree with the tree's sim clock, and the fade start is
3502 // stamped correctly whether or not `advance_time` ever mirrors it.
3503 // A second of virtual time is what makes the mirror load-bearing.
3504 tree.advance_time(std::time::Duration::from_secs(1));
3505
3506 tree.dismiss_overlay(id);
3507 assert!(
3508 tree.is_visible(content),
3509 "content stays active during fade-out"
3510 );
3511
3512 // Less than the tween: a stale mirror would have stamped the start a
3513 // whole second in the past, and this advance would reap the content
3514 // instead of leaving it up.
3515 tree.advance_time(std::time::Duration::from_millis(60));
3516 assert!(
3517 tree.is_visible(content),
3518 "60 ms into a 100 ms tween the content is still up"
3519 );
3520
3521 tree.advance_time(std::time::Duration::from_millis(90));
3522 assert!(
3523 !tree.is_visible(content),
3524 "after sim-time past the tween window, deferred removal fires"
3525 );
3526 }
3527
3528 /// The sim-clock mirror is refreshed **before** the dismissing passes run,
3529 /// not after them.
3530 ///
3531 /// The test above pins that `advance_time` mirrors the clock at all; this
3532 /// one pins *where in the call* it does it. An overlay dismissed from
3533 /// inside `advance_time` — by its own auto-dismiss timer — reads the
3534 /// mirror as it stands at that moment. Refresh it after the dismissing
3535 /// passes and the fade start is stamped one whole advance in the past, so
3536 /// a fade longer than nothing is over before it began: the surface is
3537 /// reaped in the same virtual frame that started fading it, and the tween
3538 /// the caller asked for never plays.
3539 #[test]
3540 fn an_auto_dismissed_fade_starts_at_the_instant_the_dismiss_ran() {
3541 let mut tree = WidgetTree::new();
3542 let anchor = tree.add(FillWidget::new());
3543 let content = tree.add(FillWidget::new().label("Toast"));
3544 tree.layout(SizeProposal::exact(200.0, 100.0));
3545
3546 tree.show_overlay_for(
3547 crate::overlay::OverlayRequest {
3548 content_id: content,
3549 anchor,
3550 placement: crate::overlay::OverlayPlacement::Below,
3551 dismiss: crate::overlay::DismissBehavior::Manual,
3552 layer: crate::overlay::OverlayLayer::InTree,
3553 parent_overlay: None,
3554 on_dismiss: None,
3555 fade_duration: Some(std::time::Duration::from_millis(100)),
3556 },
3557 std::time::Duration::from_millis(500),
3558 );
3559 assert_eq!(tree.active_overlays().len(), 1);
3560
3561 // One advance, well past the auto-dismiss deadline: the auto-dismiss
3562 // pass fires the dismiss, and the fade pass right after it must find a
3563 // tween that started *this* instant and has 100 ms to run.
3564 tree.advance_time(std::time::Duration::from_millis(600));
3565 assert!(
3566 tree.active_overlays().is_empty(),
3567 "precondition: the auto-dismiss fired inside this advance"
3568 );
3569 assert!(
3570 tree.is_visible(content),
3571 "the fade must start at the instant the dismiss ran, so the \
3572 content survives the frame that dismissed it"
3573 );
3574
3575 tree.advance_time(std::time::Duration::from_millis(150));
3576 assert!(
3577 !tree.is_visible(content),
3578 "and is reaped once the tween's own window has passed"
3579 );
3580 }
3581}