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