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