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