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