Skip to main content

teksilo_core/widget_tree/
overlay_impl.rs

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