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 /// Whether a modal is up and `widget_id` is behind it.
692 ///
693 /// A modal leaves reachable its own content, the content of every overlay
694 /// above it (a drop-down or a menu opened from inside it) and of any
695 /// overlay in a lower band that is anchored in those (the selection
696 /// handles of a field inside it). Everything else, the page and whatever
697 /// it had open, is behind its scrim: the pointer cannot reach it and the
698 /// Tab cycle does not go there. A modal is a `Centered` overlay, the
699 /// discriminator the Tab trap and the pointer's hit test already use, and
700 /// it stays modal while it fades out, as focus stays in it until it has
701 /// gone.
702 ///
703 /// An assistive technology's request is the one input nothing else stops.
704 /// AccessKit's only word for a modal is `Node::set_modal`, a state every
705 /// adapter reports and none acts on (`accesskit_atspi_common`
706 /// `node.rs:342-344`, `accesskit_windows` `node.rs:735-741`,
707 /// `accesskit_macos` `node.rs:1194-1197`): each goes on routing a request
708 /// to any node in the tree, and the page stays in it. Leaving the page out
709 /// of the walk, as a browser leaves out what a modal dialog makes inert,
710 /// is not an option while a node id outlives its removal: AT-SPI declares
711 /// a removed node defunct, libatspi keeps that for its path, and
712 /// `tools/reader/` measured Orca 46.1 dropping the focus event of the
713 /// opener the page came back with ("Ignoring defunct object").
714 pub(super) fn is_behind_modal(&self, widget_id: WidgetId) -> bool {
715 let Some(modal) = self.overlay_manager.topmost_centered() else {
716 return false;
717 };
718 let stack = &self.overlay_manager.stack;
719 let Some(at) = stack.iter().position(|overlay| overlay.id == modal.id) else {
720 return false;
721 };
722 let (below, above) = stack.split_at(at);
723 let in_modal = |id: WidgetId| {
724 above
725 .iter()
726 .any(|overlay| self.is_descendant_of(id, overlay.content_id))
727 };
728 !(in_modal(widget_id)
729 || below.iter().any(|overlay| {
730 self.is_descendant_of(widget_id, overlay.content_id) && in_modal(overlay.anchor)
731 }))
732 }
733
734 fn menu_ancestor_for_widget(&self, widget_id: WidgetId) -> Option<WidgetId> {
735 let mut current = Some(widget_id);
736 while let Some(id) = current {
737 if let Some(node) = self.arena.get(id) {
738 let mut builder = AccessNodeBuilder::new();
739 node.widget.accessibility(&mut builder);
740 if builder.role() == accesskit::Role::Menu {
741 return Some(id);
742 }
743 }
744 current = self.arena.parent(id);
745 }
746 None
747 }
748
749 pub(super) fn dismiss_child_overlays_for_source(
750 &mut self,
751 source_widget: WidgetId,
752 preserve_content: Option<WidgetId>,
753 ops: &mut dyn crate::window::WindowOps,
754 ) {
755 if let Some(parent_overlay) = self.overlay_ancestor_for_widget(source_widget) {
756 let preserve_overlay = preserve_content
757 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
758 let (dismissed, focus_restore) = self
759 .overlay_manager
760 .dismiss_descendants_of(parent_overlay, preserve_overlay);
761 self.dormant_dismissed_content(&dismissed, &mut *ops);
762 if let Some(restore_id) = focus_restore
763 && self.arena.is_active(restore_id)
764 {
765 self.focus_ops(restore_id, &mut *ops);
766 }
767 return;
768 }
769
770 let Some(menu_root) = self.menu_ancestor_for_widget(source_widget) else {
771 return;
772 };
773 let preserve_overlay = preserve_content
774 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
775 let overlay_ids: Vec<crate::overlay::OverlayId> = self
776 .overlay_manager
777 .stack
778 .iter()
779 .filter(|overlay| {
780 overlay.parent_overlay.is_none()
781 && self.is_descendant_of(overlay.anchor, menu_root)
782 && !preserve_overlay.is_some_and(|keep| {
783 overlay.id == keep
784 || self.overlay_manager.is_descendant_of(overlay.id, keep)
785 })
786 })
787 .map(|overlay| overlay.id)
788 .collect();
789
790 for overlay_id in overlay_ids {
791 let (dismissed, focus_restore) =
792 self.overlay_manager.dismiss_with_focus_restore(overlay_id);
793 self.dormant_dismissed_content(&dismissed, &mut *ops);
794 if let Some(restore_id) = focus_restore
795 && self.arena.is_active(restore_id)
796 {
797 self.focus_ops(restore_id, &mut *ops);
798 }
799 }
800 }
801
802 /// Dismiss the menu chain anchored to `source_widget`. Walks from
803 /// the source's containing overlay up the `parent_overlay` chain,
804 /// collecting every overlay whose content role is *not* a "stop"
805 /// role (`Tooltip`, `Dialog`, `AlertDialog`). Dismisses the
806 /// topmost collected overlay — `OverlayManager::dismiss` cascades
807 /// to descendants, so the entire menu/popover cascade closes in
808 /// one go while a hosting tooltip / dialog is preserved.
809 ///
810 /// Replacement for `dismiss_all_overlays` in menu / dropdown item
811 /// activation handlers, where the popover may be hosted inside a
812 /// composite tooltip and "all overlays" is too broad.
813 pub(super) fn dismiss_self_overlay_chain_for_source(
814 &mut self,
815 source_widget: WidgetId,
816 ops: &mut dyn crate::window::WindowOps,
817 ) {
818 let Some(start) = self.overlay_ancestor_for_widget(source_widget) else {
819 return;
820 };
821
822 let mut topmost_to_dismiss: Option<crate::overlay::OverlayId> = None;
823 let mut current = Some(start);
824 while let Some(overlay_id) = current {
825 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
826 break;
827 };
828 if self.overlay_is_host_surface(overlay_id) {
829 break;
830 }
831 topmost_to_dismiss = Some(overlay_id);
832 current = overlay.parent_overlay;
833 }
834
835 if let Some(target) = topmost_to_dismiss {
836 let (dismissed, focus_restore) =
837 self.overlay_manager.dismiss_with_focus_restore(target);
838 self.dormant_dismissed_content(&dismissed, &mut *ops);
839 if let Some(restore_id) = focus_restore
840 && self.arena.is_active(restore_id)
841 {
842 self.focus_ops(restore_id, &mut *ops);
843 }
844 }
845 }
846
847 /// Whether an overlay is **positioned relative to its anchor** — a panel
848 /// that belongs to a control, rather than to the window.
849 ///
850 /// This is the line between a disclosure and a notification. `Below`,
851 /// `Above`, `TrailingEdge`, `AtPointer`, `NearAnchor` and `BelowPreferred`
852 /// all place content *at* the widget that opened it: the anchor is a real
853 /// relationship, and "focus left that control" is a meaningful thing to say
854 /// about them. The viewport-placed variants are not that — `ViewportCorner`
855 /// and `FullViewport` say outright that anchor bounds are ignored,
856 /// `BottomCenter` is where a snackbar lives, and `Centered` is the modal.
857 /// Their `anchor` field is bookkeeping (whatever widget happened to ask),
858 /// not a disclosure they hang off.
859 ///
860 /// Written as an exhaustive `match` on purpose: a placement variant added
861 /// later must not quietly inherit whichever answer happened to be the
862 /// default, so the compiler makes it a decision.
863 fn overlay_is_anchor_positioned(placement: &crate::overlay::OverlayPlacement) -> bool {
864 use crate::overlay::OverlayPlacement as P;
865 match placement {
866 P::Below
867 | P::Above
868 | P::TrailingEdge
869 | P::AtPointer(_)
870 | P::AtPointerAvoiding { .. }
871 | P::NearAnchor { .. }
872 | P::BelowPreferred => true,
873 // `AboveSelection` joins the viewport-placed group: what it hangs
874 // off is a range of text, not the widget recorded as its anchor.
875 // Its lifetime belongs to the selection controller that raised it,
876 // which is also why it lives in the text-affordance band.
877 P::Centered
878 | P::BottomCenter
879 | P::ViewportCorner { .. }
880 | P::FullViewport
881 | P::AboveSelection { .. } => false,
882 }
883 }
884
885 /// Whether an overlay is one that keyboard focus leaving it should close.
886 ///
887 /// Three exclusions, all structural rather than declared:
888 ///
889 /// * Anything **not positioned at its anchor** (see
890 /// [`Self::overlay_is_anchor_positioned`]). That covers the modal — the
891 /// one surface that legitimately *contains* focus (ARIA's Dialog (Modal)
892 /// pattern), which `cycle_focus` already roots Tab traversal at — its
893 /// full-viewport scrim, and the notification surfaces. A snackbar is the
894 /// sharp case: it is shown *from* a focused button and keeps that button
895 /// focused, so an anchor-aware rule would otherwise tear it down on the
896 /// user's very next keystroke, overriding both its timer and
897 /// `.persistent()`. A toast's lifetime belongs to its timer, never to
898 /// where the keyboard happens to be.
899 /// * An overlay **already fading out**. Dismissing it again collapses the
900 /// tween it is mid-way through — and a dismissal is usually what moved
901 /// focus here in the first place, so this is the common case, not a
902 /// corner.
903 /// * A **tooltip**, owned end-to-end by `tooltip_focus_leave_outside`,
904 /// which asks a deliberately wider question — it keeps a tip alive while
905 /// focus rests on its *anchor*, which is the normal state of a
906 /// focus-promoted tip. Judging it by content alone would kill it the
907 /// moment it appeared.
908 ///
909 /// Note what is *not* consulted: [`DismissBehavior`](crate::overlay::DismissBehavior).
910 /// That enum picks which of Escape / click-outside / hover-out apply, which
911 /// is an orthogonal axis — a popover that opted out of click-outside did not
912 /// thereby ask to survive being tabbed away from.
913 ///
914 /// Deliberately *not* `overlay_is_host_surface` either: that matches
915 /// `Role::Dialog`, and `PopoverSurface` announces itself as exactly that,
916 /// so host-ness would exempt every popover in the framework — the one
917 /// surface this rule exists for. Host-ness still stops the walk *above*
918 /// the overlay focus left; see `dismiss_overlays_left_by_focus`.
919 fn overlay_follows_focus_out(&self, overlay_id: crate::overlay::OverlayId) -> bool {
920 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
921 return false;
922 };
923 if !Self::overlay_is_anchor_positioned(&overlay.placement) {
924 return false;
925 }
926 if overlay.is_dismissing() {
927 return false;
928 }
929 !self
930 .tooltips
931 .iter()
932 .any(|entry| entry.content_id == overlay.content_id)
933 }
934
935 /// The topmost overlay `widget_id` belongs to for focus purposes — either
936 /// because it sits *inside* that overlay's content, or because it is the
937 /// **anchor** the overlay hangs off.
938 ///
939 /// The anchor half is not a nicety. A non-searchable `ComboBox` keeps focus
940 /// on its own trigger the whole time its dropdown is open, and a
941 /// `SearchField` keeps it in the text input while the suggestion list
942 /// floats below — in both cases focus is never inside the overlay at all,
943 /// so a content-only test would decide nothing was ever open and leave the
944 /// panel behind when the user tabbed away. `tooltip_focus_leave_outside`
945 /// reached the same conclusion for tips, for the same reason.
946 ///
947 /// **One pass over the stack, top down, asking both questions at each
948 /// level** — not "by content, else by anchor". Those two orderings differ
949 /// exactly when a widget is inside one overlay and the anchor of another,
950 /// which is the ordinary shape of a dropdown opened inside a modal: a
951 /// content-first lookup answers with the *modal*, whose whole point is that
952 /// it does not follow focus out, and the dropdown anchored to the very
953 /// widget holding focus never gets considered at all. That left a
954 /// `ComboBox` panel open over the Settings dialog after Tab had moved on.
955 /// Scanning top down instead lets the nearer, more specific overlay win,
956 /// because the stack is already ordered by exactly that.
957 fn overlay_orbit_for_widget(&self, widget_id: WidgetId) -> Option<crate::overlay::OverlayId> {
958 self.overlay_manager
959 .stack
960 .iter()
961 .rev()
962 .find(|overlay| {
963 self.is_descendant_of(widget_id, overlay.content_id)
964 || (self.overlay_follows_focus_out(overlay.id)
965 && self.is_descendant_of(widget_id, overlay.anchor))
966 })
967 .map(|overlay| overlay.id)
968 }
969
970 /// Close whatever non-modal overlay the keyboard just walked out of.
971 ///
972 /// Non-modal overlays do not trap Tab — menus, popovers and dropdown
973 /// panels implement patterns (Menu, Disclosure, Combobox) that all treat
974 /// Tab as an *exit* gesture, not a navigation one. APG is unqualified
975 /// about menus in particular: Tab "moves focus out of the menu or menubar,
976 /// and closes all menus and submenus". So rather than contain focus, we
977 /// let it go and take the overlay down behind it — which is also what
978 /// keeps an open panel from hiding the focus ring that just left it
979 /// (WCAG 2.2 SC 2.4.11, Focus Not Obscured).
980 ///
981 /// **Ancestry here is two different questions, and they use two
982 /// identically-named helpers.** A submenu's content is `add_detached`, so
983 /// it is never an arena descendant of the menu that opened it — only
984 /// [`OverlayManager::is_descendant_of`](crate::overlay::OverlayManager::is_descendant_of),
985 /// which walks `parent_overlay`, relates the two. Asking
986 /// [`Self::is_descendant_of`] (the arena walk) instead would close a menu
987 /// the instant its own submenu took focus.
988 ///
989 /// Walking *up* the chain and dismissing the outermost eligible level is
990 /// what delivers APG's plural "all menus": `dismiss` already cascades back
991 /// down to every descendant, so one call closes the whole tree.
992 ///
993 /// Unlike every other dismissal path this one must **not** restore focus to
994 /// the overlay's `focus_restore`. The caller has already installed the new
995 /// focus target; re-focusing the trigger here would yank it straight back.
996 pub(super) fn dismiss_overlays_left_by_focus(
997 &mut self,
998 old: Option<WidgetId>,
999 new_focus: WidgetId,
1000 ops: &mut dyn crate::window::WindowOps,
1001 ) {
1002 let Some(old) = old else {
1003 return;
1004 };
1005 let Some(old_overlay) = self.overlay_orbit_for_widget(old) else {
1006 return;
1007 };
1008 // Deliberately the *content*-only lookup, not the orbit: arriving on an
1009 // overlay's own anchor is leaving it. That is the trigger — exactly
1010 // where Escape would have put you — so Shift+Tab off the front of a
1011 // popover closes it rather than parking an open panel under the focus
1012 // ring of the button that owns it.
1013 let new_overlay = self.overlay_ancestor_for_widget(new_focus);
1014
1015 // Focus went *deeper* into the same cascade — a submenu opening off its
1016 // parent, a picker inside a popover. Nothing was left behind.
1017 if let Some(new_overlay) = new_overlay
1018 && (new_overlay == old_overlay
1019 || self
1020 .overlay_manager
1021 .is_descendant_of(new_overlay, old_overlay))
1022 {
1023 return;
1024 }
1025
1026 if !self.overlay_follows_focus_out(old_overlay) {
1027 return;
1028 }
1029
1030 let mut topmost = old_overlay;
1031 let mut current = self
1032 .overlay_manager
1033 .overlay(old_overlay)
1034 .and_then(|overlay| overlay.parent_overlay);
1035 while let Some(overlay_id) = current {
1036 // Focus backed out to a shallower level of this same cascade (a
1037 // submenu handing back to the menu that owns it). That level stays;
1038 // only what sits below it goes.
1039 if Some(overlay_id) == new_overlay {
1040 break;
1041 }
1042 // Never let a menu take its hosting dialog, composite tooltip or
1043 // revealed menubar down with it.
1044 if self.overlay_is_host_surface(overlay_id) {
1045 break;
1046 }
1047 if !self.overlay_follows_focus_out(overlay_id) {
1048 break;
1049 }
1050 topmost = overlay_id;
1051 current = self
1052 .overlay_manager
1053 .overlay(overlay_id)
1054 .and_then(|overlay| overlay.parent_overlay);
1055 }
1056
1057 let dismissed = self.overlay_manager.dismiss(topmost);
1058 self.dormant_dismissed_content(&dismissed, &mut *ops);
1059 }
1060
1061 /// Dismiss every overlay whose content's role is *not* a host
1062 /// surface (`Tooltip` / `Dialog` / `AlertDialog`). Targets stay
1063 /// stable across the loop because we resolve ids first, then
1064 /// dismiss — `OverlayManager::dismiss` cascades to descendants,
1065 /// so an already-cascaded id is a no-op.
1066 ///
1067 /// Replacement for `dismiss_all_overlays` in popover triggers and
1068 /// pre-show cleanup paths, where the broad "dismiss everything"
1069 /// semantics also closed an outer composite tooltip or modal
1070 /// hosting the trigger.
1071 pub(super) fn dismiss_all_overlays_except_hosts(
1072 &mut self,
1073 ops: &mut dyn crate::window::WindowOps,
1074 ) {
1075 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1076 .overlay_manager
1077 .stack
1078 .iter()
1079 .map(|overlay| overlay.id)
1080 .filter(|&id| !self.overlay_is_host_surface(id))
1081 .collect();
1082
1083 for overlay_id in to_dismiss {
1084 let dismissed = self.overlay_manager.dismiss(overlay_id);
1085 self.dormant_dismissed_content(&dismissed, &mut *ops);
1086 }
1087 }
1088
1089 /// Whether `overlay_id` is a "host" surface — a tooltip, dialog,
1090 /// or alert dialog. Host overlays survive `dismiss_all_except_hosts`
1091 /// and stop the upward walk in `dismiss_self_overlay_chain_for_source`,
1092 /// so a popover hosted inside a composite tooltip can dismiss
1093 /// itself (or its menu cascade) without taking the host with it.
1094 pub(super) fn overlay_is_host_surface(&self, overlay_id: crate::overlay::OverlayId) -> bool {
1095 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
1096 return false;
1097 };
1098 // A modal (a `Centered` overlay — see `modal_overlay_for_widget`) is
1099 // always a host surface: a dropdown / popover / menu opened *inside* a
1100 // modal must dismiss only its own cascade, never tear down the hosting
1101 // modal. Without this, a `ComboBox`/menu inside a modal that closes via
1102 // `dismiss_all_except_hosts` / `dismiss_self_overlay_chain_for_source`
1103 // walks past the modal (whose content is not a `Dialog`-role widget) and
1104 // dismisses it too. Same fix that stopped a `TabWidget` overflow menu
1105 // from closing its hosting composite tooltip, extended to modals.
1106 if matches!(
1107 overlay.placement,
1108 crate::overlay::OverlayPlacement::Centered
1109 ) {
1110 return true;
1111 }
1112 let Some(node) = self.arena.get(overlay.content_id) else {
1113 return false;
1114 };
1115 // Prefer an `.access_role(...)` override (e.g. a collapsible
1116 // `MenuBar`'s bar-content node marked `Role::MenuBar`) over the
1117 // widget's own role — the override is what the AccessKit walker
1118 // surfaces, so it must also decide host-ness here. Without this,
1119 // the revealed bar would not be recognised as a host and
1120 // `dismiss_all_except_hosts` (called when a menu opens) would
1121 // tear it down mid-navigation.
1122 let role = node
1123 .access_overrides
1124 .as_ref()
1125 .and_then(|o| o.role)
1126 .unwrap_or_else(|| {
1127 let mut builder = AccessNodeBuilder::new();
1128 node.widget.accessibility(&mut builder);
1129 builder.role()
1130 });
1131 matches!(
1132 role,
1133 accesskit::Role::Tooltip
1134 | accesskit::Role::Dialog
1135 | accesskit::Role::AlertDialog
1136 | accesskit::Role::MenuBar
1137 )
1138 }
1139
1140 pub(super) fn dismiss_modal_for_source(
1141 &mut self,
1142 source_widget: WidgetId,
1143 ops: &mut dyn crate::window::WindowOps,
1144 ) -> bool {
1145 let Some(modal_overlay) = self.modal_overlay_for_widget(source_widget) else {
1146 return false;
1147 };
1148
1149 let (dismissed, focus_restore) = self
1150 .overlay_manager
1151 .dismiss_with_focus_restore(modal_overlay);
1152 self.dormant_dismissed_content(&dismissed, &mut *ops);
1153 if let Some(restore_id) = focus_restore
1154 && self.arena.is_active(restore_id)
1155 {
1156 self.focus_ops(restore_id, &mut *ops);
1157 }
1158 true
1159 }
1160
1161 pub fn is_descendant_of(&self, widget_id: WidgetId, ancestor: WidgetId) -> bool {
1162 if widget_id == ancestor {
1163 return true;
1164 }
1165 let mut current = self.arena.parent(widget_id);
1166 while let Some(parent_id) = current {
1167 if parent_id == ancestor {
1168 return true;
1169 }
1170 current = self.arena.parent(parent_id);
1171 }
1172 false
1173 }
1174
1175 /// Whether `widget_id` is the root content node of an active overlay
1176 /// (an open menu, popover, dialog, …). Menus and popovers move keyboard
1177 /// focus onto their whole content container while navigating items via
1178 /// an internal highlight index, so the focus-tooltip path must not treat
1179 /// that container's focus as a per-item trigger (it would surface every
1180 /// descendant's rich tooltip at once).
1181 fn is_overlay_content_root(&self, widget_id: WidgetId) -> bool {
1182 self.overlay_manager.find_by_content(widget_id).is_some()
1183 }
1184
1185 /// Called when a widget gains keyboard focus. For any *rich* tooltip (one
1186 /// with `sticky_after` set) whose anchor contains `widget_id`, **arm** the
1187 /// tooltip's ordinary show delay — it appears only if focus comes to rest,
1188 /// and even then it is not promoted. Promotion is left to the dwell sweep,
1189 /// on the same `sticky_after` clock and the same visible `DwellIndicator`
1190 /// the pointer path uses.
1191 ///
1192 /// Both halves mirror the pointer deliberately. A tip that appeared the
1193 /// instant focus arrived strobed across a row of tooltipped buttons as the
1194 /// user Tabbed through, the same way a tip on pointer-*enter* (rather than
1195 /// pointer-*pause*) would flicker across a toolbar.
1196 ///
1197 /// Focus arriving is not a statement of intent: Tab passes through
1198 /// controls constantly, and a tip that promoted on arrival turned every
1199 /// such pass into a persistent `Role::Dialog` that assistive tech
1200 /// announces and that claims a slot in the Tab cycle. Sustained focus is
1201 /// the keyboard's equivalent of a stationary pointer, so it earns
1202 /// stickiness the same way and over the same window — which also leaves
1203 /// the user a couple of seconds to move on if they did not want the
1204 /// panel. Until then the surface stays an ephemeral `Role::Tooltip`,
1205 /// reachable to screen readers through the anchor's own description, and
1206 /// out of the Tab order (see `cycle_focus`).
1207 ///
1208 /// Plain tooltips (no `sticky_after`) are deliberately NOT
1209 /// auto-shown on focus: their text reaches assistive tech as the
1210 /// anchor's description, copied onto it by the a11y tree pass while
1211 /// the tooltip is hidden and written from its `described_by`
1212 /// relation while it is shown (no AccessKit adapter reads the
1213 /// relation itself). That is the W3C-recommended pattern for
1214 /// supplementary hints.
1215 pub(super) fn tooltip_focus_enter(&mut self, widget_id: WidgetId) {
1216 // Two ways a registered rich/composite tooltip can relate to the
1217 // focus target:
1218 // • direct — focus landed ON the anchor or somewhere inside it
1219 // (an ordinary focusable control, a self-anchored focusable
1220 // widget, and composites whose focus sinks into an inner field).
1221 // Always show.
1222 // • reverse — the anchor sits strictly *inside* the focused widget.
1223 // This exists for composing controls (e.g. `Button`) that keep
1224 // focus on their outer node but anchor the tooltip on an inner
1225 // body root. Show ONLY when the focused widget is a single
1226 // such control — never when it is a *container* that merely
1227 // happens to be focusable and owns many tooltip-bearing
1228 // descendants (an open `MenuList`, whose whole panel receives
1229 // focus while items are navigated by an internal highlight
1230 // index), or every descendant's rich tooltip fires at once — the
1231 // "wall of tooltips".
1232 //
1233 // The two arms are mutually exclusive (`if` / `else if`): a widget
1234 // that anchors its own tooltip to its `self_id` (`TabHeader`,
1235 // `ColorSwatch`) satisfies BOTH predicates because `is_descendant_of`
1236 // is reflexive — routing it to `direct` only avoids a duplicate
1237 // `show_overlay` (which would leak the first overlay).
1238 type ToShow = (WidgetId, WidgetId, crate::overlay::TooltipPlacement);
1239 let mut direct: Vec<ToShow> = Vec::new();
1240 let mut reverse: Vec<ToShow> = Vec::new();
1241 for e in &self.tooltips {
1242 if e.sticky_after.is_none() || e.overlay_id.is_some() || e.suppressed_until_focus_leaves
1243 {
1244 continue;
1245 }
1246 if self.is_descendant_of(widget_id, e.anchor_id) {
1247 direct.push((e.anchor_id, e.content_id, e.placement));
1248 } else if self.is_descendant_of(e.anchor_id, widget_id) {
1249 reverse.push((e.anchor_id, e.content_id, e.placement));
1250 }
1251 }
1252 let mut to_show = direct;
1253 // A reverse match is a single composing control only when it resolves
1254 // to exactly one anchor and the focused node is not itself a menu /
1255 // popover content root. Otherwise it is a container fan-out — skip it.
1256 if reverse.len() == 1 && !self.is_overlay_content_root(widget_id) {
1257 to_show.extend(reverse);
1258 }
1259
1260 // Arm the same pending-delay timer the pointer path arms — do not show
1261 // now. Focus arriving is not intent, and Tab moves fast: showing on
1262 // arrival made a sweep across a row of tooltipped buttons strobe a tip
1263 // at every stop. The pointer has always required a *pause* before a tip
1264 // appears; the keyboard equivalent is focus coming to rest, so it waits
1265 // out the same `delay` (and gets the same warm-reshow discount inside a
1266 // session). `hover_origin` stays `None`: there is no pointer to apply
1267 // the stationary-slop filter to.
1268 let sim_now = self.sim_clock;
1269 let real_now = std::time::Instant::now();
1270 for (_anchor_id, content_id, _placement) in to_show {
1271 if let Some(entry) = self
1272 .tooltips
1273 .iter_mut()
1274 .find(|e| e.content_id == content_id)
1275 {
1276 entry.hover_start = Some(sim_now);
1277 entry.real_hover_start = Some(real_now);
1278 entry.hover_origin = None;
1279 entry.armed_by_focus = true;
1280 entry.armed_by_hold = false;
1281 }
1282 }
1283 }
1284
1285 /// Surface the tooltip of a keyboard-highlighted menu item immediately
1286 /// (no dwell), positioned per its own `TooltipPlacement`, and dismiss the
1287 /// previously-highlighted item's tooltip. Real keyboard focus stays on the
1288 /// enclosing `MenuList` (for key handling); this is keyed on `item_id`.
1289 ///
1290 /// The tooltip is shown as a `Manual`-dismiss **child overlay of the
1291 /// enclosing menu**, so closing the menu (Escape / click-outside / the
1292 /// opener) cascades the tooltip away automatically, and a single Escape
1293 /// reaches the menu rather than only clearing the tooltip.
1294 pub(super) fn show_highlight_tooltip(
1295 &mut self,
1296 item_id: WidgetId,
1297 ops: &mut dyn crate::window::WindowOps,
1298 ) {
1299 // Clear (and reconcile) any currently-shown highlight tooltip first —
1300 // moving the highlight replaces it; a tooltip-less target clears it.
1301 self.clear_highlight_tooltip(&mut *ops);
1302
1303 // Find the single tooltip anchored within the highlighted item's
1304 // subtree (a `MenuItem` anchors to its inner body root). Skip if it is
1305 // already shown or the item carries no tooltip.
1306 let found = self.tooltips.iter().find_map(|e| {
1307 if e.overlay_id.is_none() && self.is_descendant_of(e.anchor_id, item_id) {
1308 Some((e.anchor_id, e.content_id, e.placement))
1309 } else {
1310 None
1311 }
1312 });
1313 let Some((anchor_id, content_id, placement)) = found else {
1314 return;
1315 };
1316
1317 let parent_overlay = self.overlay_ancestor_for_widget(item_id);
1318 self.arena.activate(content_id);
1319 let fade_duration = if self.prefers_reduced_motion {
1320 None
1321 } else {
1322 Some(self.theme.motion.duration_fast)
1323 };
1324 let oid = self.show_overlay(crate::overlay::OverlayRequest {
1325 content_id,
1326 anchor: anchor_id,
1327 placement: Self::tooltip_overlay_placement(placement),
1328 dismiss: crate::overlay::DismissBehavior::Manual,
1329 layer: crate::overlay::OverlayLayer::InTree,
1330 parent_overlay,
1331 on_dismiss: None,
1332 fade_duration,
1333 });
1334 let real_now = std::time::Instant::now();
1335 let sim_now = self.sim_clock;
1336 if let Some(entry) = self
1337 .tooltips
1338 .iter_mut()
1339 .find(|e| e.content_id == content_id)
1340 {
1341 entry.overlay_id = Some(oid);
1342 entry.shown_at_sim = Some(sim_now);
1343 entry.shown_at_real = Some(real_now);
1344 if let Some(sink) = entry.shown_at_sink.as_ref() {
1345 sink.set(Some(real_now));
1346 }
1347 }
1348 self.highlight_tooltip = Some((oid, content_id));
1349 }
1350
1351 /// Dismiss the keyboard-highlight tooltip if one is showing, resetting its
1352 /// entry so it can re-show later. Safe to call when none is active or when
1353 /// the overlay was already cascade-dismissed by a menu close (the tracked
1354 /// id is simply no longer in the stack).
1355 pub(super) fn clear_highlight_tooltip(&mut self, ops: &mut dyn crate::window::WindowOps) {
1356 let Some((oid, content_id)) = self.highlight_tooltip.take() else {
1357 return;
1358 };
1359 if self.overlay_manager.stack.iter().any(|o| o.id == oid) {
1360 let dismissed = self.overlay_manager.dismiss(oid);
1361 self.dormant_dismissed_content(&dismissed, &mut *ops);
1362 }
1363 if let Some(entry) = self
1364 .tooltips
1365 .iter_mut()
1366 .find(|e| e.content_id == content_id)
1367 {
1368 entry.overlay_id = None;
1369 entry.shown_at_sim = None;
1370 entry.shown_at_real = None;
1371 if let Some(sink) = entry.shown_at_sink.as_ref() {
1372 sink.set(None);
1373 }
1374 }
1375 }
1376
1377 /// Called when focus moves to a new widget. Dismisses every
1378 /// focus-promoted rich tooltip whose anchor- and tooltip-content
1379 /// subtrees both fail to contain the new focus — so Tab'ing INTO
1380 /// a sticky tooltip to click a link keeps it up, but Tab'ing
1381 /// past it onto unrelated controls closes it (preventing sticky
1382 /// accumulation as the user navigates through a form).
1383 ///
1384 /// Pointer-dwelled stickies (`promoted_by_focus == false`)
1385 /// survive focus changes intact — they're dismissed only via
1386 /// Escape or click-outside, matching the existing mouse UX.
1387 pub(super) fn tooltip_focus_leave_outside(
1388 &mut self,
1389 new_focus: Option<WidgetId>,
1390 ops: &mut dyn crate::window::WindowOps,
1391 ) {
1392 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1393 .tooltips
1394 .iter()
1395 .filter(|e| e.promoted_by_focus && e.overlay_id.is_some())
1396 .filter(|e| {
1397 let in_scope = new_focus
1398 .map(|nf| {
1399 // In scope when the new focus lands in either
1400 // the anchor's subtree or the tooltip content's
1401 // subtree — covers Tab-to-anchor, Tab-deeper-
1402 // inside-anchor, and Tab-into-tooltip.
1403 self.is_descendant_of(nf, e.anchor_id)
1404 || self.is_descendant_of(e.anchor_id, nf)
1405 || self.is_descendant_of(nf, e.content_id)
1406 })
1407 .unwrap_or(false);
1408 !in_scope
1409 })
1410 .filter_map(|e| e.overlay_id)
1411 .collect();
1412
1413 for oid in to_dismiss {
1414 let dismissed = self.overlay_manager.dismiss(oid);
1415 self.dormant_dismissed_content(&dismissed, &mut *ops);
1416 }
1417
1418 // Focus moved on before the delay ripened: disarm, or the tip would
1419 // still open a beat later, over a control the user has already left.
1420 for e in &mut self.tooltips {
1421 if e.armed_by_focus && e.overlay_id.is_none() {
1422 e.hover_start = None;
1423 e.real_hover_start = None;
1424 e.armed_by_focus = false;
1425 }
1426 }
1427
1428 // Focus has landed somewhere new: any entry it is now outside of has
1429 // had its Escape-suppression served. Tabbing away and back must
1430 // re-summon the tip, or one Escape would mute that anchor forever.
1431 let served: Vec<usize> = self
1432 .tooltips
1433 .iter()
1434 .enumerate()
1435 .filter(|(_, e)| e.suppressed_until_focus_leaves)
1436 .filter(|(_, e)| {
1437 let still_inside = new_focus
1438 .map(|nf| {
1439 self.is_descendant_of(nf, e.anchor_id)
1440 || self.is_descendant_of(nf, e.content_id)
1441 })
1442 .unwrap_or(false);
1443 !still_inside
1444 })
1445 .map(|(i, _)| i)
1446 .collect();
1447 for i in served {
1448 self.tooltips[i].suppressed_until_focus_leaves = false;
1449 }
1450 }
1451
1452 /// Whether a pointer hover over `widget_id` should count as a hover
1453 /// of `anchor_id` for tooltip purposes.
1454 ///
1455 /// `widget_id` must be a descendant-or-self of `anchor_id`, AND no
1456 /// active-overlay boundary may separate them. The second clause is
1457 /// what stops a tooltip leaking onto overlay content that merely
1458 /// *remains* an arena child of its anchor: a `ComboBox`'s dropdown
1459 /// panel, a `PopoverButton`'s popover, a menu — all are kept under
1460 /// their trigger in the arena (for hit-test / a11y / teardown) but
1461 /// are shown as overlays. Hovering one of their rows is a hover of
1462 /// the *overlay*, not of the anchor's own chrome, so the anchor's
1463 /// (or any ancestor's) tooltip must not fire.
1464 ///
1465 /// Concretely: if the hovered widget lives inside an active overlay
1466 /// whose content subtree does **not** contain the anchor, the hover
1467 /// is on the overlay and we return `false`. A tooltip attached to a
1468 /// widget *inside* the overlay (e.g. a dropdown row's own tooltip)
1469 /// still fires, because that anchor is itself within the overlay's
1470 /// content subtree.
1471 fn tooltip_hover_targets_anchor(&self, widget_id: WidgetId, anchor_id: WidgetId) -> bool {
1472 if !self.is_descendant_of(widget_id, anchor_id) {
1473 return false;
1474 }
1475 if let Some(overlay_id) = self.overlay_ancestor_for_widget(widget_id)
1476 && let Some(content_id) = self
1477 .overlay_manager
1478 .overlay(overlay_id)
1479 .map(|o| o.content_id)
1480 && !self.is_descendant_of(anchor_id, content_id)
1481 {
1482 return false;
1483 }
1484 true
1485 }
1486
1487 /// Arm the dwell for the tooltip that owns this hover.
1488 ///
1489 /// A hover target can sit inside several tooltip anchors at once (a row
1490 /// with its own tip inside a panel with a tip). Only the **innermost**
1491 /// anchor arms: it is the most specific description of what the pointer is
1492 /// actually over, and arming the outer ones too would let two tooltips
1493 /// mature and open simultaneously, one on top of the other.
1494 ///
1495 /// "Innermost" is measured by arena depth from the hovered widget, so
1496 /// nesting order — not the order the anchors happened to be attached in —
1497 /// decides the winner.
1498 /// The tooltip entry a pointer over `widget_id` should surface: the
1499 /// **innermost** one whose anchor is `widget_id` or an ancestor of it.
1500 ///
1501 /// Shared by the hover arm and by the tree-owned hold route
1502 /// ([`super::touch_route`]) so a hold and a hover cannot pick different
1503 /// tips for the same node.
1504 pub(super) fn tooltip_index_for(&self, widget_id: WidgetId) -> Option<usize> {
1505 self.tooltips
1506 .iter()
1507 .enumerate()
1508 .filter(|(_, entry)| self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id))
1509 .filter_map(|(index, entry)| {
1510 self.ancestor_distance(widget_id, entry.anchor_id)
1511 .map(|depth| (depth, index))
1512 })
1513 .min()
1514 .map(|(_, index)| index)
1515 }
1516
1517 pub(super) fn tooltip_pointer_enter(&mut self, widget_id: WidgetId) {
1518 let Some(index) = self.tooltip_index_for(widget_id) else {
1519 return;
1520 };
1521 // Don't restart a timer for a tip that is already showing.
1522 if self.tooltips[index].overlay_id.is_some() {
1523 return;
1524 }
1525 self.tooltips[index].hover_start = Some(self.sim_clock);
1526 self.tooltips[index].real_hover_start = Some(std::time::Instant::now());
1527 self.tooltips[index].hover_origin = self.hover_owner_position();
1528 self.tooltips[index].armed_by_focus = false;
1529 self.tooltips[index].armed_by_hold = false;
1530 self.arena.mark_needs_paint(self.tooltips[index].anchor_id);
1531 }
1532
1533 /// Number of parent hops from `widget_id` up to `ancestor`, or `None` if
1534 /// `ancestor` is not on the chain. `0` when they are the same widget.
1535 fn ancestor_distance(&self, widget_id: WidgetId, ancestor: WidgetId) -> Option<usize> {
1536 let mut current = widget_id;
1537 let mut depth = 0usize;
1538 loop {
1539 if current == ancestor {
1540 return Some(depth);
1541 }
1542 current = self.arena.parent(current)?;
1543 depth += 1;
1544 }
1545 }
1546
1547 /// Cancel tooltip activity on a pointer press.
1548 ///
1549 /// A press is a statement that the user already knows what the control
1550 /// does: a pending dwell is cancelled (so a tooltip does not pop *after*
1551 /// the click that answered it), and a shown non-sticky tooltip is
1552 /// dismissed (so it stops covering what was just clicked). Re-hovering
1553 /// restarts the delay from scratch, matching Windows and GTK.
1554 ///
1555 /// Pointer-dwelled **sticky** tooltips are left alone — they are an
1556 /// interactive surface the user deliberately pinned, and they own their
1557 /// own Escape / click-outside dismissal.
1558 /// `at` is the press position, when there is one. A press landing *inside*
1559 /// a tooltip's own surface is the user reaching into it (a rich tooltip's
1560 /// inline link), not dismissing it, so that tooltip is spared.
1561 pub(super) fn tooltip_pointer_press(&mut self, at: Option<teksilo_canvas::Point>) {
1562 let mut to_dismiss = Vec::new();
1563 let candidates: Vec<(usize, Option<crate::overlay::OverlayId>)> = self
1564 .tooltips
1565 .iter()
1566 .enumerate()
1567 .map(|(i, e)| (i, if e.is_sticky { None } else { e.overlay_id }))
1568 .collect();
1569 for (index, overlay_id) in candidates {
1570 // Content rect only — deliberately NOT `pointer_inside_overlay_region`,
1571 // which also counts the anchor (it exists to keep the tip alive
1572 // while the pointer crosses the gap). A press on the *anchor* is
1573 // exactly the case that must dismiss.
1574 let inside = match (overlay_id, at) {
1575 (Some(oid), Some(pos)) => self
1576 .overlay_content_bounds(oid)
1577 .is_some_and(|rect| rect.contains(pos)),
1578 _ => false,
1579 };
1580 if inside {
1581 continue;
1582 }
1583 let entry = &mut self.tooltips[index];
1584 entry.hover_start = None;
1585 entry.real_hover_start = None;
1586 entry.hover_origin = None;
1587 if let Some(oid) = overlay_id {
1588 to_dismiss.push(oid);
1589 }
1590 }
1591 for overlay_id in to_dismiss {
1592 self.dismiss_overlay(overlay_id);
1593 }
1594 }
1595
1596 /// Retire shown tooltips on **Escape**, without consuming the key.
1597 ///
1598 /// WCAG 1.4.13 (Content on Hover or Focus) requires that hover content can
1599 /// be dismissed without moving the pointer or focus, and Escape is the
1600 /// mechanism everyone expects. What it does **not** require is that the
1601 /// keystroke stop there — and stopping there is a bug with real teeth,
1602 /// because a tooltip is up far more often than anyone realises. A writer
1603 /// renaming a table cell, pointer resting where they clicked, presses
1604 /// Escape to abandon the rename: the tip they were not looking at silently
1605 /// goes away and the editor stays open. Press it again and it works. It
1606 /// reads as "Escape does nothing", and it is why an Escape-cancels
1607 /// affordance anywhere in an app is unreliable rather than plainly broken.
1608 ///
1609 /// So this runs *before* the overlay stack's own Escape walk and returns no
1610 /// verdict: the tips go, and the key carries on to whatever the user
1611 /// actually meant it for — a menu, a popover, or the focused widget.
1612 /// Retiring them first also means that walk can no longer pick a tooltip as
1613 /// its target, which is what used to swallow the press.
1614 ///
1615 /// Sticky tooltips are untouched, exactly as on a pointer press: the user
1616 /// deliberately pinned those, and they own their own Escape handling.
1617 pub(super) fn tooltip_escape_pressed(&mut self) {
1618 // No position — the same "every non-sticky tip goes" case as a window
1619 // deactivation. A key press has nowhere to be "inside".
1620 self.tooltip_pointer_press(None);
1621 }
1622
1623 /// Retire hover tooltips when the window stops being active.
1624 ///
1625 /// Same shape as [`tooltip_pointer_press`](Self::tooltip_pointer_press) —
1626 /// pending dwells cancelled, shown non-sticky tips dismissed, pinned
1627 /// stickies preserved — but triggered by the window losing focus rather
1628 /// than by a click.
1629 pub(super) fn tooltip_window_deactivated(&mut self) {
1630 // No position: the window is going away, so every non-sticky tip goes
1631 // with it regardless of where the pointer happened to be.
1632 self.tooltip_pointer_press(None);
1633 }
1634
1635 /// Cancel every pending (not-yet-shown) tooltip dwell.
1636 ///
1637 /// Called when a drag session starts: the pointer is now carrying
1638 /// something, and a tooltip that pops mid-drag is a stray overlay in the
1639 /// user's way. `process_tooltips_real` runs from the layout pass, which a
1640 /// drag keeps driving, so the dwell would otherwise mature and show even
1641 /// though the pointer-move path is short-circuited for the drag.
1642 pub(crate) fn tooltip_cancel_pending_dwell(&mut self) {
1643 for entry in &mut self.tooltips {
1644 if entry.overlay_id.is_none() {
1645 entry.hover_start = None;
1646 entry.real_hover_start = None;
1647 entry.hover_origin = None;
1648 }
1649 }
1650 }
1651
1652 /// Restart a pending tooltip's delay when the pointer keeps moving
1653 /// inside the same anchor beyond the stationary slop. Called from
1654 /// pointer-move when the hover target has not changed.
1655 pub(super) fn tooltip_pointer_moved(
1656 &mut self,
1657 widget_id: WidgetId,
1658 position: teksilo_canvas::Point,
1659 ) {
1660 let matching: Vec<usize> = self
1661 .tooltips
1662 .iter()
1663 .enumerate()
1664 .filter(|(_, entry)| {
1665 entry.overlay_id.is_none()
1666 && entry.hover_start.is_some()
1667 && !entry.armed_by_focus
1668 && self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id)
1669 })
1670 .map(|(index, _)| index)
1671 .collect();
1672 if matching.is_empty() {
1673 return;
1674 }
1675 let now = self.sim_clock;
1676 let real_now = std::time::Instant::now();
1677 let slop = super::TOOLTIP_STATIONARY_SLOP;
1678 let slop_sq = slop * slop;
1679 for index in matching {
1680 let origin = match self.tooltips[index].hover_origin {
1681 Some(o) => o,
1682 None => {
1683 // Enter happened without a recorded position (tests that
1684 // call `tooltip_pointer_enter` directly) — adopt this
1685 // position as the origin without restarting the timer.
1686 self.tooltips[index].hover_origin = Some(position);
1687 continue;
1688 }
1689 };
1690 let dx = position.x - origin.x;
1691 let dy = position.y - origin.y;
1692 if dx * dx + dy * dy <= slop_sq {
1693 continue;
1694 }
1695 // Pointer has moved meaningfully since hover began — restart.
1696 self.tooltips[index].hover_start = Some(now);
1697 self.tooltips[index].real_hover_start = Some(real_now);
1698 self.tooltips[index].hover_origin = Some(position);
1699 }
1700 }
1701
1702 /// Promote a shown tooltip from "ephemeral hover" to "sticky".
1703 ///
1704 /// - Flags the tooltip entry as sticky so
1705 /// `tooltip_pointer_press`
1706 /// no longer auto-dismisses it,
1707 /// - Swaps the overlay's dismiss behavior to
1708 /// `EscapeOrClickOutside` so clicking anywhere off the tooltip
1709 /// (or pressing Escape) closes it.
1710 ///
1711 /// The entry is **not** removed: when the user later dismisses
1712 /// the sticky overlay, `dormant_dismissed_content` resets the
1713 /// entry back to its initial state so a future hover re-shows
1714 /// the tooltip from scratch.
1715 ///
1716 /// Called by the auto-promote sweep in `process_tooltips_impl` once the
1717 /// dwell timer reaches its threshold, and reachable from a widget through
1718 /// `BuildContext::promote_tooltip_to_sticky`.
1719 pub fn promote_tooltip_to_sticky(&mut self, content_id: WidgetId) {
1720 let Some(entry) = self
1721 .tooltips
1722 .iter_mut()
1723 .find(|entry| entry.content_id == content_id)
1724 else {
1725 return;
1726 };
1727 if entry.is_sticky {
1728 return;
1729 }
1730 entry.is_sticky = true;
1731 let overlay_id = entry.overlay_id;
1732 if let Some(overlay_id) = overlay_id {
1733 self.overlay_manager.set_dismiss(
1734 overlay_id,
1735 crate::overlay::DismissBehavior::EscapeOrClickOutside,
1736 );
1737 }
1738 }
1739
1740 pub(super) fn tooltip_pointer_leave(
1741 &mut self,
1742 widget_id: WidgetId,
1743 _ops: &mut dyn crate::window::WindowOps,
1744 ) {
1745 let matching: Vec<usize> = self
1746 .tooltips
1747 .iter()
1748 .enumerate()
1749 .filter(|(_, entry)| self.is_descendant_of(widget_id, entry.anchor_id))
1750 .map(|(index, _)| index)
1751 .collect();
1752 for index in matching {
1753 // Cancel any pending (not-yet-shown) dwell so re-hovering restarts
1754 // the delay timer.
1755 self.tooltips[index].hover_start = None;
1756 self.tooltips[index].real_hover_start = None;
1757 self.tooltips[index].hover_origin = None;
1758 // Audit G12 (WCAG 1.4.13 Hoverable): do NOT dismiss a *shown*
1759 // tooltip here on anchor-leave — that killed it the instant the
1760 // pointer crossed the 8px gap toward the tooltip. Dismissal of a
1761 // shown (non-sticky) tooltip is owned by the overlay stack's
1762 // `PointerLeave { 100ms }` machinery (process_pointer_leave_overlays_real,
1763 // run every frame), whose `pointer_inside_overlay_region` keeps the
1764 // overlay alive while the pointer is over EITHER the anchor or the
1765 // tooltip and dismisses only after 100ms outside both. Sticky
1766 // tooltips are dismissed via Escape / click-outside. The stale
1767 // overlay_id left when that machinery dismisses the overlay is
1768 // reconciled at the top of `process_tooltips_impl`.
1769 }
1770 }
1771
1772 /// Returns the earliest of every deadline the tree owes the event loop —
1773 /// pending tooltips and their dwell steps, delayed overlays, overlay
1774 /// auto-dismiss / pointer-leave / fade removal, animations, animated
1775 /// quads, input gestures, an explicit wake-at and the per-frame tick
1776 /// (if any).
1777 pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
1778 let now = std::time::Instant::now();
1779 let session_active = self.tooltip_session_active_real(now);
1780 let tooltip_deadline = self
1781 .tooltips
1782 .iter()
1783 .filter(|entry| entry.overlay_id.is_none())
1784 .filter_map(|entry| {
1785 let start = entry.real_hover_start?;
1786 let delay = self.effective_tooltip_delay(entry.delay, session_active);
1787 Some(start + delay)
1788 })
1789 .min();
1790
1791 // Sticky-on-dwell wake-ups: once a rich tooltip is shown, wake once per
1792 // indicator step (500 ms for the default 2 s promotion) so the step
1793 // indicator advances and the promotion fires, even with the pointer
1794 // held still. Without these deadlines the loop would only wake on user
1795 // input, freezing the dwell counter. The step is derived per entry from
1796 // its own `sticky_after` rather than hardcoded, so a caller with a
1797 // non-default promotion window still gets evenly-spaced wake-ups.
1798 //
1799 // The step boundary is rounded off `last_frame_time` (the last rendered
1800 // frame) — NOT `Instant::now()`. The app's `request_redraw_due`
1801 // re-derives this deadline at each timer wake and only redraws windows
1802 // whose `deadline <= now`. If we rounded off `now`, then at the instant
1803 // a 500 ms boundary's wake fires, `elapsed` has just crossed it and the
1804 // boundary would already have rolled forward to the NEXT step (a future
1805 // instant) — so `deadline <= now` would never hold and the window would
1806 // never redraw. The dwell then only advanced when some unrelated input
1807 // event happened to redraw the window (the "only updates on mouse move"
1808 // bug). Pinning the boundary to `last_frame_time` keeps the deadline
1809 // `<= now` at its own wake until a render actually advances the frame
1810 // time to the next step — one redraw per 500 ms boundary, no free-run.
1811 let ref_time = self.last_frame_time.unwrap_or_else(std::time::Instant::now);
1812 let dwell_tooltip_deadline = self
1813 .tooltips
1814 .iter()
1815 .filter_map(|entry| {
1816 let sticky_after = entry.sticky_after?;
1817 let shown_at = entry.shown_at_real?;
1818 if entry.overlay_id.is_none() || entry.is_sticky {
1819 return None;
1820 }
1821 let dwell_step = sticky_after / super::TOOLTIP_DWELL_STEPS;
1822 if dwell_step.is_zero() {
1823 return None;
1824 }
1825 let elapsed = ref_time.saturating_duration_since(shown_at);
1826 if elapsed >= sticky_after {
1827 return None;
1828 }
1829 // Round up to the next step boundary so each wake-up lands on a
1830 // 500 ms / 1 s / 1.5 s / 2 s mark (measured at the last frame).
1831 let steps_passed = (elapsed.as_millis() / dwell_step.as_millis()) as u32;
1832 let next_step_at = shown_at + dwell_step * (steps_passed + 1);
1833 Some(next_step_at.min(shown_at + sticky_after))
1834 })
1835 .min();
1836 let delayed_overlay_deadline = self
1837 .pending_delayed_overlays
1838 .iter()
1839 .map(|pending| pending.real_requested_at + pending.delay)
1840 .min();
1841 let auto_dismiss_deadline = self.overlay_manager.next_auto_dismiss_deadline();
1842 // Hover-opened overlays (every shown tooltip, hover submenus) dismiss
1843 // on a `PointerLeave { delay }` grace that only advances when a frame
1844 // runs. The pointer's last motion event is the *start* of that grace,
1845 // not a reason to wake at its end — so without this term a tooltip the
1846 // user has walked away from stays on screen for as long as the app
1847 // stays idle.
1848 let pointer_leave_deadline = self.overlay_manager.next_pointer_leave_deadline();
1849 // A fade-out defers the overlay's removal by the tween's duration, and
1850 // that removal is what parks the content. The tween's own scheduler
1851 // deadline usually wakes the loop at the same instant, but only while
1852 // the tween is registered — a fade that is cancelled, completed early
1853 // or never scheduled (reduced motion) leaves nothing else to wake for,
1854 // and the surface stays on screen until unrelated input arrives.
1855 let fade_dismiss_deadline = self.overlay_manager.next_fade_dismiss_deadline();
1856 let animation_deadline = self
1857 .animation_scheduler
1858 .next_deadline(&self.arena, self.paint_epoch);
1859 // Same pattern for the shader-driven animated-quad registry —
1860 // without this the event loop sleeps between frame intervals
1861 // and shader-driven animations only advance on unrelated
1862 // wakes (mouse move, scroll), producing a visible staircase.
1863 let animated_quad_deadline = self
1864 .animated_quads
1865 .next_deadline(&self.arena, self.paint_epoch);
1866 // Every deadline the input layer owns — a pending long press, a
1867 // press-feedback delay, a live fling simulation — folded into the one
1868 // `WaitUntil` over the one clock. Without the fling term a coast would
1869 // only advance on unrelated wakes, which is a list that scrolls when
1870 // the mouse happens to move.
1871 let gesture_deadline = self.next_input_deadline();
1872 let wake_at_deadline = self.pending_wake_at.get();
1873 // Per-frame-effect path (Pulse / Cycle / caret blink / drag
1874 // auto-scroll): a fixed 60 Hz deadline instead of the old
1875 // `ControlFlow::Poll` free-run, so continuous animations render
1876 // at 60 Hz regardless of the display's refresh rate. See
1877 // `frame_tick_deadline`.
1878 let frame_tick_deadline = self.frame_tick_deadline();
1879
1880 [
1881 tooltip_deadline,
1882 dwell_tooltip_deadline,
1883 delayed_overlay_deadline,
1884 auto_dismiss_deadline,
1885 pointer_leave_deadline,
1886 fade_dismiss_deadline,
1887 animation_deadline,
1888 animated_quad_deadline,
1889 gesture_deadline,
1890 wake_at_deadline,
1891 frame_tick_deadline,
1892 ]
1893 .into_iter()
1894 .flatten()
1895 .min()
1896 }
1897
1898 pub fn overlay_manager(&self) -> &crate::overlay::OverlayManager {
1899 &self.overlay_manager
1900 }
1901
1902 /// Mutable access to the overlay manager. Used by the
1903 /// modal-presentation pipeline to wire up cascade-dismissal
1904 /// between paired overlays (e.g. the dialog scrim and the modal
1905 /// panel) via `OverlayManager::set_parent_overlay`.
1906 pub fn overlay_manager_mut(&mut self) -> &mut crate::overlay::OverlayManager {
1907 &mut self.overlay_manager
1908 }
1909
1910 pub fn active_overlays(&self) -> Vec<crate::overlay::OverlayId> {
1911 self.overlay_manager.active_ids()
1912 }
1913
1914 /// Laid-out bounds of an open overlay's content surface.
1915 ///
1916 /// This is the size the overlay pass actually measured — taken with an
1917 /// *unbounded* proposal, independent of the host tree's own proposal — so
1918 /// it is the right thing to assert against for content that must cap or
1919 /// wrap itself (tooltips against `TOOLTIP_MAX_WIDTH`, popovers against
1920 /// their max height). Reading `bounds(content_id)` instead would report
1921 /// whatever the surrounding layout handed the widget.
1922 pub fn overlay_content_bounds(
1923 &self,
1924 id: crate::overlay::OverlayId,
1925 ) -> Option<teksilo_canvas::Rect> {
1926 self.overlay_manager
1927 .stack
1928 .iter()
1929 .find(|o| o.id == id)
1930 .map(|o| o.bounds)
1931 }
1932
1933 pub fn show_overlay(
1934 &mut self,
1935 request: crate::overlay::OverlayRequest,
1936 ) -> crate::overlay::OverlayId {
1937 let fade_duration = request.fade_duration;
1938 let content_id = request.content_id;
1939 let is_modal = matches!(
1940 request.placement,
1941 crate::overlay::OverlayPlacement::Centered
1942 );
1943 let id = self.overlay_manager.show(request);
1944 self.cancel_pointers_for_modal(is_modal);
1945 // The overlay's content subtree just entered the active set;
1946 // the AT tree shape changed and the cached snapshot must be
1947 // rebuilt. The dismiss path already flips this; we must mirror
1948 // it here, otherwise a popup show + read AT sequence returns
1949 // the pre-popup snapshot. The unconditional `a11y_dirty = true`
1950 // in `layout()` previously masked this gap; now this explicit
1951 // set is required.
1952 self.a11y_dirty = true;
1953 if let Some(duration) = fade_duration {
1954 self.attach_overlay_fade(id, content_id, duration);
1955 }
1956 id
1957 }
1958
1959 /// Show an overlay in an explicit z-band.
1960 ///
1961 /// [`show_overlay`](Self::show_overlay) is this with
1962 /// [`OverlayBand::Standard`](crate::overlay::OverlayBand::Standard). The
1963 /// other band is for the touch text affordances — selection handles, the
1964 /// magnifier, the selection toolbar — which must render above the editor's
1965 /// `clips_children` ancestor, below every menu, and outside the
1966 /// outside-press dismissal that every caret-moving tap would otherwise
1967 /// trigger. See [`crate::overlay::text_affordance`].
1968 pub fn show_overlay_in_band(
1969 &mut self,
1970 request: crate::overlay::OverlayRequest,
1971 band: crate::overlay::OverlayBand,
1972 ) -> crate::overlay::OverlayId {
1973 let fade_duration = request.fade_duration;
1974 let content_id = request.content_id;
1975 let is_modal = matches!(
1976 request.placement,
1977 crate::overlay::OverlayPlacement::Centered
1978 );
1979 let id = self.overlay_manager.show_in_band(request, band);
1980 self.cancel_pointers_for_modal(is_modal);
1981 self.a11y_dirty = true;
1982 if let Some(duration) = fade_duration {
1983 self.attach_overlay_fade(id, content_id, duration);
1984 }
1985 id
1986 }
1987
1988 /// Show an overlay relative to a source widget, inheriting the source
1989 /// overlay ancestry and focus-restore behavior used during event dispatch.
1990 pub fn show_overlay_from_source(
1991 &mut self,
1992 source_widget: WidgetId,
1993 mut request: crate::overlay::OverlayRequest,
1994 ) -> crate::overlay::OverlayId {
1995 if request.parent_overlay.is_none() {
1996 request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1997 }
1998 if let Some(existing) = self.overlay_manager.find_by_content(request.content_id) {
1999 return existing;
2000 }
2001
2002 let fade_duration = request.fade_duration;
2003 let content_id = request.content_id;
2004 let is_modal = matches!(
2005 request.placement,
2006 crate::overlay::OverlayPlacement::Centered
2007 );
2008 let current_focus = self.focused;
2009 let id = self.overlay_manager.show(request);
2010 self.cancel_pointers_for_modal(is_modal);
2011 self.a11y_dirty = true;
2012 if let Some(focus_id) = current_focus {
2013 self.overlay_manager.set_top_focus_restore(focus_id);
2014 }
2015 if let Some(duration) = fade_duration {
2016 self.attach_overlay_fade(id, content_id, duration);
2017 }
2018 id
2019 }
2020
2021 pub fn show_overlay_for(
2022 &mut self,
2023 request: crate::overlay::OverlayRequest,
2024 duration: std::time::Duration,
2025 ) -> crate::overlay::OverlayId {
2026 let fade_duration = request.fade_duration;
2027 let content_id = request.content_id;
2028 let is_modal = matches!(
2029 request.placement,
2030 crate::overlay::OverlayPlacement::Centered
2031 );
2032 let id = self.overlay_manager.show_for(request, duration);
2033 self.cancel_pointers_for_modal(is_modal);
2034 self.overlay_manager.set_shown_at_sim(id, self.sim_clock);
2035 self.a11y_dirty = true;
2036 if let Some(fade) = fade_duration {
2037 self.attach_overlay_fade(id, content_id, fade);
2038 }
2039 id
2040 }
2041
2042 /// Internal: install an animated opacity scope on `content_id`,
2043 /// kick off the 0→1 fade-in tween, and register the signal with
2044 /// the overlay manager so the matching fade-out plays on
2045 /// dismiss. Owner of the animated signal is `content_id` itself
2046 /// — the visibility-gate fix from the scheduler ensures the
2047 /// fade-in still ticks even when the content is freshly inserted
2048 /// and not yet stamped with a paint epoch.
2049 fn attach_overlay_fade(
2050 &mut self,
2051 overlay_id: crate::overlay::OverlayId,
2052 content_id: WidgetId,
2053 duration: std::time::Duration,
2054 ) {
2055 let opacity = crate::signal::Signal::<f32>::new_animated(0.0);
2056 self.register_animated_signal(&opacity, content_id);
2057 self.set_opacity(content_id, opacity.clone());
2058 // Audit G16 (WCAG 2.3.3 / EN 301 549 11.7): honour reduced motion —
2059 // snap the overlay to fully visible with no fade-in tween, and register
2060 // a zero-duration fade so dismissal snaps to 0 as well.
2061 if self.prefers_reduced_motion() {
2062 opacity.set(1.0);
2063 self.overlay_manager
2064 .attach_fade(overlay_id, opacity, std::time::Duration::ZERO);
2065 return;
2066 }
2067 let _ = opacity.try_animate_with_options(crate::animation::AnimationRequest {
2068 target: 1.0,
2069 duration,
2070 easing: teksilo_tokens::Easing::EaseOut,
2071 frame_interval: None,
2072 looping: false,
2073 epsilon: 0.0,
2074 max_duration: None,
2075 });
2076 self.overlay_manager
2077 .attach_fade(overlay_id, opacity, duration);
2078 }
2079
2080 /// Dismiss an overlay programmatically. Uses
2081 /// [`NoopWindowOps`](crate::window::NoopWindowOps) for any
2082 /// focus-loss handlers it triggers — user code fires these from
2083 /// outside a dispatch.
2084 pub fn dismiss_overlay(&mut self, id: crate::overlay::OverlayId) {
2085 let mut noop = crate::window::NoopWindowOps;
2086 self.dismiss_overlay_with_ops(id, &mut noop);
2087 }
2088
2089 /// Dispatch-path variant that threads `ops` through to the
2090 /// focus-loss handler fired during dismissal.
2091 pub fn dismiss_overlay_with_ops(
2092 &mut self,
2093 id: crate::overlay::OverlayId,
2094 ops: &mut dyn crate::window::WindowOps,
2095 ) {
2096 let dismissed = self.overlay_manager.dismiss(id);
2097 self.dormant_dismissed_content(&dismissed, &mut *ops);
2098 }
2099
2100 /// A modal just opened. Every live pointer loses its interaction: the
2101 /// surface it was working on is now behind a scrim it cannot reach, and
2102 /// the `Up` that would have completed the press lands on the modal
2103 /// instead.
2104 ///
2105 /// A modal is a `Centered` overlay — the same discriminator
2106 /// [`modal_overlay_for_widget`](Self::modal_overlay_for_widget) uses, so
2107 /// the two cannot drift. Every other placement (a menu, a popover, a
2108 /// tooltip, a drag preview) opens *over* an interaction that legitimately
2109 /// continues, and must not cancel anything.
2110 fn cancel_pointers_for_modal(&mut self, is_modal: bool) {
2111 if !is_modal {
2112 return;
2113 }
2114 let mut noop = crate::window::NoopWindowOps;
2115 self.cancel_all_pointers(crate::pointer::CancelReason::ModalOpened, &mut noop);
2116 }
2117
2118 /// Run the dismissal callbacks the overlay manager parked.
2119 ///
2120 /// The manager cannot run them itself: they take an `EventContext` and it
2121 /// has no tree. It is called from `dormant_dismissed_content`, which every
2122 /// dismissal path reaches while still holding `ops` and *before* focus
2123 /// returns to the trigger — so `on_dismiss` keeps the ordering its callers
2124 /// document and depend on (`present_in_tree_modal_request` restores the
2125 /// pre-modal `:focus-visible` modality in one, and the trigger must paint
2126 /// with the restored value).
2127 pub(super) fn run_pending_dismiss_callbacks(&mut self, ops: &mut dyn crate::window::WindowOps) {
2128 // A callback is allowed to dismiss another overlay, which parks more
2129 // and re-enters here through that overlay's own teardown. Bail on the
2130 // inner call and let the outer loop collect them: one level deep by
2131 // construction, not by luck.
2132 if self.draining_dismiss.replace(true) {
2133 return;
2134 }
2135 // Bounded rather than `while`: a pair of callbacks that dismiss each
2136 // other's overlay would otherwise spin. Eight is far above any real
2137 // cascade and the excess stays parked rather than being lost.
2138 for _ in 0..8 {
2139 let pending = self.overlay_manager.take_pending_dismiss();
2140 if pending.is_empty() {
2141 break;
2142 }
2143 for (cb, reason) in pending {
2144 self.run_with_event_context(&mut *ops, |ctx| cb(reason, ctx));
2145 }
2146 }
2147 self.draining_dismiss.set(false);
2148 }
2149
2150 pub(super) fn dormant_dismissed_content(
2151 &mut self,
2152 content_ids: &[WidgetId],
2153 ops: &mut dyn crate::window::WindowOps,
2154 ) {
2155 // Before anything is parked: the callback may still want to read the
2156 // content it is being told about, and its documented position is
2157 // during dismissal, ahead of the focus restore below.
2158 self.run_pending_dismiss_callbacks(&mut *ops);
2159 // Reset any tooltip entries that match a dismissed content
2160 // id so the next hover starts fresh — without this, sticky
2161 // tooltips dismissed via Escape/click-outside would keep
2162 // their `is_sticky` flag and stale `overlay_id`, and the
2163 // next hover would never re-show them.
2164 let mut any_tooltip_dismissed = false;
2165 for &id in content_ids {
2166 if let Some(entry) = self.tooltips.iter_mut().find(|e| e.content_id == id) {
2167 // A focus-promoted tip is dismissed while the focus that
2168 // summoned it is still in scope, and the restore below hands
2169 // that focus straight back to the anchor — which re-enters
2170 // `tooltip_focus_enter` on an entry whose `overlay_id` this
2171 // very line has just cleared. Mute it until focus genuinely
2172 // leaves, or Escape would close and reopen in one keystroke.
2173 entry.suppressed_until_focus_leaves = entry.promoted_by_focus;
2174 entry.overlay_id = None;
2175 entry.is_sticky = false;
2176 entry.hover_start = None;
2177 entry.real_hover_start = None;
2178 entry.hover_origin = None;
2179 entry.shown_at_sim = None;
2180 entry.shown_at_real = None;
2181 entry.promoted_by_focus = false;
2182 entry.armed_by_focus = false;
2183 entry.armed_by_hold = false;
2184 if let Some(sink) = entry.shown_at_sink.as_ref() {
2185 sink.set(None);
2186 }
2187 any_tooltip_dismissed = true;
2188 }
2189 }
2190 if any_tooltip_dismissed {
2191 let grace = super::TOOLTIP_SESSION_GRACE;
2192 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
2193 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
2194 }
2195 for &id in content_ids {
2196 let focused_in_subtree = self
2197 .focused
2198 .filter(|focused| self.is_descendant_of(*focused, id));
2199 let hovered_in_subtree = self
2200 .hovered_id()
2201 .filter(|hovered| self.is_descendant_of(*hovered, id));
2202
2203 if let Some(focused) = focused_in_subtree {
2204 self.dispatch_to_widget(focused, &WidgetEvent::FocusLost, &mut *ops);
2205 if self
2206 .focused
2207 .is_some_and(|current| self.is_descendant_of(current, id))
2208 {
2209 let old = self.focused;
2210 self.set_focused(None);
2211 self.focus_origin = None;
2212 self.update_focus_within_signals(old, None);
2213 self.update_view_focus_signals(old, None);
2214 }
2215 }
2216
2217 // A pointer anchored inside the overlay about to be parked would
2218 // be stranded on a widget that no longer takes events. Cancel it —
2219 // but only if there is still a press to revoke, which is what lets
2220 // a tap on a menu item whose own handler closes that menu complete
2221 // instead of cancelling itself. See `press_is_revocable`.
2222 self.cancel_pointers_in_subtree(
2223 id,
2224 crate::pointer::CancelReason::OverlayDismissed,
2225 &mut *ops,
2226 );
2227 let _parked = self.arena.set_dormant(id);
2228
2229 if hovered_in_subtree.is_some() {
2230 let old = self.hovered_id();
2231 self.set_hovered(None);
2232 self.update_hover_within_signals(old, None);
2233 }
2234 }
2235 if !content_ids.is_empty() {
2236 self.cached_frame = None;
2237 self.a11y_dirty = true;
2238 }
2239 }
2240
2241 pub fn is_visible(&self, id: WidgetId) -> bool {
2242 self.arena.is_active(id)
2243 }
2244}
2245
2246#[cfg(test)]
2247mod tests {
2248 use super::*;
2249 use crate::test_widgets::FillWidget;
2250
2251 #[test]
2252 fn is_visible_reflects_dormancy() {
2253 let mut tree = WidgetTree::new();
2254 let widget = tree.add(FillWidget::new());
2255 tree.layout(SizeProposal::exact(100.0, 50.0));
2256
2257 assert!(tree.is_visible(widget));
2258 tree.set_dormant(widget);
2259 assert!(!tree.is_visible(widget));
2260 tree.activate(widget);
2261 assert!(tree.is_visible(widget));
2262 }
2263
2264 #[test]
2265 fn frame_tick_deadline_caps_per_frame_effects_at_60hz() {
2266 let mut tree = WidgetTree::new();
2267 // Not armed → no per-frame-effect deadline.
2268 assert!(tree.frame_tick_deadline().is_none());
2269
2270 // Arm the per-frame-effect path (what Pulse / Cycle / caret blink
2271 // / drag auto-scroll do via `request_frame` or the render re-arm).
2272 tree.request_frame();
2273 // Pace from a known frame time so the interval is assertable.
2274 let t0 = std::time::Instant::now();
2275 tree.last_frame_time = Some(t0);
2276
2277 let deadline = tree
2278 .frame_tick_deadline()
2279 .expect("an armed per-frame effect must publish a deadline");
2280 let interval = deadline.saturating_duration_since(t0);
2281 // 60 Hz == 16.667 ms; the cap replaces the old ControlFlow::Poll
2282 // free-run (which rendered at the display's full refresh rate).
2283 assert!(
2284 interval >= std::time::Duration::from_micros(16_000)
2285 && interval <= std::time::Duration::from_micros(17_500),
2286 "per-frame effects must pace at ~60 Hz (got {interval:?})"
2287 );
2288
2289 // It must flow through next_timer_deadline so the event loop uses
2290 // WaitUntil rather than the removed Poll free-run.
2291 assert_eq!(
2292 tree.next_timer_deadline(),
2293 Some(deadline),
2294 "frame-tick deadline must be surfaced by next_timer_deadline"
2295 );
2296 }
2297
2298 #[test]
2299 fn throttled_subscriber_stretches_deadline_but_per_frame_wins_min() {
2300 use crate::test_widgets::{FillWidget, StackWidget};
2301 let mut tree = WidgetTree::new();
2302 let w = tree.add(FillWidget::new());
2303 let w2 = tree.add(FillWidget::new());
2304 tree.add(StackWidget::new().child(w).child(w2));
2305 // Cycle-style throttled subscription: wake at most once per 1.5 s.
2306 let _throttled =
2307 tree.subscribe_frame_tick_throttled(w, std::time::Duration::from_millis(1500));
2308 // Armed the way a subscription is, by a render that painted its
2309 // owner. Not by `request_frame`: that is due at 60 Hz whatever is
2310 // subscribed (`a_raw_frame_request_is_not_paced_by_a_throttled_subscriber`).
2311 tree.layout(SizeProposal::exact(200.0, 100.0));
2312 let _ = tree.render();
2313 assert!(
2314 !tree.frame_tick_requested.get(),
2315 "precondition: nothing but the subscriber wants this frame"
2316 );
2317 let t0 = tree.last_frame_time.expect("a frame has run");
2318
2319 // The render painted the subscriber, so its throttled interval governs.
2320 let d = tree.frame_tick_deadline().expect("armed");
2321 let dt = d.saturating_duration_since(t0);
2322 assert!(
2323 dt >= std::time::Duration::from_millis(1490)
2324 && dt <= std::time::Duration::from_millis(1510),
2325 "a lone throttled subscriber must pace at its interval (~1.5 s), got {dt:?}"
2326 );
2327
2328 // A per-frame subscriber pulls the subscribers' deadline back to
2329 // 60 Hz: it is the minimum interval across visible subscribers, so a
2330 // Cycle sharing a tree with a Pulse rides the Pulse's cadence. `w2`
2331 // was painted by the same render, so it counts as visible.
2332 let _per_frame = tree.subscribe_frame_tick(w2);
2333 let d2 = tree.frame_tick_deadline().expect("armed");
2334 let dt2 = d2.saturating_duration_since(t0);
2335 assert!(
2336 dt2 <= std::time::Duration::from_millis(20),
2337 "a per-frame subscriber must pull the shared deadline to 60 Hz, got {dt2:?}"
2338 );
2339 }
2340
2341 /// A throttled subscriber stretches its own wake and nothing else's. A
2342 /// caret, a drag auto-scroll or the announcer asking for one more frame
2343 /// is due at the normal 60 Hz pace even while a once-a-minute clock is on
2344 /// screen, and when that request has been served the clock is back to
2345 /// pacing alone.
2346 #[test]
2347 fn a_raw_frame_request_is_not_paced_by_a_throttled_subscriber() {
2348 use crate::test_widgets::FillWidget;
2349 const NORMAL_FRAME: std::time::Duration = std::time::Duration::from_micros(17_500);
2350 const CLOCK: std::time::Duration = std::time::Duration::from_secs(60);
2351 fn frame(tree: &mut WidgetTree) {
2352 tree.layout(SizeProposal::exact(200.0, 100.0));
2353 let _ = tree.render();
2354 }
2355 fn wait(tree: &WidgetTree) -> std::time::Duration {
2356 let deadline = tree.frame_tick_deadline().expect("armed");
2357 deadline.saturating_duration_since(tree.last_frame_time.expect("a frame has run"))
2358 }
2359
2360 let mut tree = WidgetTree::new();
2361 let clock = tree.add(FillWidget::new());
2362 let _tick = tree.subscribe_frame_tick_throttled(clock, CLOCK);
2363 frame(&mut tree);
2364 assert!(
2365 wait(&tree) >= CLOCK - NORMAL_FRAME,
2366 "precondition: the painted clock paces the loop at its own interval"
2367 );
2368
2369 // Both doors a widget reaches the flag by: the tree method, and the
2370 // shared handle stashed for use inside a tick effect.
2371 for request in [
2372 (|tree: &WidgetTree| tree.request_frame()) as fn(&WidgetTree),
2373 |tree: &WidgetTree| tree.frame_request_handle().set(true),
2374 ] {
2375 request(&tree);
2376 assert!(
2377 wait(&tree) <= NORMAL_FRAME,
2378 "a requested frame must not wait on the clock (waited {:?})",
2379 wait(&tree)
2380 );
2381 assert_eq!(tree.next_timer_deadline(), tree.frame_tick_deadline());
2382
2383 frame(&mut tree);
2384 assert!(
2385 wait(&tree) >= CLOCK - NORMAL_FRAME,
2386 "once the request is served the clock paces alone again, rather than the loop \
2387 free-running at 60 Hz (waited {:?})",
2388 wait(&tree)
2389 );
2390 }
2391 }
2392
2393 /// The render-end re-arm has a flag of its own, so it needs what the raw
2394 /// request flag always had: `frame_requested` reports it, and each door
2395 /// that fires the tick (`layout`, and `advance_time` for a tree on
2396 /// simulated time) spends it. An arm nothing spends outlives its owner: a
2397 /// subscriber parked in a hidden `Switcher` branch would go on waking the
2398 /// loop at 60 Hz, which is the bug the scheduler exists to prevent. No
2399 /// test in the workspace failed when the arm was left unspent, when
2400 /// `frame_requested` ignored it, or when `advance_time` skipped it.
2401 #[test]
2402 fn the_subscriber_arm_is_spent_by_its_tick_and_ends_with_its_owner() {
2403 use crate::test_widgets::StackWidget;
2404 fn frame(tree: &mut WidgetTree) {
2405 tree.layout(SizeProposal::exact(200.0, 100.0));
2406 let _ = tree.render();
2407 }
2408
2409 let mut tree = WidgetTree::new();
2410 let owner = tree.add(FillWidget::new());
2411 let sibling = tree.add(FillWidget::new());
2412 tree.add(StackWidget::new().child(owner).child(sibling));
2413 let ticks = std::rc::Rc::new(std::cell::Cell::new(0_u32));
2414 let _count = tree.frame_tick().observe({
2415 let ticks = ticks.clone();
2416 move |_| ticks.set(ticks.get() + 1)
2417 });
2418 let _sub = tree.subscribe_frame_tick(owner);
2419
2420 frame(&mut tree);
2421 assert!(
2422 tree.frame_requested(),
2423 "a render that painted the subscriber arms the next tick"
2424 );
2425 assert!(
2426 !tree.frame_tick_requested.get(),
2427 "precondition: armed by the subscribers' flag, not by a raw request"
2428 );
2429
2430 // The layout door.
2431 let before = ticks.get();
2432 tree.layout(SizeProposal::exact(200.0, 100.0));
2433 assert_eq!(
2434 ticks.get(),
2435 before + 1,
2436 "the armed tick fires on the next layout"
2437 );
2438 assert!(
2439 !tree.frame_requested(),
2440 "the layout that fired the tick has spent the arm"
2441 );
2442 let _ = tree.render();
2443 assert!(
2444 tree.frame_requested(),
2445 "the owner painted again and re-armed"
2446 );
2447
2448 // The simulated-time door the automation bridge drives.
2449 let before = ticks.get();
2450 tree.advance_time(std::time::Duration::from_millis(16));
2451 assert_eq!(ticks.get(), before + 1, "advance_time fires an armed tick");
2452 assert!(
2453 !tree.frame_requested(),
2454 "advance_time spends the arm as layout does"
2455 );
2456 tree.resume_real_time();
2457
2458 // Parked, the owner stops painting. The tick it armed while visible
2459 // still runs, once, and then nothing is left to wake the loop.
2460 frame(&mut tree);
2461 tree.set_dormant(owner);
2462 assert!(
2463 tree.frame_tick_deadline().is_some(),
2464 "the tick armed before the owner was parked is still due"
2465 );
2466 let before = ticks.get();
2467 frame(&mut tree);
2468 assert_eq!(ticks.get(), before + 1);
2469 assert!(
2470 !tree.frame_requested(),
2471 "a parked subscriber must not re-arm the tick"
2472 );
2473 assert_eq!(
2474 tree.next_timer_deadline(),
2475 None,
2476 "with its only subscriber parked the loop sleeps"
2477 );
2478 }
2479
2480 #[test]
2481 fn show_and_dismiss_overlay() {
2482 let mut tree = WidgetTree::new();
2483 let anchor = tree.add(FillWidget::new());
2484 let content = tree.add(FillWidget::new().label("Overlay"));
2485 tree.layout(SizeProposal::exact(200.0, 100.0));
2486
2487 assert!(tree.active_overlays().is_empty());
2488
2489 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2490 content_id: content,
2491 anchor,
2492 placement: crate::overlay::OverlayPlacement::Below,
2493 dismiss: crate::overlay::DismissBehavior::Manual,
2494 layer: crate::overlay::OverlayLayer::InTree,
2495 parent_overlay: None,
2496 on_dismiss: None,
2497 fade_duration: None,
2498 });
2499
2500 assert_eq!(tree.active_overlays().len(), 1);
2501
2502 tree.dismiss_overlay(id);
2503 assert!(tree.active_overlays().is_empty());
2504 assert!(!tree.is_visible(content));
2505 }
2506
2507 /// A modal (a `Centered` overlay) must count as a host surface so a
2508 /// `ComboBox` / menu / popover opened *inside* it — which closes via
2509 /// `dismiss_all_except_hosts` / `dismiss_self_overlay_chain_for_source` —
2510 /// dismisses only its own cascade and never tears down the hosting modal.
2511 #[test]
2512 fn modal_centered_overlay_is_a_host_surface() {
2513 let mut tree = WidgetTree::new();
2514 let anchor = tree.add(FillWidget::new());
2515 let modal_content = tree.add(FillWidget::new().label("Modal"));
2516 let dropdown_content = tree.add(FillWidget::new().label("Dropdown"));
2517 tree.layout(SizeProposal::exact(200.0, 100.0));
2518
2519 let modal = tree.show_overlay(crate::overlay::OverlayRequest {
2520 content_id: modal_content,
2521 anchor,
2522 placement: crate::overlay::OverlayPlacement::Centered,
2523 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2524 layer: crate::overlay::OverlayLayer::InTree,
2525 parent_overlay: None,
2526 on_dismiss: None,
2527 fade_duration: None,
2528 });
2529 let dropdown = tree.show_overlay(crate::overlay::OverlayRequest {
2530 content_id: dropdown_content,
2531 anchor,
2532 placement: crate::overlay::OverlayPlacement::Below,
2533 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2534 layer: crate::overlay::OverlayLayer::InTree,
2535 parent_overlay: Some(modal),
2536 on_dismiss: None,
2537 fade_duration: None,
2538 });
2539
2540 assert!(
2541 tree.overlay_is_host_surface(modal),
2542 "a Centered modal overlay must be treated as a host surface"
2543 );
2544 assert!(
2545 !tree.overlay_is_host_surface(dropdown),
2546 "a plain dropdown overlay is not a host surface"
2547 );
2548 }
2549
2550 #[test]
2551 fn escape_dismisses_topmost_overlay() {
2552 let mut tree = WidgetTree::new();
2553 let anchor = tree.add(FillWidget::new().focusable());
2554 let content = tree.add(FillWidget::new());
2555 tree.layout(SizeProposal::exact(200.0, 100.0));
2556 tree.focus(anchor);
2557
2558 tree.show_overlay(crate::overlay::OverlayRequest {
2559 content_id: content,
2560 anchor,
2561 placement: crate::overlay::OverlayPlacement::Below,
2562 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2563 layer: crate::overlay::OverlayLayer::InTree,
2564 parent_overlay: None,
2565 on_dismiss: None,
2566 fade_duration: None,
2567 });
2568
2569 assert_eq!(tree.active_overlays().len(), 1);
2570
2571 tree.press_key(Key::Escape, Modifiers::NONE);
2572 assert!(tree.active_overlays().is_empty());
2573 assert!(!tree.is_visible(content));
2574 }
2575
2576 /// Build two stacked overlays (a submenu over its parent menu) so the
2577 /// nested-overlay "back" key path (`overlay_manager.len() > 1`) is live.
2578 fn show_two_nested_overlays(tree: &mut WidgetTree) -> (WidgetId, WidgetId) {
2579 let anchor = tree.add(FillWidget::new());
2580 let c1 = tree.add(FillWidget::new());
2581 let c2 = tree.add(FillWidget::new());
2582 tree.layout(SizeProposal::exact(200.0, 100.0));
2583 let o1 = tree.show_overlay(crate::overlay::OverlayRequest {
2584 content_id: c1,
2585 anchor,
2586 placement: crate::overlay::OverlayPlacement::Below,
2587 dismiss: crate::overlay::DismissBehavior::Manual,
2588 layer: crate::overlay::OverlayLayer::InTree,
2589 parent_overlay: None,
2590 on_dismiss: None,
2591 fade_duration: None,
2592 });
2593 tree.show_overlay(crate::overlay::OverlayRequest {
2594 content_id: c2,
2595 anchor: c1,
2596 placement: crate::overlay::OverlayPlacement::Below,
2597 dismiss: crate::overlay::DismissBehavior::Manual,
2598 layer: crate::overlay::OverlayLayer::InTree,
2599 parent_overlay: Some(o1),
2600 on_dismiss: None,
2601 fade_duration: None,
2602 });
2603 (c1, c2)
2604 }
2605
2606 #[test]
2607 fn nested_overlay_back_key_dismisses_with_arrow_left_under_ltr() {
2608 let mut tree = WidgetTree::new();
2609 let _ = show_two_nested_overlays(&mut tree);
2610 assert_eq!(tree.active_overlays().len(), 2);
2611
2612 // Wrong-direction arrow under LTR leaves both overlays open.
2613 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2614 assert_eq!(tree.active_overlays().len(), 2);
2615
2616 // ArrowLeft (inline-start under LTR) closes the top nested overlay.
2617 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2618 assert_eq!(tree.active_overlays().len(), 1);
2619 }
2620
2621 #[test]
2622 fn nested_overlay_back_key_flips_to_arrow_right_under_rtl() {
2623 let mut tree = WidgetTree::new();
2624 tree.set_layout_direction(crate::environment::LayoutDirection::RightToLeft);
2625 let _ = show_two_nested_overlays(&mut tree);
2626 assert_eq!(tree.active_overlays().len(), 2);
2627
2628 // Under RTL, ArrowLeft navigates *into* a submenu — it must NOT
2629 // dismiss the top overlay.
2630 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2631 assert_eq!(tree.active_overlays().len(), 2);
2632
2633 // ArrowRight is the inline-start ("back toward parent") key in RTL.
2634 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2635 assert_eq!(tree.active_overlays().len(), 1);
2636 }
2637
2638 /// A focusable leaf that records the keys it is handed, so a test can ask
2639 /// the question the writer actually asks: did the keystroke reach me?
2640 #[derive(Debug)]
2641 struct KeyProbe {
2642 seen: std::rc::Rc<std::cell::RefCell<Vec<Key>>>,
2643 }
2644
2645 impl Widget for KeyProbe {
2646 fn layout_response(
2647 &self,
2648 proposal: SizeProposal,
2649 _ctx: &crate::widget::LayoutContext,
2650 ) -> crate::widget::LayoutResponse {
2651 proposal.resolve(0.0, 0.0).into()
2652 }
2653
2654 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2655 let seen = self.seen.clone();
2656 ctx.apply_self_handlers(
2657 crate::widget_builder::HandlerSet::new()
2658 .focusable(true)
2659 .on_key(move |event, _ctx| match event {
2660 WidgetEvent::KeyDown { key, .. } => {
2661 seen.borrow_mut().push(*key);
2662 crate::event::EventResponse::Handled
2663 }
2664 _ => crate::event::EventResponse::Ignored,
2665 }),
2666 );
2667 Vec::new()
2668 }
2669 }
2670
2671 /// ⚠ The regression this exists for. Every mounted text editor keeps one
2672 /// full-viewport affordance host alive in the `TextAffordance` band for its
2673 /// selection handles. Counting those alongside menus made **two editors on
2674 /// one page** — a manuscript column and the synopsis beside it — read as a
2675 /// submenu over its parent menu, so the back key dismissed an affordance
2676 /// host and returned: ArrowLeft stopped reaching any editor in the window,
2677 /// and the writer could no longer step the caret left at all.
2678 ///
2679 /// A text affordance is not a cascade level, which is exactly what its own
2680 /// band says — the same distinction `dismissed_by_outside_press` already
2681 /// makes for presses.
2682 #[test]
2683 fn back_key_ignores_text_affordance_overlays_and_reaches_the_editor() {
2684 let mut tree = WidgetTree::new();
2685 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2686 let editor = tree.add(KeyProbe { seen: seen.clone() });
2687 let handles_a = tree.add(FillWidget::new());
2688 let handles_b = tree.add(FillWidget::new());
2689 tree.layout(SizeProposal::exact(200.0, 100.0));
2690 tree.focus(editor);
2691
2692 // Two editors on one page: one affordance host apiece.
2693 for content in [handles_a, handles_b] {
2694 tree.show_overlay_in_band(
2695 crate::overlay::OverlayRequest {
2696 content_id: content,
2697 anchor: editor,
2698 placement: crate::overlay::OverlayPlacement::FullViewport,
2699 dismiss: crate::overlay::DismissBehavior::Manual,
2700 layer: crate::overlay::OverlayLayer::InTree,
2701 parent_overlay: None,
2702 on_dismiss: None,
2703 fade_duration: None,
2704 },
2705 crate::overlay::OverlayBand::TextAffordance,
2706 );
2707 }
2708 assert_eq!(tree.active_overlays().len(), 2);
2709
2710 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2711
2712 assert_eq!(
2713 tree.active_overlays().len(),
2714 2,
2715 "a text affordance is not a menu cascade level — the back key must \
2716 leave both hosts standing"
2717 );
2718 assert_eq!(
2719 seen.borrow().as_slice(),
2720 &[Key::ArrowLeft],
2721 "ArrowLeft must reach the focused editor; swallowing it is the bug \
2722 that left the caret unable to step left in any dual-pane tab"
2723 );
2724 }
2725
2726 /// The complement: a real menu cascade still navigates back, even while
2727 /// affordance hosts are mounted beneath it. The band filter must narrow the
2728 /// count, not disable the key.
2729 #[test]
2730 fn back_key_still_closes_a_submenu_over_a_mounted_text_affordance() {
2731 let mut tree = WidgetTree::new();
2732 let affordance = tree.add(FillWidget::new());
2733 let anchor = tree.add(FillWidget::new());
2734 let menu = tree.add(FillWidget::new());
2735 let submenu = tree.add(FillWidget::new());
2736 tree.layout(SizeProposal::exact(200.0, 100.0));
2737 tree.show_overlay_in_band(
2738 crate::overlay::OverlayRequest {
2739 content_id: affordance,
2740 anchor,
2741 placement: crate::overlay::OverlayPlacement::FullViewport,
2742 dismiss: crate::overlay::DismissBehavior::Manual,
2743 layer: crate::overlay::OverlayLayer::InTree,
2744 parent_overlay: None,
2745 on_dismiss: None,
2746 fade_duration: None,
2747 },
2748 crate::overlay::OverlayBand::TextAffordance,
2749 );
2750 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2751 content_id: menu,
2752 anchor,
2753 placement: crate::overlay::OverlayPlacement::Below,
2754 dismiss: crate::overlay::DismissBehavior::Manual,
2755 layer: crate::overlay::OverlayLayer::InTree,
2756 parent_overlay: None,
2757 on_dismiss: None,
2758 fade_duration: None,
2759 });
2760 tree.show_overlay(crate::overlay::OverlayRequest {
2761 content_id: submenu,
2762 anchor: menu,
2763 placement: crate::overlay::OverlayPlacement::Below,
2764 dismiss: crate::overlay::DismissBehavior::Manual,
2765 layer: crate::overlay::OverlayLayer::InTree,
2766 parent_overlay: Some(parent),
2767 on_dismiss: None,
2768 fade_duration: None,
2769 });
2770 assert_eq!(tree.active_overlays().len(), 3);
2771
2772 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2773 assert_eq!(
2774 tree.active_overlays().len(),
2775 2,
2776 "the submenu must still close: the affordance host narrows the \
2777 count, it does not disarm the back key"
2778 );
2779 }
2780
2781 /// The back key navigates *menu* cascades only — it must never close a
2782 /// dialog/alert/modal on top. A modal is a scrim+panel overlay pair, so two
2783 /// stacked modals put two (non-host) scrims in the stack, which inflates the
2784 /// "nested menu" count; guard on the *topmost* overlay being back-navigable.
2785 #[test]
2786 fn back_key_does_not_dismiss_a_dialog_on_top_of_a_modal() {
2787 let mut tree = WidgetTree::new();
2788 let anchor = tree.add(FillWidget::new());
2789 let scrim1 = tree.add(FillWidget::new());
2790 let scrim2 = tree.add(FillWidget::new());
2791 let dialog = tree.add(FillWidget::new());
2792 tree.layout(SizeProposal::exact(200.0, 100.0));
2793 // Two non-host "scrim" overlays (as the two modals' scrims would be)…
2794 for c in [scrim1, scrim2] {
2795 tree.show_overlay(crate::overlay::OverlayRequest {
2796 content_id: c,
2797 anchor,
2798 placement: crate::overlay::OverlayPlacement::Below,
2799 dismiss: crate::overlay::DismissBehavior::Manual,
2800 layer: crate::overlay::OverlayLayer::InTree,
2801 parent_overlay: None,
2802 on_dismiss: None,
2803 fade_duration: None,
2804 });
2805 }
2806 // …with a Centered (host) dialog panel on top.
2807 tree.show_overlay(crate::overlay::OverlayRequest {
2808 content_id: dialog,
2809 anchor,
2810 placement: crate::overlay::OverlayPlacement::Centered,
2811 dismiss: crate::overlay::DismissBehavior::Manual,
2812 layer: crate::overlay::OverlayLayer::InTree,
2813 parent_overlay: None,
2814 on_dismiss: None,
2815 fade_duration: None,
2816 });
2817 assert_eq!(tree.active_overlays().len(), 3);
2818
2819 // The nested-menu count is 2 (the scrims), but the top is a host dialog,
2820 // so the back key must leave it alone (Escape / its buttons dismiss it).
2821 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2822 assert_eq!(
2823 tree.active_overlays().len(),
2824 3,
2825 "the back key must not close a dialog sitting on top of a modal"
2826 );
2827 }
2828
2829 #[test]
2830 fn escape_does_not_dismiss_manual_overlay() {
2831 let mut tree = WidgetTree::new();
2832 let anchor = tree.add(FillWidget::new().focusable());
2833 let content = tree.add(FillWidget::new());
2834 tree.layout(SizeProposal::exact(200.0, 100.0));
2835 tree.focus(anchor);
2836
2837 tree.show_overlay(crate::overlay::OverlayRequest {
2838 content_id: content,
2839 anchor,
2840 placement: crate::overlay::OverlayPlacement::Below,
2841 dismiss: crate::overlay::DismissBehavior::Manual,
2842 layer: crate::overlay::OverlayLayer::InTree,
2843 parent_overlay: None,
2844 on_dismiss: None,
2845 fade_duration: None,
2846 });
2847
2848 assert_eq!(tree.active_overlays().len(), 1);
2849
2850 tree.press_key(Key::Escape, Modifiers::NONE);
2851 // Manual overlays should NOT be dismissed by Escape
2852 assert_eq!(tree.active_overlays().len(), 1);
2853 }
2854
2855 #[test]
2856 fn click_outside_dismisses_overlay() {
2857 let mut tree = WidgetTree::new();
2858 let anchor = tree.add(FillWidget::new());
2859 let content = tree.add(FillWidget::new());
2860 tree.layout(SizeProposal::exact(200.0, 100.0));
2861
2862 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2863 content_id: content,
2864 anchor,
2865 placement: crate::overlay::OverlayPlacement::Below,
2866 dismiss: crate::overlay::DismissBehavior::ClickOutside,
2867 layer: crate::overlay::OverlayLayer::InTree,
2868 parent_overlay: None,
2869 on_dismiss: None,
2870 fade_duration: None,
2871 });
2872
2873 tree.overlay_manager
2874 .set_content_bounds(overlay, teksilo_canvas::Size::new(100.0, 50.0));
2875
2876 assert_eq!(tree.active_overlays().len(), 1);
2877
2878 tree.dispatch_event(WidgetEvent::pointer_down(
2879 Point::new(500.0, 500.0),
2880 PointerButton::Primary,
2881 Modifiers::NONE,
2882 ));
2883 assert!(tree.active_overlays().is_empty());
2884 assert!(!tree.is_visible(content));
2885 }
2886
2887 #[test]
2888 fn cascade_dismissal() {
2889 let mut tree = WidgetTree::new();
2890 let anchor = tree.add(FillWidget::new());
2891 let content_a = tree.add(FillWidget::new());
2892 let content_b = tree.add(FillWidget::new());
2893 tree.layout(SizeProposal::exact(200.0, 100.0));
2894
2895 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2896 content_id: content_a,
2897 anchor,
2898 placement: crate::overlay::OverlayPlacement::Below,
2899 dismiss: crate::overlay::DismissBehavior::Manual,
2900 layer: crate::overlay::OverlayLayer::InTree,
2901 parent_overlay: None,
2902 on_dismiss: None,
2903 fade_duration: None,
2904 });
2905 tree.show_overlay(crate::overlay::OverlayRequest {
2906 content_id: content_b,
2907 anchor: content_a,
2908 placement: crate::overlay::OverlayPlacement::TrailingEdge,
2909 dismiss: crate::overlay::DismissBehavior::Manual,
2910 layer: crate::overlay::OverlayLayer::InTree,
2911 parent_overlay: Some(parent),
2912 on_dismiss: None,
2913 fade_duration: None,
2914 });
2915
2916 assert_eq!(tree.active_overlays().len(), 2);
2917
2918 tree.dismiss_overlay(parent);
2919 assert!(tree.active_overlays().is_empty());
2920 assert!(!tree.is_visible(content_a));
2921 assert!(!tree.is_visible(content_b));
2922 }
2923
2924 #[test]
2925 fn dismissed_overlay_content_is_dormant_and_invisible() {
2926 let mut tree = WidgetTree::new();
2927 let anchor = tree.add(FillWidget::new());
2928 let content = tree.add(FillWidget::new());
2929 tree.layout(SizeProposal::exact(800.0, 600.0));
2930
2931 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2932 content_id: content,
2933 anchor,
2934 placement: crate::overlay::OverlayPlacement::Below,
2935 dismiss: crate::overlay::DismissBehavior::Manual,
2936 layer: crate::overlay::OverlayLayer::InTree,
2937 parent_overlay: None,
2938 on_dismiss: None,
2939 fade_duration: None,
2940 });
2941
2942 tree.layout(SizeProposal::exact(800.0, 600.0));
2943
2944 tree.dismiss_overlay(id);
2945 assert!(!tree.is_visible(content));
2946
2947 tree.layout(SizeProposal::exact(800.0, 600.0));
2948
2949 let center = tree.bounds(content).center();
2950 let hit = tree.hit_test(center);
2951 assert_ne!(hit, Some(content));
2952
2953 let _frame = tree.render();
2954 assert!(!tree.is_visible(content));
2955 }
2956
2957 #[test]
2958 fn tooltip_appears_after_delay() {
2959 let mut tree = WidgetTree::new();
2960 let anchor = tree.add(FillWidget::new());
2961 let tooltip = tree.add(FillWidget::new().label("Tooltip text"));
2962 tree.layout(SizeProposal::exact(200.0, 100.0));
2963
2964 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2965
2966 let center = tree.bounds(anchor).center();
2967 tree.pointer_move(center);
2968 assert!(tree.active_overlays().is_empty());
2969
2970 tree.advance_time(std::time::Duration::from_millis(600));
2971
2972 assert_eq!(tree.active_overlays().len(), 1);
2973 assert!(tree.find_by_label("Tooltip text").is_some());
2974 }
2975
2976 #[test]
2977 fn tooltip_survives_theme_switch() {
2978 // Regression: switching themes used to wipe the tooltip
2979 // registry, so subsequent hovers found nothing to show. Theme
2980 // changes don't rebuild widgets (they only update the theme
2981 // signal), so the registry must be preserved.
2982 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
2983 let anchor = tree.add(FillWidget::new());
2984 let tooltip = tree.add(FillWidget::new().label("Tip"));
2985 tree.layout(SizeProposal::exact(200.0, 100.0));
2986
2987 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2988
2989 tree.set_theme(crate::presets::intui::dark());
2990
2991 tree.pointer_move(tree.bounds(anchor).center());
2992 tree.advance_time(std::time::Duration::from_millis(600));
2993
2994 assert_eq!(tree.active_overlays().len(), 1);
2995 assert!(tree.find_by_label("Tip").is_some());
2996 }
2997
2998 #[test]
2999 fn tooltip_suppressed_when_hovering_anchor_owned_overlay_content() {
3000 // Regression: a tooltip attached to an anchor (a ComboBox, a
3001 // PopoverButton, a menu trigger, …) must not re-trigger while
3002 // the pointer is over content the anchor opened as an overlay.
3003 // Those overlays keep their content as an arena child of the
3004 // anchor (for hit-test / a11y / teardown), so a plain
3005 // descendant walk would treat hovering a dropdown row as
3006 // hovering the anchor's own chrome.
3007 let mut tree = WidgetTree::new();
3008 let anchor = tree.add(FillWidget::new());
3009 // Overlay content + a row inside it, both arena children of the
3010 // anchor — exactly the ComboBox dropdown shape.
3011 let panel = tree.add_child(anchor, FillWidget::new());
3012 let row = tree.add_child(panel, FillWidget::new());
3013 let tip = tree.add(FillWidget::new().label("Tip"));
3014 tree.layout(SizeProposal::exact(200.0, 100.0));
3015
3016 let delay = std::time::Duration::from_millis(100);
3017 tree.attach_tooltip(anchor, tip, delay);
3018
3019 // Sanity: hovering the anchor's own chrome starts the timer and
3020 // the tooltip appears.
3021 tree.tooltip_pointer_enter(anchor);
3022 tree.advance_time(delay + std::time::Duration::from_millis(50));
3023 assert_eq!(
3024 tree.active_overlays().len(),
3025 1,
3026 "tooltip should appear when hovering the anchor itself"
3027 );
3028 // Dismiss the first tooltip before the next scenario: move the pointer
3029 // away and let the 100ms hoverable grace (audit G12) expire.
3030 tree.pointer_move(Point::new(500.0, 500.0));
3031 tree.advance_time(std::time::Duration::from_millis(150));
3032 assert!(tree.active_overlays().is_empty());
3033
3034 // Open the panel as an overlay anchored to the anchor.
3035 tree.show_overlay(crate::overlay::OverlayRequest {
3036 content_id: panel,
3037 anchor,
3038 placement: crate::overlay::OverlayPlacement::Below,
3039 dismiss: crate::overlay::DismissBehavior::Manual,
3040 layer: crate::overlay::OverlayLayer::InTree,
3041 parent_overlay: None,
3042 on_dismiss: None,
3043 fade_duration: None,
3044 });
3045 assert_eq!(tree.active_overlays().len(), 1);
3046
3047 // Hovering a row inside the overlay must NOT start the anchor's
3048 // tooltip: the hover lands on overlay content, not anchor chrome.
3049 tree.tooltip_pointer_enter(row);
3050 tree.advance_time(delay + std::time::Duration::from_millis(50));
3051 assert_eq!(
3052 tree.active_overlays().len(),
3053 1,
3054 "anchor tooltip must not leak onto its own overlay's rows"
3055 );
3056 }
3057
3058 #[test]
3059 fn tooltip_inside_overlay_still_fires() {
3060 // The overlay gate must not over-reach: a tooltip whose anchor
3061 // is *itself* inside the overlay (a dropdown row with its own
3062 // tooltip) still fires when that row is hovered.
3063 let mut tree = WidgetTree::new();
3064 let host = tree.add(FillWidget::new());
3065 let panel = tree.add_child(host, FillWidget::new());
3066 let row = tree.add_child(panel, FillWidget::new());
3067 let tip = tree.add(FillWidget::new().label("Row tip"));
3068 tree.layout(SizeProposal::exact(200.0, 100.0));
3069
3070 let delay = std::time::Duration::from_millis(100);
3071 // Anchor is the row, which lives inside the overlay's content.
3072 tree.attach_tooltip(row, tip, delay);
3073
3074 tree.show_overlay(crate::overlay::OverlayRequest {
3075 content_id: panel,
3076 anchor: host,
3077 placement: crate::overlay::OverlayPlacement::Below,
3078 dismiss: crate::overlay::DismissBehavior::Manual,
3079 layer: crate::overlay::OverlayLayer::InTree,
3080 parent_overlay: None,
3081 on_dismiss: None,
3082 fade_duration: None,
3083 });
3084 assert_eq!(tree.active_overlays().len(), 1);
3085
3086 tree.tooltip_pointer_enter(row);
3087 tree.advance_time(delay + std::time::Duration::from_millis(50));
3088 assert_eq!(
3089 tree.active_overlays().len(),
3090 2,
3091 "a row's own tooltip should still fire inside an overlay"
3092 );
3093 }
3094
3095 #[test]
3096 fn tooltip_dismissed_on_pointer_leave() {
3097 let mut tree = WidgetTree::new();
3098 let anchor = tree.add(FillWidget::new());
3099 let tooltip = tree.add(FillWidget::new().label("Tip"));
3100 tree.layout(SizeProposal::exact(200.0, 100.0));
3101
3102 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3103
3104 tree.pointer_move(tree.bounds(anchor).center());
3105 tree.advance_time(std::time::Duration::from_millis(600));
3106 assert_eq!(tree.active_overlays().len(), 1);
3107
3108 // WCAG 1.4.13 (Hoverable, audit G12): leaving the anchor no longer
3109 // dismisses instantly — the pointer might be heading toward the
3110 // tooltip. The overlay stack's 100ms PointerLeave grace owns dismissal
3111 // once the pointer is outside BOTH the anchor and the tooltip.
3112 tree.pointer_move(Point::new(500.0, 500.0));
3113 assert_eq!(
3114 tree.active_overlays().len(),
3115 1,
3116 "tooltip persists briefly after anchor-leave (hoverable grace)"
3117 );
3118 tree.advance_time(std::time::Duration::from_millis(150));
3119 assert!(
3120 tree.active_overlays().is_empty(),
3121 "tooltip dismissed after the 100ms grace outside anchor+overlay"
3122 );
3123 }
3124
3125 #[test]
3126 fn tooltip_not_shown_if_pointer_leaves_before_delay() {
3127 let mut tree = WidgetTree::new();
3128 let anchor = tree.add(FillWidget::new());
3129 let tooltip = tree.add(FillWidget::new().label("Tip"));
3130 tree.layout(SizeProposal::exact(200.0, 100.0));
3131
3132 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3133
3134 tree.pointer_move(tree.bounds(anchor).center());
3135 tree.advance_time(std::time::Duration::from_millis(200));
3136 tree.pointer_move(Point::new(500.0, 500.0));
3137
3138 tree.advance_time(std::time::Duration::from_millis(500));
3139 assert!(tree.active_overlays().is_empty());
3140 }
3141
3142 #[test]
3143 fn tooltip_reshow_uses_short_delay_after_prior_tip() {
3144 // Windows TTDT_RESHOW: while a tip is open (or just dismissed), the
3145 // next anchor pays the short reshow delay, not the full initial delay.
3146 let mut tree = WidgetTree::new();
3147 let a = tree.add(FillWidget::new());
3148 let b = tree.add(FillWidget::new());
3149 let tip_a = tree.add(FillWidget::new().label("Tip A"));
3150 let tip_b = tree.add(FillWidget::new().label("Tip B"));
3151 tree.layout(SizeProposal::exact(400.0, 200.0));
3152
3153 let delay = std::time::Duration::from_millis(500);
3154 tree.attach_tooltip(a, tip_a, delay);
3155 tree.attach_tooltip(b, tip_b, delay);
3156
3157 // First tip: full initial delay (drive via enter so anchors may
3158 // share layout bounds without hit-test ambiguity).
3159 tree.tooltip_pointer_enter(a);
3160 tree.advance_time(std::time::Duration::from_millis(150));
3161 assert!(
3162 tree.active_overlays().is_empty(),
3163 "must not appear before full initial delay"
3164 );
3165 tree.advance_time(std::time::Duration::from_millis(400));
3166 assert_eq!(
3167 tree.active_overlays().len(),
3168 1,
3169 "first tip after initial delay"
3170 );
3171 assert!(tree.find_by_label("Tip A").is_some());
3172
3173 // While A is still shown the reshow session is active — B uses 100 ms.
3174 let mut noop = crate::window::NoopWindowOps;
3175 tree.tooltip_pointer_leave(a, &mut noop);
3176 tree.tooltip_pointer_enter(b);
3177 tree.advance_time(std::time::Duration::from_millis(120));
3178 assert!(
3179 tree.find_by_label("Tip B").is_some(),
3180 "second tip should use the short reshow delay while session is warm"
3181 );
3182 }
3183
3184 #[test]
3185 fn reshow_delay_reverts_to_full_after_the_session_grace_expires() {
3186 // The reshow session is a *session*: once the last tip has been gone
3187 // for TOOLTIP_SESSION_GRACE, a fresh hover is a new deliberate act and
3188 // pays the full delay again. Regression guard for a grace that is
3189 // never cleared (permanent 100 ms flash on every control) or cleared
3190 // too eagerly (the toolbar sweep loses its snappiness).
3191 let mut tree = WidgetTree::new();
3192 // Reduced motion removes the fade-out, so the dismissal — and with it
3193 // the start of the grace window — lands on the pass that dismisses
3194 // rather than on the one that finishes the tween.
3195 tree.set_accessibility_preferences(false, true, 1.0);
3196 let a = tree.add(FillWidget::new());
3197 let b = tree.add(FillWidget::new());
3198 let tip_a = tree.add(FillWidget::new().label("Tip A"));
3199 let tip_b = tree.add(FillWidget::new().label("Tip B"));
3200 tree.layout(SizeProposal::exact(400.0, 200.0));
3201
3202 let delay = std::time::Duration::from_millis(500);
3203 tree.attach_tooltip(a, tip_a, delay);
3204 tree.attach_tooltip(b, tip_b, delay);
3205
3206 // Warm the session, then close A and let the grace run out.
3207 tree.tooltip_pointer_enter(a);
3208 tree.advance_time(std::time::Duration::from_millis(550));
3209 assert!(tree.find_by_label("Tip A").is_some(), "first tip shown");
3210
3211 tree.pointer_move(Point::new(900.0, 900.0));
3212 tree.advance_time(std::time::Duration::from_millis(150));
3213 assert!(tree.active_overlays().is_empty(), "A dismissed on leave");
3214
3215 // Past the 1 s grace with nothing shown, the session is cold.
3216 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
3217
3218 tree.tooltip_pointer_enter(b);
3219 tree.advance_time(std::time::Duration::from_millis(150));
3220 assert!(
3221 tree.active_overlays().is_empty(),
3222 "session went cold — B must pay the FULL delay, not the 100 ms reshow"
3223 );
3224 tree.advance_time(std::time::Duration::from_millis(400));
3225 assert!(
3226 tree.find_by_label("Tip B").is_some(),
3227 "B still appears once its full delay elapses"
3228 );
3229 }
3230
3231 #[test]
3232 fn warm_reshow_scales_the_delay_rather_than_flattening_every_tier() {
3233 // The reshow shortcut is proportional (Windows TTDT_RESHOW =
3234 // TTDT_INITIAL / 5), not an absolute floor. A *heavy* 700 ms entry
3235 // exists because its content needs a longer statement of intent, so on
3236 // the warm path it must reshow at 140 ms — not collapse to the light
3237 // tier's 100 ms.
3238 let mut tree = WidgetTree::new();
3239 let light = tree.add(FillWidget::new());
3240 let heavy = tree.add(FillWidget::new());
3241 let tip_light = tree.add(FillWidget::new().label("Light"));
3242 let tip_heavy = tree.add(FillWidget::new().label("Heavy"));
3243 tree.layout(SizeProposal::exact(400.0, 200.0));
3244
3245 tree.attach_tooltip(light, tip_light, std::time::Duration::from_millis(500));
3246 tree.attach_tooltip(heavy, tip_heavy, std::time::Duration::from_millis(700));
3247
3248 // Warm the session with the light tip and leave it open.
3249 tree.tooltip_pointer_enter(light);
3250 tree.advance_time(std::time::Duration::from_millis(550));
3251 assert!(tree.find_by_label("Light").is_some(), "session warm");
3252
3253 let mut noop = crate::window::NoopWindowOps;
3254 tree.tooltip_pointer_leave(light, &mut noop);
3255 tree.tooltip_pointer_enter(heavy);
3256
3257 // 120 ms would have been enough under the old flat 100 ms clamp.
3258 tree.advance_time(std::time::Duration::from_millis(120));
3259 assert!(
3260 tree.find_by_label("Heavy").is_none(),
3261 "a heavy tooltip must not fire at the light tier's reshow delay"
3262 );
3263 // 700 * (100/500) = 140 ms.
3264 tree.advance_time(std::time::Duration::from_millis(40));
3265 assert!(
3266 tree.find_by_label("Heavy").is_some(),
3267 "heavy reshow is the scaled 140 ms"
3268 );
3269 }
3270
3271 #[test]
3272 fn a_pinned_sticky_tooltip_does_not_hold_the_session_warm() {
3273 // A sticky tip survives pointer-leave and stays up until Escape or a
3274 // click outside. Counting it as an active session would put every
3275 // other anchor on the 100 ms path for as long as it is pinned.
3276 let mut tree = WidgetTree::new();
3277 tree.set_accessibility_preferences(false, true, 1.0); // no fade deferral
3278 let a = tree.add(FillWidget::new());
3279 let b = tree.add(FillWidget::new());
3280 let tip_a = tree.add(FillWidget::new().label("Pinned"));
3281 let tip_b = tree.add(FillWidget::new().label("Other"));
3282 tree.layout(SizeProposal::exact(400.0, 200.0));
3283
3284 tree.attach_tooltip_with_sticky(
3285 a,
3286 tip_a,
3287 std::time::Duration::from_millis(500),
3288 Some(std::time::Duration::from_millis(100)),
3289 );
3290 tree.attach_tooltip(b, tip_b, std::time::Duration::from_millis(500));
3291
3292 tree.tooltip_pointer_enter(a);
3293 tree.advance_time(std::time::Duration::from_millis(550));
3294 assert!(tree.find_by_label("Pinned").is_some());
3295 tree.promote_tooltip_to_sticky(tip_a);
3296
3297 // Let the dismiss-grace from nothing elapse, then hover B.
3298 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
3299 tree.tooltip_pointer_enter(b);
3300 tree.advance_time(std::time::Duration::from_millis(150));
3301 assert!(
3302 tree.find_by_label("Other").is_none(),
3303 "a pinned sticky must not keep every other anchor on the reshow path"
3304 );
3305 }
3306
3307 #[test]
3308 fn reattaching_a_tooltip_does_not_grow_the_entry_table() {
3309 // `attach_tooltip*` is called from `build()`, so it re-runs on every
3310 // rebuild. An anchor owns at most one tooltip: without retirement the
3311 // table gains a dead row (and an orphaned, parentless content node) per
3312 // rebuild, forever — and it is scanned on every pointer move, four
3313 // times per layout pass, and once per widget in the a11y walk.
3314 let mut tree = WidgetTree::new();
3315 let anchor = tree.add(FillWidget::new());
3316 tree.layout(SizeProposal::exact(200.0, 100.0));
3317
3318 let delay = std::time::Duration::from_millis(100);
3319 for _ in 0..25 {
3320 // Each "rebuild" mints a fresh content widget, as `ctx.add` does.
3321 let tip = tree.add(FillWidget::new().label("Tip"));
3322 tree.attach_tooltip(anchor, tip, delay);
3323 assert_eq!(
3324 tree.tooltip_entry_count(),
3325 1,
3326 "an anchor must own exactly one tooltip entry across rebuilds"
3327 );
3328 }
3329
3330 // The surviving entry is the newest one and still works.
3331 tree.tooltip_pointer_enter(anchor);
3332 tree.advance_time(delay + std::time::Duration::from_millis(50));
3333 assert_eq!(
3334 tree.active_overlays().len(),
3335 1,
3336 "latest tooltip still shows"
3337 );
3338 }
3339
3340 #[test]
3341 fn destroying_an_anchor_reaps_its_tooltip_entry() {
3342 // The content widget is parentless (`ctx.add`), so the anchor's own
3343 // subtree teardown never reaches it.
3344 let mut tree = WidgetTree::new();
3345 let host = tree.add(FillWidget::new());
3346 let anchor = tree.add_child(host, FillWidget::new());
3347 let tip = tree.add(FillWidget::new().label("Tip"));
3348 tree.layout(SizeProposal::exact(200.0, 100.0));
3349
3350 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3351 assert_eq!(tree.tooltip_entry_count(), 1);
3352
3353 tree.destroy_subtree(anchor);
3354 assert_eq!(
3355 tree.tooltip_entry_count(),
3356 0,
3357 "destroying the anchor must reap its entry and content node"
3358 );
3359 }
3360
3361 #[test]
3362 fn a_leaving_tooltip_schedules_a_wake_for_its_dismissal() {
3363 // The pointer's last motion event only *starts* the PointerLeave
3364 // grace. Without a deadline for its end the loop parks in
3365 // `ControlFlow::Wait` and the tooltip hangs on screen until unrelated
3366 // input redraws the window.
3367 let mut tree = WidgetTree::new();
3368 tree.set_accessibility_preferences(false, true, 1.0); // no fade deadline
3369 let anchor = tree.add(FillWidget::new());
3370 let tip = tree.add(FillWidget::new().label("Tip"));
3371 tree.layout(SizeProposal::exact(400.0, 200.0));
3372
3373 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3374 tree.pointer_move(tree.bounds(anchor).center());
3375 tree.advance_time(std::time::Duration::from_millis(150));
3376 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
3377
3378 // Shown, pointer still inside: nothing pending, so no deadline.
3379 assert!(
3380 tree.next_timer_deadline().is_none(),
3381 "a settled tooltip under the pointer schedules nothing"
3382 );
3383
3384 // Pointer leaves — now the 100 ms grace is running and MUST be a wake
3385 // source, or nothing will ever dismiss the tooltip on an idle app.
3386 tree.pointer_move(Point::new(900.0, 900.0));
3387 assert!(
3388 tree.next_timer_deadline().is_some(),
3389 "the PointerLeave grace must contribute a wake deadline"
3390 );
3391 }
3392
3393 /// A fading-out overlay must be a wake source in its own right.
3394 ///
3395 /// Its removal is deferred by the tween's duration and fires from
3396 /// `process_overlay_fade_dismissals_*`, which only runs when something
3397 /// wakes the loop. The tween's own scheduler deadline usually is that
3398 /// something — but the scheduler withholds a deadline for an animation
3399 /// whose owner is not being painted, and an overlay dismissed before it was
3400 /// ever rendered is exactly that. Without a term of its own the surface
3401 /// then stays on the stack, its content held active, until unrelated input
3402 /// happens to redraw the window.
3403 #[test]
3404 fn a_fading_out_overlay_schedules_a_wake_for_its_deferred_removal() {
3405 let mut tree = WidgetTree::new();
3406 let anchor = tree.add(FillWidget::new());
3407 let content = tree.add(FillWidget::new().label("Faded"));
3408 tree.layout(SizeProposal::exact(200.0, 100.0));
3409
3410 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3411 content_id: content,
3412 anchor,
3413 placement: crate::overlay::OverlayPlacement::Below,
3414 dismiss: crate::overlay::DismissBehavior::Manual,
3415 layer: crate::overlay::OverlayLayer::InTree,
3416 parent_overlay: None,
3417 on_dismiss: None,
3418 fade_duration: Some(std::time::Duration::from_millis(200)),
3419 });
3420 tree.dismiss_overlay(id);
3421 assert!(
3422 tree.is_visible(content),
3423 "precondition: the removal is deferred, so there is something to wake for"
3424 );
3425
3426 // `is_some()` would pass on any of the eleven terms the fold carries,
3427 // so assert the *value*: the deadline the loop will sleep to is this
3428 // overlay's, at its fade start plus its duration. The tween's own
3429 // scheduler term is absent here — the content has never been painted,
3430 // which is the case this term exists for.
3431 let expected = tree.overlay_manager.next_fade_dismiss_deadline();
3432 assert!(
3433 expected.is_some(),
3434 "precondition: the fade start was stamped, so there is a deadline to compare against"
3435 );
3436 assert_eq!(
3437 tree.next_timer_deadline(),
3438 expected,
3439 "the wake deadline must be the deferred removal's own, not merely some deadline"
3440 );
3441 }
3442
3443 #[test]
3444 fn pressing_cancels_a_pending_dwell_and_dismisses_a_shown_tooltip() {
3445 let mut tree = WidgetTree::new();
3446 let anchor = tree.add(FillWidget::new());
3447 let tip = tree.add(FillWidget::new().label("Tip"));
3448 tree.layout(SizeProposal::exact(400.0, 200.0));
3449
3450 let delay = std::time::Duration::from_millis(500);
3451 tree.attach_tooltip(anchor, tip, delay);
3452
3453 // Press partway through the dwell: the user has answered their own
3454 // question, so the tip must not arrive afterwards.
3455 tree.pointer_move(tree.bounds(anchor).center());
3456 tree.advance_time(std::time::Duration::from_millis(300));
3457 tree.tooltip_pointer_press(None);
3458 tree.advance_time(std::time::Duration::from_millis(400));
3459 assert!(
3460 tree.active_overlays().is_empty(),
3461 "a press must cancel the pending dwell, not merely delay it"
3462 );
3463
3464 // And a press while one is shown retires it rather than leaving it
3465 // covering the control that was just clicked.
3466 tree.tooltip_pointer_enter(anchor);
3467 tree.advance_time(delay + std::time::Duration::from_millis(50));
3468 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown again");
3469 tree.tooltip_pointer_press(Some(tree.bounds(anchor).center()));
3470 assert!(
3471 tree.active_overlays().is_empty(),
3472 "a press must dismiss the shown tooltip"
3473 );
3474 }
3475
3476 #[test]
3477 fn window_deactivation_retires_hover_tooltips() {
3478 let mut tree = WidgetTree::new();
3479 let anchor = tree.add(FillWidget::new());
3480 let tip = tree.add(FillWidget::new().label("Tip"));
3481 tree.layout(SizeProposal::exact(400.0, 200.0));
3482
3483 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3484 tree.pointer_move(tree.bounds(anchor).center());
3485 tree.advance_time(std::time::Duration::from_millis(150));
3486 assert_eq!(tree.active_overlays().len(), 1);
3487
3488 tree.set_window_active(false);
3489 assert!(
3490 tree.active_overlays().is_empty(),
3491 "a tooltip must not float over another window's chrome"
3492 );
3493 }
3494
3495 #[test]
3496 fn only_the_innermost_anchor_arms_its_dwell() {
3497 // A row inside a panel, both with tooltips. Arming both would mature
3498 // two tips and stack them on top of each other.
3499 let mut tree = WidgetTree::new();
3500 let panel = tree.add(FillWidget::new());
3501 let row = tree.add_child(panel, FillWidget::new());
3502 let panel_tip = tree.add(FillWidget::new().label("Panel"));
3503 let row_tip = tree.add(FillWidget::new().label("Row"));
3504 tree.layout(SizeProposal::exact(400.0, 200.0));
3505
3506 let delay = std::time::Duration::from_millis(100);
3507 tree.attach_tooltip(panel, panel_tip, delay);
3508 tree.attach_tooltip(row, row_tip, delay);
3509
3510 tree.tooltip_pointer_enter(row);
3511 tree.advance_time(delay + std::time::Duration::from_millis(50));
3512
3513 assert_eq!(
3514 tree.active_overlays().len(),
3515 1,
3516 "exactly one tooltip may open for a hover"
3517 );
3518 assert!(
3519 tree.find_by_label("Row").is_some(),
3520 "the innermost anchor wins"
3521 );
3522 }
3523
3524 #[test]
3525 fn escape_dismisses_a_hover_tooltip_and_falls_through_to_the_menu_below() {
3526 // WCAG 2.2 SC 1.4.13(a): hover content must be dismissible without
3527 // moving the pointer. And a tooltip raised over an open menu must not
3528 // swallow the Escape meant for the menu underneath.
3529 let mut tree = WidgetTree::new();
3530 tree.set_accessibility_preferences(false, true, 1.0); // no fade deferral
3531 let anchor = tree.add(FillWidget::new());
3532 let menu = tree.add(FillWidget::new());
3533 let tip = tree.add(FillWidget::new().label("Tip"));
3534 tree.layout(SizeProposal::exact(400.0, 200.0));
3535
3536 let menu_overlay = tree.show_overlay(crate::overlay::OverlayRequest {
3537 content_id: menu,
3538 anchor,
3539 placement: crate::overlay::OverlayPlacement::Below,
3540 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
3541 layer: crate::overlay::OverlayLayer::InTree,
3542 parent_overlay: None,
3543 on_dismiss: None,
3544 fade_duration: None,
3545 });
3546 assert_eq!(tree.active_overlays().len(), 1);
3547
3548 // Raise a tooltip on top of the menu.
3549 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3550 tree.tooltip_pointer_enter(anchor);
3551 tree.advance_time(std::time::Duration::from_millis(150));
3552 assert_eq!(
3553 tree.active_overlays().len(),
3554 2,
3555 "tooltip sits above the menu"
3556 );
3557
3558 // First Escape takes the tooltip...
3559 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3560 assert!(dismissed.is_some(), "Escape must dismiss the hover tooltip");
3561 assert_eq!(tree.active_overlays().len(), 1);
3562
3563 // ...the second reaches the menu, which was previously unreachable.
3564 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3565 assert_eq!(
3566 dismissed.map(|(id, _, _)| id),
3567 Some(menu_overlay),
3568 "Escape must then reach the menu underneath"
3569 );
3570 assert!(tree.active_overlays().is_empty());
3571 }
3572
3573 #[test]
3574 fn tooltip_timer_restarts_when_pointer_keeps_moving() {
3575 // Stationary-pointer filter: travel beyond ~4 px from hover origin
3576 // restarts the delay so a sweeping cursor does not pop tips.
3577 let mut tree = WidgetTree::new();
3578 let anchor = tree.add(FillWidget::new());
3579 let tooltip = tree.add(FillWidget::new().label("Tip"));
3580 tree.layout(SizeProposal::exact(200.0, 100.0));
3581
3582 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3583
3584 let start = tree.bounds(anchor).center();
3585 tree.pointer_move(start);
3586 tree.advance_time(std::time::Duration::from_millis(300));
3587 // Still pending; move well past the 4 px slop inside the same anchor.
3588 tree.pointer_move(Point::new(start.x + 20.0, start.y));
3589 // Another 300 ms would have completed the *original* 500 ms timer,
3590 // but the restart means we need a full 500 ms from the move.
3591 tree.advance_time(std::time::Duration::from_millis(300));
3592 assert!(
3593 tree.active_overlays().is_empty(),
3594 "moving past stationary slop must restart the delay"
3595 );
3596 tree.advance_time(std::time::Duration::from_millis(250));
3597 assert_eq!(
3598 tree.active_overlays().len(),
3599 1,
3600 "tooltip appears after a full delay of stillness"
3601 );
3602 }
3603
3604 #[test]
3605 fn timed_overlay_auto_dismisses_after_duration() {
3606 let mut tree = WidgetTree::new();
3607 let anchor = tree.add(FillWidget::new());
3608 let content = tree.add(FillWidget::new().label("Toast"));
3609 tree.layout(SizeProposal::exact(200.0, 100.0));
3610
3611 tree.show_overlay_for(
3612 crate::overlay::OverlayRequest {
3613 content_id: content,
3614 anchor,
3615 placement: crate::overlay::OverlayPlacement::Below,
3616 dismiss: crate::overlay::DismissBehavior::Manual,
3617 layer: crate::overlay::OverlayLayer::InTree,
3618 parent_overlay: None,
3619 on_dismiss: None,
3620 fade_duration: None,
3621 },
3622 std::time::Duration::from_millis(300),
3623 );
3624
3625 assert_eq!(tree.active_overlays().len(), 1);
3626
3627 tree.advance_time(std::time::Duration::from_millis(200));
3628 assert_eq!(tree.active_overlays().len(), 1);
3629
3630 tree.advance_time(std::time::Duration::from_millis(150));
3631 assert!(tree.active_overlays().is_empty());
3632 assert!(!tree.is_visible(content));
3633 }
3634
3635 #[test]
3636 fn fade_dismiss_keeps_overlay_off_active_list_immediately() {
3637 // The user-facing `active_overlays()` accessor reports a
3638 // dismissing-with-fade overlay as gone the moment dismiss is
3639 // requested — even though the fade-out tween is still
3640 // playing under the hood. Caller code asking "is this
3641 // overlay still up?" gets the expected answer; the framework
3642 // reaps the actual content on the next layout pass past the
3643 // tween deadline.
3644 let mut tree = WidgetTree::new();
3645 let anchor = tree.add(FillWidget::new());
3646 let content = tree.add(FillWidget::new().label("Faded"));
3647 tree.layout(SizeProposal::exact(200.0, 100.0));
3648
3649 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3650 content_id: content,
3651 anchor,
3652 placement: crate::overlay::OverlayPlacement::Below,
3653 dismiss: crate::overlay::DismissBehavior::Manual,
3654 layer: crate::overlay::OverlayLayer::InTree,
3655 parent_overlay: None,
3656 on_dismiss: None,
3657 fade_duration: Some(std::time::Duration::from_millis(100)),
3658 });
3659 assert_eq!(tree.active_overlays().len(), 1);
3660
3661 tree.dismiss_overlay(id);
3662 // Reported as gone immediately, even though the content
3663 // widget is still active and painting the fade-out tween —
3664 // the deferred removal happens later in
3665 // process_overlay_fade_dismissals_real.
3666 assert!(tree.active_overlays().is_empty());
3667 }
3668
3669 #[test]
3670 fn fade_dismiss_defers_content_dormancy_until_sim_tween_completes() {
3671 // Sim-clock variant of the fade-defer contract: dismiss kicks
3672 // off the fade-out tween and stamps both real- and sim-time
3673 // start markers. `advance_time` (sim-clock) past the tween
3674 // duration flushes the deferred removal via
3675 // `process_overlay_fade_dismissals_sim`.
3676 let mut tree = WidgetTree::new();
3677 let anchor = tree.add(FillWidget::new());
3678 let content = tree.add(FillWidget::new().label("Faded"));
3679 tree.layout(SizeProposal::exact(200.0, 100.0));
3680
3681 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3682 content_id: content,
3683 anchor,
3684 placement: crate::overlay::OverlayPlacement::Below,
3685 dismiss: crate::overlay::DismissBehavior::Manual,
3686 layer: crate::overlay::OverlayLayer::InTree,
3687 parent_overlay: None,
3688 on_dismiss: None,
3689 fade_duration: Some(std::time::Duration::from_millis(100)),
3690 });
3691 // Move the simulated clock **before** the dismiss. Without this the
3692 // manager's mirror — seeded with `Instant::now()` at construction —
3693 // happens to agree with the tree's sim clock, and the fade start is
3694 // stamped correctly whether or not `advance_time` ever mirrors it.
3695 // A second of virtual time is what makes the mirror load-bearing.
3696 tree.advance_time(std::time::Duration::from_secs(1));
3697
3698 tree.dismiss_overlay(id);
3699 assert!(
3700 tree.is_visible(content),
3701 "content stays active during fade-out"
3702 );
3703
3704 // Less than the tween: a stale mirror would have stamped the start a
3705 // whole second in the past, and this advance would reap the content
3706 // instead of leaving it up.
3707 tree.advance_time(std::time::Duration::from_millis(60));
3708 assert!(
3709 tree.is_visible(content),
3710 "60 ms into a 100 ms tween the content is still up"
3711 );
3712
3713 tree.advance_time(std::time::Duration::from_millis(90));
3714 assert!(
3715 !tree.is_visible(content),
3716 "after sim-time past the tween window, deferred removal fires"
3717 );
3718 }
3719
3720 /// The sim-clock mirror is refreshed **before** the dismissing passes run,
3721 /// not after them.
3722 ///
3723 /// The test above pins that `advance_time` mirrors the clock at all; this
3724 /// one pins *where in the call* it does it. An overlay dismissed from
3725 /// inside `advance_time` — by its own auto-dismiss timer — reads the
3726 /// mirror as it stands at that moment. Refresh it after the dismissing
3727 /// passes and the fade start is stamped one whole advance in the past, so
3728 /// a fade longer than nothing is over before it began: the surface is
3729 /// reaped in the same virtual frame that started fading it, and the tween
3730 /// the caller asked for never plays.
3731 #[test]
3732 fn an_auto_dismissed_fade_starts_at_the_instant_the_dismiss_ran() {
3733 let mut tree = WidgetTree::new();
3734 let anchor = tree.add(FillWidget::new());
3735 let content = tree.add(FillWidget::new().label("Toast"));
3736 tree.layout(SizeProposal::exact(200.0, 100.0));
3737
3738 tree.show_overlay_for(
3739 crate::overlay::OverlayRequest {
3740 content_id: content,
3741 anchor,
3742 placement: crate::overlay::OverlayPlacement::Below,
3743 dismiss: crate::overlay::DismissBehavior::Manual,
3744 layer: crate::overlay::OverlayLayer::InTree,
3745 parent_overlay: None,
3746 on_dismiss: None,
3747 fade_duration: Some(std::time::Duration::from_millis(100)),
3748 },
3749 std::time::Duration::from_millis(500),
3750 );
3751 assert_eq!(tree.active_overlays().len(), 1);
3752
3753 // One advance, well past the auto-dismiss deadline: the auto-dismiss
3754 // pass fires the dismiss, and the fade pass right after it must find a
3755 // tween that started *this* instant and has 100 ms to run.
3756 tree.advance_time(std::time::Duration::from_millis(600));
3757 assert!(
3758 tree.active_overlays().is_empty(),
3759 "precondition: the auto-dismiss fired inside this advance"
3760 );
3761 assert!(
3762 tree.is_visible(content),
3763 "the fade must start at the instant the dismiss ran, so the \
3764 content survives the frame that dismissed it"
3765 );
3766
3767 tree.advance_time(std::time::Duration::from_millis(150));
3768 assert!(
3769 !tree.is_visible(content),
3770 "and is reaped once the tween's own window has passed"
3771 );
3772 }
3773}