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