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