Skip to main content

teksilo_core/widget_tree/
overlay_impl.rs

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