Skip to main content

teksilo_core/
overlay.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Overlay system for tooltips, dropdown menus, context menus, and popovers.
5//!
6//! Overlays render outside the normal layout hierarchy. They float above the
7//! main content, positioned relative to an anchor widget or the pointer.
8//! The `OverlayManager` coordinates creation, positioning, stacking, dismissal,
9//! event routing, and accessibility.
10
11use std::rc::Rc;
12use std::time::{Duration, Instant};
13
14use teksilo_canvas::{Point, Rect, Size, Vec2};
15use teksilo_tokens::Corner;
16
17use crate::environment::LayoutDirection;
18use crate::signal::Signal;
19use crate::widget_id::WidgetId;
20
21mod safe_triangle;
22
23pub(crate) use safe_triangle::point_in_safe_triangle;
24
25/// How long a submenu's safe region stays armed after the pointer
26/// leaves the trigger row.
27///
28/// The region suppresses both dismissal paths (the sibling
29/// hover-switch and the overlay's own pointer-leave grace), so it
30/// needs a ceiling: a pointer that stops inside the cone is no longer
31/// travelling, and the menu must go back to behaving normally. 600 ms
32/// is long enough for a deliberate, slow diagonal across a tall
33/// submenu and short enough that a parked pointer resolves before the
34/// user notices anything is stuck.
35pub(crate) const SAFE_REGION_BUDGET: Duration = Duration::from_millis(600);
36
37/// Callback invoked by the framework when an overlay is dismissed —
38/// regardless of the dismiss path (Escape, click outside, pointer
39/// leave, explicit API call, cascade). The anchor widget uses this
40/// hook to reset its own interaction state so that SR-facing
41/// properties like `set_expanded` on a `ComboBox` or a submenu
42/// trigger stay consistent with the actual overlay-visible state.
43///
44/// Fired exactly once per overlay lifetime, at the point the
45/// overlay is removed from the stack. `Fn` rather than `FnOnce`
46/// simply because it's easier to pass around by `Rc`; the
47/// framework only invokes it once.
48pub type OverlayDismissCallback = Rc<dyn Fn()>;
49
50/// Unique identifier for an active overlay.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct OverlayId(u64);
53
54impl OverlayId {
55    pub(crate) fn new(id: u64) -> Self {
56        Self(id)
57    }
58}
59
60/// How an overlay is positioned relative to its anchor.
61#[derive(Debug, Clone)]
62pub enum OverlayPlacement {
63    /// Below the anchor, leading-edge aligned (dropdown).
64    Below,
65    /// Above the anchor (fallback when no space below).
66    Above,
67    /// To the trailing side of the anchor (submenu).
68    TrailingEdge,
69    /// At the pointer position (context menu).
70    AtPointer(Point),
71    /// Near the anchor with a preferred alignment and offset (tooltip).
72    NearAnchor { offset: Vec2 },
73    /// Centered within the viewport (dialog).
74    Centered,
75    /// Bottom-centered within the viewport (snackbar/toast).
76    BottomCenter,
77    /// Below the anchor if space allows, otherwise above (combo box dropdown).
78    /// The viewport height is supplied by `position_overlays()` at layout time.
79    BelowPreferred,
80    /// Snaps content to a viewport corner with a per-axis margin
81    /// (used by `ToastHost` for stacked toast notifications, also
82    /// suitable for picture-in-picture, floating action overlays).
83    /// Anchor bounds are ignored. The leading/trailing axis honours
84    /// `LayoutDirection`: `TopTrailing` is top-right under LTR and
85    /// top-left under RTL.
86    ViewportCorner { corner: Corner, margin: Vec2 },
87    /// Fills the entire viewport, anchor-independent. Used by the
88    /// modal-presentation pipeline to mount a dialog scrim behind a
89    /// centered modal panel — the scrim covers the full window so the
90    /// content behind dims uniformly. Anchor bounds are ignored.
91    FullViewport,
92}
93
94/// Placement preference for a tooltip relative to its anchor. Resolved to
95/// a concrete [`OverlayPlacement`] at show time (see
96/// `WidgetTree::tooltip_overlay_placement`).
97///
98/// `Below` is the default (drop below the anchor, flip above near the
99/// viewport edge). `Side` opens to the anchor's trailing side (RTL-aware,
100/// with a leading fallback) — for anchors stacked **vertically** (menu
101/// items, a vertical tab strip, list/tree rows, a docking activity rail,
102/// a vertical `RadioTileGroup`) where a `Below` tooltip would cover the
103/// next sibling.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum TooltipPlacement {
106    /// Below the anchor (flips above near the viewport edge). The default.
107    #[default]
108    Below,
109    /// To the anchor's trailing side (RTL-aware, leading fallback).
110    Side,
111}
112
113/// When an overlay is dismissed.
114#[derive(Debug, Clone)]
115pub enum DismissBehavior {
116    /// Dismiss when the user clicks outside the overlay.
117    ClickOutside,
118    /// Dismiss when the user presses Escape.
119    EscapeKey,
120    /// Dismiss on either Escape or an outside click.
121    EscapeOrClickOutside,
122    /// Dismiss when the pointer leaves both anchor and overlay.
123    PointerLeave { delay: Duration },
124    /// Dismiss only via explicit API call.
125    Manual,
126}
127
128/// Where the overlay renders.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum OverlayLayer {
131    /// Rendered within the application window's wgpu surface.
132    InTree,
133    /// Rendered in a separate native OS window.
134    NativePopup,
135    /// Framework decides based on content size.
136    Auto,
137}
138
139/// A request to show an overlay.
140pub struct OverlayRequest {
141    /// The root widget of the overlay content.
142    pub content_id: WidgetId,
143    /// The widget this overlay is anchored to.
144    pub anchor: WidgetId,
145    /// Positioning relative to the anchor.
146    pub placement: OverlayPlacement,
147    /// How the overlay is dismissed.
148    pub dismiss: DismissBehavior,
149    /// Rendering layer.
150    pub layer: OverlayLayer,
151    /// Parent overlay (for submenu cascading).
152    pub parent_overlay: Option<OverlayId>,
153    /// Invoked when the overlay is dismissed by any path. Use this
154    /// to reset anchor-side state (e.g. `ComboBox.interaction`)
155    /// when the framework tears down the overlay without going
156    /// through the anchor's own key/tap handlers.
157    pub on_dismiss: Option<OverlayDismissCallback>,
158    /// Optional fade-in / fade-out duration. When `Some`, the
159    /// framework attaches an animated opacity scope to `content_id`
160    /// at show time (using the existing `set_opacity` rendering
161    /// pipeline — no `Fade` widget required from the caller), tweens
162    /// the opacity from 0 → 1 over `duration`, and on dismiss
163    /// reverses the tween and defers the actual stack removal by
164    /// `duration`. Construct with [`OverlayRequest::with_fade`] when
165    /// the struct-literal idiom isn't ergonomic.
166    pub fade_duration: Option<Duration>,
167}
168
169impl OverlayRequest {
170    /// Attach a fade-in / fade-out animation to this request.
171    /// `duration` controls both directions. The framework wires
172    /// everything internally — caller does not create a `Fade`
173    /// widget or manage a signal:
174    ///
175    /// ```text
176    /// let req = OverlayRequest { content_id, anchor, ... }
177    ///     .with_fade(theme.motion.duration_fast);
178    /// ```
179    pub fn with_fade(mut self, duration: Duration) -> Self {
180        self.fade_duration = Some(duration);
181        self
182    }
183}
184
185/// Fade-on-show / fade-on-dismiss state for an overlay. Populated by
186/// the framework when an [`OverlayRequest`] carries `fade_duration`.
187/// The framework owns the `Signal<f32>` (an animated 0..1 opacity)
188/// and applies it to the overlay's content via `set_opacity`, so the
189/// caller doesn't need to wrap the content in a `Fade` widget — the
190/// rendering walker's opacity scope (Item 1) does the work.
191///
192/// Mirrors the `pointer_leave_started_real/_sim` and
193/// `shown_at_real/_sim` dual-clock pattern used elsewhere in
194/// `ActiveOverlay`: the real-clock field drives the live event loop;
195/// the sim-clock field drives the headless `tick_animations` /
196/// `advance_time` test path so deterministic tests can advance the
197/// fade-out window without `std::thread::sleep`.
198#[derive(Clone)]
199pub(crate) struct OverlayFadeState {
200    /// Animated opacity (0..1) bound to the overlay's content via
201    /// `WidgetTree::set_opacity`. The framework starts the tween at
202    /// 0 and animates to 1 on show, then animates back to 0 on
203    /// dismiss before the deferred removal fires.
204    pub opacity: Signal<f32>,
205    /// Tween duration on both directions. Picked from
206    /// `theme.motion.duration_fast` for tooltip / popover and
207    /// `duration_normal` for snackbar / dialog.
208    pub duration: Duration,
209    /// `Some(start_real)` when a dismiss has been requested and the
210    /// fade-out tween has started. The real-clock processor
211    /// considers the overlay ready for removal once
212    /// `Instant::now() - start_real >= duration`.
213    pub dismissing_started_real: Option<Instant>,
214    /// `Some(start_sim)` set in lockstep with `dismissing_started_real`
215    /// using the tree's `sim_clock`. The sim-clock processor uses
216    /// it for deterministic headless tests.
217    pub dismissing_started_sim: Option<Instant>,
218}
219
220/// An active overlay in the stack.
221pub(crate) struct ActiveOverlay {
222    pub id: OverlayId,
223    pub content_id: WidgetId,
224    pub anchor: WidgetId,
225    pub placement: OverlayPlacement,
226    pub dismiss: DismissBehavior,
227    pub layer: OverlayLayer,
228    pub parent_overlay: Option<OverlayId>,
229    /// Computed bounds after positioning.
230    pub bounds: Rect,
231    /// Widget that had focus before this overlay was shown.
232    /// Used to restore focus when the overlay is dismissed.
233    pub focus_restore: Option<WidgetId>,
234    /// When pointer-leave dismissal started (real time).
235    pub pointer_leave_started_real: Option<std::time::Instant>,
236    /// When pointer-leave dismissal started (simulated time).
237    pub pointer_leave_started_sim: Option<std::time::Instant>,
238    /// Apex of the "safe triangle" — the point at which the pointer
239    /// left the anchor, armed by [`OverlayManager::arm_safe_region`].
240    /// While it is set and unexpired, a pointer inside the triangle
241    /// spanned by it and this overlay's near edge counts as still
242    /// inside the overlay's region, so the pointer-leave grace does
243    /// not run — and a pointer that strays back out only starts that
244    /// grace, keeping the apex so a course correction can stop it
245    /// again. See [`safe_triangle`].
246    pub safe_apex: Option<Point>,
247    /// When the safe region was armed (real time). Bounds it by
248    /// [`SAFE_REGION_BUDGET`].
249    pub safe_apex_started_real: Option<std::time::Instant>,
250    /// When the safe region was armed (simulated time).
251    pub safe_apex_started_sim: Option<std::time::Instant>,
252    /// Dismiss automatically after this duration, if set.
253    pub auto_dismiss_after: Option<Duration>,
254    /// While the auto-dismiss timer is paused (via
255    /// [`OverlayManager::pause_auto_dismiss`]), `auto_dismiss_after`
256    /// is cleared and the time that *would have remained* is stashed
257    /// here. [`OverlayManager::resume_auto_dismiss`] restores
258    /// `auto_dismiss_after = Some(this)` and stamps a fresh
259    /// `shown_at_*`. `None` whenever the overlay is not paused.
260    pub paused_remaining: Option<Duration>,
261    /// When the overlay was shown (real time).
262    pub shown_at_real: std::time::Instant,
263    /// When the overlay was shown (simulated time).
264    pub shown_at_sim: std::time::Instant,
265    /// Dismiss callback supplied by the show request. Invoked
266    /// exactly once when the overlay is removed from the stack,
267    /// regardless of dismiss path.
268    pub on_dismiss: Option<OverlayDismissCallback>,
269    /// Optional fade-in / fade-out state. Installed post-show by the
270    /// crate-internal `OverlayManager::attach_fade`, which
271    /// `WidgetTree::attach_overlay_fade` calls whenever the show
272    /// request carried a `fade_duration`; read back from outside
273    /// through [`OverlayManager::fade_duration`]. When `Some`, all
274    /// dismiss paths (auto, escape, click-outside, pointer-leave,
275    /// manual) defer the actual removal until the fade-out tween
276    /// completes.
277    pub fade: Option<OverlayFadeState>,
278}
279
280impl ActiveOverlay {
281    /// Whether this overlay is already on its way out — dismissed, but still
282    /// on the stack while its fade-out tween runs.
283    ///
284    /// Such an overlay still answers every stack query, so anything that
285    /// *targets* an overlay has to step over it: dismissing it a second time
286    /// collapses the tween it is in the middle of, and (for input) spends the
287    /// keystroke on a corpse while leaving whatever sits underneath
288    /// unreachable.
289    pub(crate) fn is_dismissing(&self) -> bool {
290        self.fade
291            .as_ref()
292            .is_some_and(|fade| fade.dismissing_started_real.is_some())
293    }
294}
295
296// Manual Debug impl: `Rc<dyn Fn()>` doesn't derive Debug, but the
297// surrounding systems (tests, logging) want ActiveOverlay to be
298// printable. Skip the callback field and tag it with a placeholder.
299impl std::fmt::Debug for ActiveOverlay {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("ActiveOverlay")
302            .field("id", &self.id)
303            .field("content_id", &self.content_id)
304            .field("anchor", &self.anchor)
305            .field("placement", &self.placement)
306            .field("dismiss", &self.dismiss)
307            .field("layer", &self.layer)
308            .field("parent_overlay", &self.parent_overlay)
309            .field("bounds", &self.bounds)
310            .field("focus_restore", &self.focus_restore)
311            .field(
312                "pointer_leave_started_real",
313                &self.pointer_leave_started_real,
314            )
315            .field("pointer_leave_started_sim", &self.pointer_leave_started_sim)
316            .field("safe_apex", &self.safe_apex)
317            .field("auto_dismiss_after", &self.auto_dismiss_after)
318            .field("shown_at_real", &self.shown_at_real)
319            .field("shown_at_sim", &self.shown_at_sim)
320            .field(
321                "on_dismiss",
322                &self.on_dismiss.as_ref().map(|_| "<callback>"),
323            )
324            .field("fading", &self.fade.is_some())
325            .finish()
326    }
327}
328
329/// Maximum overlay nesting depth. Bounds runaway cascades: a rich-tooltip
330/// `[label](:key)` link loop (A→B→A) keeps minting fresh nested overlays
331/// (and dormant widgets) on each hop with no natural ceiling. A real
332/// menu-submenu or tooltip cascade never gets close to this — once a new
333/// overlay would exceed it, `OverlayManager::show*` drops the request
334/// instead of growing the stack without bound.
335pub(crate) const MAX_OVERLAY_NESTING_DEPTH: usize = 12;
336
337/// Manages the overlay stack — creation, positioning, dismissal, cascading.
338/// Leading-edge-aligned x for a `Below` / `Above` overlay, clamped so the
339/// overlay stays inside the viewport.
340///
341/// In LTR the leading edge is `anchor.x`; in RTL it is the anchor's physical
342/// right edge. **Both are clamped.** The LTR arm used to be a bare `anchor.x`,
343/// which silently ran a popover off the right edge of the window whenever its
344/// trigger sat near that edge and its content was wider than the trigger — the
345/// ordinary case for a status-bar or toolbar-trailing control. The RTL arm has
346/// always clamped; there was no reason for the two to differ.
347///
348/// `max(0.0)` last, so a viewport narrower than the overlay pins it to the
349/// leading edge and clips at the trailing one, rather than pushing its start
350/// off-screen where the first thing the reader needs would be the part lost.
351fn leading_aligned_x(anchor: Rect, actual_width: f32, vw: f32, rtl: bool) -> f32 {
352    let leading = if rtl {
353        anchor.x + anchor.width - actual_width
354    } else {
355        anchor.x
356    };
357    leading.min(vw - actual_width).max(0.0)
358}
359
360pub struct OverlayManager {
361    pub(crate) stack: Vec<ActiveOverlay>,
362    next_id: u64,
363    /// Latest known sim-clock value, mirrored from
364    /// `WidgetTree::sim_clock` via [`Self::set_sim_clock`]. Read by
365    /// `dismiss` to stamp `dismissing_started_sim` in lockstep with
366    /// `dismissing_started_real`. Defaults to `Instant::now()` so
367    /// constructions outside a tree (tests of OverlayManager in
368    /// isolation) still produce sensible values.
369    sim_clock: Instant,
370    /// Monotonic counter bumped on every stack mutation (show /
371    /// dismiss). External observers — notably the inspector's Overlays
372    /// tab — bind to this signal to know when the visible overlay set
373    /// has changed without polling. Mirrors the
374    /// `ShortcutRegistry::version` pattern.
375    version: Signal<u64>,
376}
377
378impl OverlayManager {
379    pub fn new() -> Self {
380        Self {
381            stack: Vec::new(),
382            next_id: 1,
383            sim_clock: Instant::now(),
384            version: Signal::new(0),
385        }
386    }
387
388    /// Reactive handle bumped on every overlay mutation (show /
389    /// dismiss / cascade). Cheap clone. Same shape as
390    /// [`crate::shortcut::ShortcutRegistry::version`].
391    pub fn version(&self) -> &Signal<u64> {
392        &self.version
393    }
394
395    /// Bump the version signal. Called from every stack-mutating path.
396    fn bump_version(&self) {
397        self.version.set(self.version.get().wrapping_add(1));
398    }
399
400    /// Mirror the tree's sim_clock onto the manager so the fade
401    /// dismiss path can stamp the sim-time start in lockstep with
402    /// real time. Called by `WidgetTree` whenever `sim_clock` is
403    /// advanced (e.g. from `tick_animations` and `advance_time`).
404    pub(crate) fn set_sim_clock(&mut self, now_sim: Instant) {
405        self.sim_clock = now_sim;
406    }
407
408    /// Show a new overlay. Returns the OverlayId.
409    pub fn show(&mut self, request: OverlayRequest) -> OverlayId {
410        self.show_with_auto_dismiss(request, None)
411    }
412
413    /// Show a new overlay that dismisses automatically after `duration`.
414    pub fn show_for(&mut self, request: OverlayRequest, duration: Duration) -> OverlayId {
415        self.show_with_auto_dismiss(request, Some(duration))
416    }
417
418    fn show_with_auto_dismiss(
419        &mut self,
420        request: OverlayRequest,
421        auto_dismiss_after: Option<Duration>,
422    ) -> OverlayId {
423        let id = OverlayId::new(self.next_id);
424        self.next_id += 1;
425
426        // Bound cascade depth — see `MAX_OVERLAY_NESTING_DEPTH`. If this
427        // overlay would nest deeper than the cap, drop it silently: don't
428        // push, and return the (now unused) id so callers' follow-ups
429        // (`set_shown_at_sim`, `set_top_focus_restore`) safely no-op on
430        // the absent overlay. This is reachable by degenerate-but-real
431        // user action (a cyclic tooltip `:key` cascade), so it must not
432        // panic — graceful drop is the whole point.
433        if self.ancestor_depth(request.parent_overlay) >= MAX_OVERLAY_NESTING_DEPTH {
434            return id;
435        }
436
437        let now = std::time::Instant::now();
438
439        let overlay = ActiveOverlay {
440            id,
441            content_id: request.content_id,
442            anchor: request.anchor,
443            placement: request.placement,
444            dismiss: request.dismiss,
445            layer: request.layer,
446            parent_overlay: request.parent_overlay,
447            bounds: Rect::ZERO,
448            focus_restore: None,
449            pointer_leave_started_real: None,
450            pointer_leave_started_sim: None,
451            safe_apex: None,
452            safe_apex_started_real: None,
453            safe_apex_started_sim: None,
454            auto_dismiss_after,
455            paused_remaining: None,
456            shown_at_real: now,
457            shown_at_sim: now,
458            on_dismiss: request.on_dismiss,
459            fade: None,
460        };
461        self.stack.push(overlay);
462        self.bump_version();
463        id
464    }
465
466    /// Internal: install a framework-managed opacity signal as the
467    /// overlay's fade state. Called by `WidgetTree::show_overlay`
468    /// when [`OverlayRequest::fade_duration`] is `Some`. The
469    /// framework also applies the same signal to `content_id` via
470    /// `set_opacity` (so the rendering walker emits the per-frame
471    /// opacity scope) and kicks off the 0→1 fade-in tween.
472    pub(crate) fn attach_fade(&mut self, id: OverlayId, opacity: Signal<f32>, duration: Duration) {
473        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id) {
474            overlay.fade = Some(OverlayFadeState {
475                opacity,
476                duration,
477                dismissing_started_real: None,
478                dismissing_started_sim: None,
479            });
480        }
481    }
482
483    /// Public read-only accessor for the fade state. Returns the
484    /// duration if fade is configured, `None` otherwise. Used by
485    /// `WidgetTree::dismiss_overlay` to know whether to leave the
486    /// content active for the fade-out window.
487    pub fn fade_duration(&self, id: OverlayId) -> Option<Duration> {
488        self.stack
489            .iter()
490            .find(|o| o.id == id)
491            .and_then(|o| o.fade.as_ref().map(|f| f.duration))
492    }
493
494    pub fn next_auto_dismiss_deadline(&self) -> Option<std::time::Instant> {
495        self.stack
496            .iter()
497            .filter_map(|overlay| {
498                overlay
499                    .auto_dismiss_after
500                    .map(|delay| overlay.shown_at_real + delay)
501            })
502            .min()
503    }
504
505    /// Earliest instant at which a [`DismissBehavior::PointerLeave`] overlay
506    /// whose leave-grace is already running becomes due for dismissal.
507    ///
508    /// The counterpart of
509    /// [`next_auto_dismiss_deadline`](Self::next_auto_dismiss_deadline) for the
510    /// hover-opened overlays (tooltips, hover submenus). Without it the event
511    /// loop has no reason to wake between the pointer's last motion event and
512    /// the end of the grace window: `next_timer_deadline` would return `None`,
513    /// winit would sit in `ControlFlow::Wait`, and the overlay would stay on
514    /// screen until some unrelated input happened to redraw the window.
515    pub fn next_pointer_leave_deadline(&self) -> Option<std::time::Instant> {
516        self.stack
517            .iter()
518            .filter_map(|overlay| {
519                let DismissBehavior::PointerLeave { delay } = overlay.dismiss else {
520                    return None;
521                };
522                Some(overlay.pointer_leave_started_real? + delay)
523            })
524            .min()
525    }
526
527    /// Pause the auto-dismiss timer for an overlay shown with
528    /// [`show_for`](Self::show_for). The remaining time
529    /// (`auto_dismiss_after - elapsed`) is stashed; subsequent calls
530    /// to [`next_auto_dismiss_deadline`](Self::next_auto_dismiss_deadline)
531    /// ignore this overlay until [`resume_auto_dismiss`](Self::resume_auto_dismiss)
532    /// is called. Idempotent — pausing an already-paused overlay is
533    /// a no-op (the originally-stashed remaining time is preserved).
534    ///
535    /// Used by `ToastHost` to implement hover-pause: when the user
536    /// is hovering over any live toast, all live toasts pause their
537    /// timers so the user can read each one without losing the
538    /// notification they're about to act on.
539    ///
540    /// No-op on overlays without `auto_dismiss_after` (persistent
541    /// overlays don't have a timer to pause) and on unknown ids.
542    pub fn pause_auto_dismiss(&mut self, id: OverlayId) {
543        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id)
544            && overlay.paused_remaining.is_none()
545            && let Some(delay) = overlay.auto_dismiss_after.take()
546        {
547            let elapsed = overlay.shown_at_real.elapsed();
548            overlay.paused_remaining = Some(delay.saturating_sub(elapsed));
549        }
550    }
551
552    /// Resume an auto-dismiss timer paused via
553    /// [`pause_auto_dismiss`](Self::pause_auto_dismiss). The stashed
554    /// remaining time becomes the new `auto_dismiss_after`, and
555    /// `shown_at_real` / `shown_at_sim` are reset to now so the
556    /// deadline computation works correctly. Idempotent — resuming
557    /// an un-paused overlay is a no-op.
558    pub fn resume_auto_dismiss(&mut self, id: OverlayId) {
559        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id)
560            && let Some(remaining) = overlay.paused_remaining.take()
561        {
562            overlay.auto_dismiss_after = Some(remaining);
563            let now = std::time::Instant::now();
564            overlay.shown_at_real = now;
565            overlay.shown_at_sim = self.sim_clock;
566        }
567    }
568
569    /// Whether the auto-dismiss timer for an overlay is currently paused.
570    /// `false` for overlays without `auto_dismiss_after`, unknown ids,
571    /// and overlays whose timer is running.
572    pub fn is_auto_dismiss_paused(&self, id: OverlayId) -> bool {
573        self.stack
574            .iter()
575            .find(|o| o.id == id)
576            .is_some_and(|o| o.paused_remaining.is_some())
577    }
578
579    pub(crate) fn set_shown_at_sim(&mut self, id: OverlayId, shown_at_sim: std::time::Instant) {
580        if let Some(overlay) = self.stack.iter_mut().find(|overlay| overlay.id == id) {
581            overlay.shown_at_sim = shown_at_sim;
582        }
583    }
584
585    /// Count the ancestor chain length for an overlay whose parent is
586    /// `parent` — i.e. the nesting depth the *new* overlay would have.
587    /// A root (`parent == None`) is depth 0; a child of a root is depth
588    /// 1; and so on. The walk is bounded by the stack length so a
589    /// malformed parent cycle can't loop forever.
590    fn ancestor_depth(&self, parent: Option<OverlayId>) -> usize {
591        let mut depth = 0;
592        let mut current = parent;
593        while let Some(p) = current {
594            depth += 1;
595            if depth > self.stack.len() {
596                // Defensive: malformed parent cycle. Report a depth that
597                // trips the guard rather than spinning.
598                break;
599            }
600            current = self
601                .stack
602                .iter()
603                .find(|overlay| overlay.id == p)
604                .and_then(|overlay| overlay.parent_overlay);
605        }
606        depth
607    }
608
609    pub(crate) fn is_descendant_of(&self, child: OverlayId, ancestor: OverlayId) -> bool {
610        let mut current = self
611            .stack
612            .iter()
613            .find(|overlay| overlay.id == child)
614            .and_then(|overlay| overlay.parent_overlay);
615
616        while let Some(parent) = current {
617            if parent == ancestor {
618                return true;
619            }
620            current = self
621                .stack
622                .iter()
623                .find(|overlay| overlay.id == parent)
624                .and_then(|overlay| overlay.parent_overlay);
625        }
626
627        false
628    }
629
630    pub(crate) fn overlay(&self, id: OverlayId) -> Option<&ActiveOverlay> {
631        self.stack.iter().find(|overlay| overlay.id == id)
632    }
633
634    /// Public accessor for an overlay's currently-laid-out screen
635    /// rect. Returns `None` for unknown ids and for overlays that
636    /// have not yet been through a layout pass (`bounds == Rect::ZERO`
637    /// in that case, but we still hand it back — callers should not
638    /// trust a zero-sized rect for hit-test geometry).
639    ///
640    /// Used by [`MenuList`](../../teksilo_widgets/menu_list/struct.MenuList.html)'s
641    /// safe-triangle submenu hover gate, which needs the open
642    /// submenu's near-edge to test whether the cursor trajectory is
643    /// still headed toward the submenu.
644    pub fn bounds_for(&self, id: OverlayId) -> Option<Rect> {
645        self.overlay(id).map(|o| o.bounds)
646    }
647
648    pub(crate) fn topmost_centered(&self) -> Option<&ActiveOverlay> {
649        self.stack
650            .iter()
651            .rev()
652            .find(|overlay| matches!(overlay.placement, OverlayPlacement::Centered))
653    }
654
655    /// Dismiss an overlay and all its children (cascade), returning the
656    /// dismissed content widget IDs and the overlay's focus_restore target.
657    pub fn dismiss_with_focus_restore(
658        &mut self,
659        id: OverlayId,
660    ) -> (Vec<WidgetId>, Option<WidgetId>) {
661        let focus_restore = self
662            .stack
663            .iter()
664            .find(|overlay| overlay.id == id)
665            .and_then(|overlay| overlay.focus_restore);
666        let dismissed = self.dismiss(id);
667        (dismissed, focus_restore)
668    }
669
670    /// Dismiss all descendant overlays of `parent`, optionally preserving the
671    /// subtree rooted at `preserve`.
672    pub fn dismiss_descendants_of(
673        &mut self,
674        parent: OverlayId,
675        preserve: Option<OverlayId>,
676    ) -> (Vec<WidgetId>, Option<WidgetId>) {
677        let mut to_dismiss = Vec::new();
678
679        for overlay in &self.stack {
680            if !self.is_descendant_of(overlay.id, parent) {
681                continue;
682            }
683            if preserve
684                .is_some_and(|keep| overlay.id == keep || self.is_descendant_of(overlay.id, keep))
685            {
686                continue;
687            }
688            to_dismiss.push(overlay.id);
689        }
690
691        if to_dismiss.is_empty() {
692            return (Vec::new(), None);
693        }
694
695        let focus_restore = self
696            .stack
697            .iter()
698            .rev()
699            .find(|overlay| to_dismiss.contains(&overlay.id))
700            .and_then(|overlay| overlay.focus_restore);
701
702        let dismissed_content: Vec<WidgetId> = self
703            .stack
704            .iter()
705            .filter(|overlay| to_dismiss.contains(&overlay.id))
706            .map(|overlay| overlay.content_id)
707            .collect();
708        let callbacks: Vec<OverlayDismissCallback> = self
709            .stack
710            .iter()
711            .filter(|overlay| to_dismiss.contains(&overlay.id))
712            .filter_map(|overlay| overlay.on_dismiss.clone())
713            .collect();
714        self.stack
715            .retain(|overlay| !to_dismiss.contains(&overlay.id));
716        for cb in callbacks {
717            cb();
718        }
719
720        (dismissed_content, focus_restore)
721    }
722
723    /// Update the placement of an existing overlay.
724    pub fn update_placement(&mut self, id: OverlayId, placement: OverlayPlacement) {
725        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id) {
726            overlay.placement = placement;
727        }
728    }
729
730    /// Update the parent-overlay link of an existing overlay. Used by the
731    /// modal-presentation pipeline to retroactively attach the dialog
732    /// scrim (pushed first, below the modal in the stack) to the modal
733    /// (pushed second) so that dismissing the modal cascades through
734    /// `dismiss_immediate` and also dismisses the scrim.
735    pub fn set_parent_overlay(&mut self, id: OverlayId, parent: Option<OverlayId>) {
736        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id) {
737            overlay.parent_overlay = parent;
738        }
739    }
740
741    /// Dismiss an overlay and all its children (cascade).
742    /// Returns the content widget IDs of all dismissed overlays.
743    ///
744    /// **Fade-aware**: when an overlay was shown with
745    /// [`OverlayRequest::with_fade`] and is not yet fading out, this
746    /// method instead kicks off the fade-out tween on the framework-
747    /// owned opacity signal and marks `dismiss_at`, returning an
748    /// empty vec — the actual stack removal and content dormancy
749    /// happen later via
750    /// [`process_pending_fade_dismissals`](Self::process_pending_fade_dismissals).
751    /// Cascaded descendants vanish with the leaf's fade-out (they're
752    /// typically submenus the user dismissed *via* the leaf, and a
753    /// per-descendant tween would compete with the leaf's).
754    pub fn dismiss(&mut self, id: OverlayId) -> Vec<WidgetId> {
755        // Fade gate: if the target overlay has fade and isn't
756        // already fading out, kick off the fade-out and defer the
757        // entire cascade. Stamps both real and sim start times in
758        // lockstep — the sim time uses the manager's mirrored
759        // `sim_clock`, kept in sync by `WidgetTree::set_sim_clock`.
760        let sim_now = self.sim_clock;
761        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id)
762            && let Some(fade) = &mut overlay.fade
763            && fade.dismissing_started_real.is_none()
764        {
765            // Animate opacity 1 → 0 over `duration`. Uses the same
766            // try_animate_with_options path the rest of the
767            // animation system uses; the scheduler picks it up next
768            // frame and ticks the signal, dirty-marking the
769            // content's opacity binding for repaint.
770            let _ = fade
771                .opacity
772                .try_animate_with_options(crate::animation::AnimationRequest {
773                    target: 0.0,
774                    duration: fade.duration,
775                    easing: teksilo_tokens::Easing::EaseOut,
776                    frame_interval: None,
777                    looping: false,
778                    epsilon: 0.0,
779                    max_duration: None,
780                });
781            let now_real = Instant::now();
782            fade.dismissing_started_real = Some(now_real);
783            fade.dismissing_started_sim = Some(sim_now);
784            return Vec::new();
785        }
786        self.dismiss_immediate(id)
787    }
788
789    /// Internal: same shape as the original `dismiss`, but bypasses
790    /// the fade gate. Used both by `dismiss` (no fade configured /
791    /// already fading out) and by `process_pending_fade_dismissals`
792    /// when a fade-out tween has completed. Also used by the orphaned-
793    /// overlay GC (`WidgetTree::gc_orphaned_overlays`), where fading is
794    /// impossible because the content widget is already destroyed.
795    pub(crate) fn dismiss_immediate(&mut self, id: OverlayId) -> Vec<WidgetId> {
796        // Collect IDs to dismiss: the target + all descendants
797        let mut to_dismiss = vec![id];
798        let mut i = 0;
799        while i < to_dismiss.len() {
800            let parent = to_dismiss[i];
801            for overlay in &self.stack {
802                if overlay.parent_overlay == Some(parent) && !to_dismiss.contains(&overlay.id) {
803                    to_dismiss.push(overlay.id);
804                }
805            }
806            i += 1;
807        }
808        let dismissed_content: Vec<WidgetId> = self
809            .stack
810            .iter()
811            .filter(|o| to_dismiss.contains(&o.id))
812            .map(|o| o.content_id)
813            .collect();
814        // Collect dismiss callbacks (via Rc::clone) before retain
815        // so we can invoke them AFTER the borrow is released.
816        // Callbacks may do anything, including touching the arena,
817        // so running them mid-retain would risk re-entrancy.
818        let callbacks: Vec<OverlayDismissCallback> = self
819            .stack
820            .iter()
821            .filter(|o| to_dismiss.contains(&o.id))
822            .filter_map(|o| o.on_dismiss.clone())
823            .collect();
824        self.stack.retain(|o| !to_dismiss.contains(&o.id));
825        if !to_dismiss.is_empty() {
826            self.bump_version();
827        }
828        for cb in callbacks {
829            cb();
830        }
831        dismissed_content
832    }
833
834    /// Drain overlays whose real-clock fade-out tween has completed.
835    /// Call from the live layout pass; the framework dormants the
836    /// returned content widget IDs and restores focus where
837    /// appropriate. Each entry is
838    /// `(overlay_id, dismissed_content_ids, focus_restore)` so the
839    /// layout pass can run the same dormant-and-restore-focus flow
840    /// it uses for
841    /// [`dismiss_with_focus_restore`](Self::dismiss_with_focus_restore).
842    pub fn process_pending_fade_dismissals(
843        &mut self,
844        now: Instant,
845    ) -> Vec<(OverlayId, Vec<WidgetId>, Option<WidgetId>)> {
846        self.process_pending_fade_dismissals_with(|fade| {
847            let started = fade.dismissing_started_real?;
848            Some(now.saturating_duration_since(started) >= fade.duration)
849        })
850    }
851
852    /// Sim-clock variant for deterministic headless tests. Same
853    /// shape as [`process_pending_fade_dismissals`](Self::process_pending_fade_dismissals)
854    /// but reads `dismissing_started_sim`.
855    pub fn process_pending_fade_dismissals_sim(
856        &mut self,
857        now_sim: Instant,
858    ) -> Vec<(OverlayId, Vec<WidgetId>, Option<WidgetId>)> {
859        self.process_pending_fade_dismissals_with(|fade| {
860            let started = fade.dismissing_started_sim?;
861            Some(now_sim.saturating_duration_since(started) >= fade.duration)
862        })
863    }
864
865    fn process_pending_fade_dismissals_with(
866        &mut self,
867        mut elapsed_done: impl FnMut(&OverlayFadeState) -> Option<bool>,
868    ) -> Vec<(OverlayId, Vec<WidgetId>, Option<WidgetId>)> {
869        let ready: Vec<(OverlayId, Option<WidgetId>)> = self
870            .stack
871            .iter()
872            .filter_map(|o| {
873                let fade = o.fade.as_ref()?;
874                if elapsed_done(fade)? {
875                    Some((o.id, o.focus_restore))
876                } else {
877                    None
878                }
879            })
880            .collect();
881        ready
882            .into_iter()
883            .map(|(id, focus_restore)| {
884                let dismissed = self.dismiss_immediate(id);
885                (id, dismissed, focus_restore)
886            })
887            .collect()
888    }
889
890    /// Earliest real-clock deadline at which a fading-out overlay
891    /// wants to finish its dismissal. Used by the event-loop wakeup
892    /// logic to schedule the next frame.
893    pub fn next_fade_dismiss_deadline(&self) -> Option<Instant> {
894        self.stack
895            .iter()
896            .filter_map(|o| {
897                let fade = o.fade.as_ref()?;
898                let started = fade.dismissing_started_real?;
899                Some(started + fade.duration)
900            })
901            .min()
902    }
903
904    /// Dismiss the topmost overlay unconditionally (e.g., ArrowLeft for submenu cascading).
905    /// Returns the overlay ID, content widget IDs, and focus_restore target.
906    pub fn dismiss_top(&mut self) -> Option<(OverlayId, Vec<WidgetId>, Option<WidgetId>)> {
907        if let Some(overlay) = self.stack.last() {
908            let id = overlay.id;
909            let focus_restore = overlay.focus_restore;
910            let content_ids = self.dismiss(id);
911            Some((id, content_ids, focus_restore))
912        } else {
913            None
914        }
915    }
916
917    /// Try to dismiss an overlay on Escape, respecting `DismissBehavior`.
918    ///
919    /// Scans the stack top-down for the first overlay that Escape may close,
920    /// rather than consulting only `stack.last()`. Two reasons:
921    ///
922    /// - A hover-opened overlay (`PointerLeave` — every shown tooltip) is
923    ///   Escape-dismissible. WCAG 2.2 SC 1.4.13(a) requires content shown on
924    ///   hover to be dismissible *without moving the pointer*, and Escape is
925    ///   that mechanism; previously no key could close a plain tooltip.
926    /// - A tooltip lives on the same stack as whatever it is anchored inside.
927    ///   Hovering a menu item long enough to raise its tooltip put a
928    ///   non-Escape overlay on top, so Escape silently did nothing at all
929    ///   until the tooltip's own 100 ms leave-grace expired — the keystroke
930    ///   was swallowed, not forwarded to the menu underneath.
931    ///
932    /// `Manual` overlays still block the scan: they are modal-ish by
933    /// construction and own the keystroke.
934    pub fn try_dismiss_top_on_escape(
935        &mut self,
936    ) -> Option<(OverlayId, Vec<WidgetId>, Option<WidgetId>)> {
937        let target = self
938            .stack
939            .iter()
940            .rev()
941            // An overlay already fading out stays on the stack until its tween
942            // finishes, but it is on its way out and no longer owns the
943            // keystroke — targeting it again would spend an Escape on a corpse
944            // and leave whatever is underneath unreachable.
945            .filter(|o| !o.is_dismissing())
946            .find_map(|o| match o.dismiss {
947                DismissBehavior::EscapeKey
948                | DismissBehavior::EscapeOrClickOutside
949                | DismissBehavior::PointerLeave { .. } => Some(Some(o.id)),
950                // Opaque to Escape and to everything under it.
951                DismissBehavior::Manual => Some(None),
952                DismissBehavior::ClickOutside => None,
953            })??;
954        let focus_restore = self
955            .stack
956            .iter()
957            .find(|o| o.id == target)
958            .and_then(|o| o.focus_restore);
959        let content_ids = self.dismiss(target);
960        Some((target, content_ids, focus_restore))
961    }
962
963    /// Set the focus_restore target for the topmost overlay.
964    pub fn set_top_focus_restore(&mut self, focus_restore: WidgetId) {
965        if let Some(overlay) = self.stack.last_mut() {
966            overlay.focus_restore = Some(focus_restore);
967        }
968    }
969
970    /// Dismiss all overlays.
971    /// Returns the content widget IDs of all dismissed overlays.
972    /// Fires every dismissed overlay's `on_dismiss` callback after the
973    /// stack is cleared — same contract as [`dismiss`](Self::dismiss),
974    /// so wrappers like [`PopoverButton`](crate::widget::EventContext)'s
975    /// `popover_open` signal flip back to `false` when a `MenuItem`
976    /// fires `ctx.dismiss_all_overlays()`. Without this, the trigger's
977    /// next click would observe stale-true and silently retoggle
978    /// instead of reopening the menu.
979    pub fn dismiss_all(&mut self) -> Vec<WidgetId> {
980        let content_ids: Vec<WidgetId> = self.stack.iter().map(|o| o.content_id).collect();
981        if content_ids.is_empty() {
982            return content_ids;
983        }
984        // Collect dismiss callbacks (via Rc::clone) before clear so we
985        // can invoke them AFTER the borrow is released. Callbacks may
986        // do anything, including touching the arena, so running them
987        // mid-clear would risk re-entrancy. Mirrors the pattern in
988        // [`dismiss_immediate`](Self::dismiss_immediate).
989        let callbacks: Vec<OverlayDismissCallback> = self
990            .stack
991            .iter()
992            .filter_map(|o| o.on_dismiss.clone())
993            .collect();
994        self.stack.clear();
995        self.bump_version();
996        for cb in callbacks {
997            cb();
998        }
999        content_ids
1000    }
1001
1002    /// Dismiss every overlay whose content is **not** in `keep`, running each
1003    /// dismissed overlay's `on_dismiss`. Used when opening a context menu: any
1004    /// overlay that *contains* the right-clicked widget (e.g. the modal the editor
1005    /// lives in) is kept, so the menu doesn't tear down its own host.
1006    pub fn dismiss_except(&mut self, keep: &std::collections::HashSet<WidgetId>) -> Vec<WidgetId> {
1007        let dismissed: Vec<WidgetId> = self
1008            .stack
1009            .iter()
1010            .filter(|o| !keep.contains(&o.content_id))
1011            .map(|o| o.content_id)
1012            .collect();
1013        if dismissed.is_empty() {
1014            return dismissed;
1015        }
1016        // Clone callbacks before mutating the stack, then run them after the
1017        // borrow is released (they may touch the arena) — mirrors `dismiss_all`.
1018        let callbacks: Vec<OverlayDismissCallback> = self
1019            .stack
1020            .iter()
1021            .filter(|o| !keep.contains(&o.content_id))
1022            .filter_map(|o| o.on_dismiss.clone())
1023            .collect();
1024        self.stack.retain(|o| keep.contains(&o.content_id));
1025        self.bump_version();
1026        for cb in callbacks {
1027            cb();
1028        }
1029        dismissed
1030    }
1031
1032    /// Whether there are any active overlays.
1033    pub fn is_empty(&self) -> bool {
1034        self.stack.is_empty()
1035    }
1036
1037    /// Number of active overlays.
1038    pub fn len(&self) -> usize {
1039        self.stack.len()
1040    }
1041
1042    /// Get all active overlay content widget IDs (for rendering).
1043    pub fn active_content_ids(&self) -> Vec<WidgetId> {
1044        self.stack.iter().map(|o| o.content_id).collect()
1045    }
1046
1047    /// Get all active overlay IDs (for testing/querying). Excludes
1048    /// overlays currently fading out — once a dismiss has been
1049    /// requested the overlay is conceptually gone (the visible
1050    /// opacity tween is on the way to 0 and the deferred removal
1051    /// will fire on the next layout pass after the fade-out
1052    /// completes), so user code asking "is this overlay still up?"
1053    /// gets the expected answer.
1054    pub fn active_ids(&self) -> Vec<OverlayId> {
1055        self.stack
1056            .iter()
1057            .filter(|o| {
1058                o.fade
1059                    .as_ref()
1060                    .is_none_or(|f| f.dismissing_started_real.is_none())
1061            })
1062            .map(|o| o.id)
1063            .collect()
1064    }
1065
1066    /// Get the anchor widget for an overlay.
1067    pub fn anchor_for(&self, id: OverlayId) -> Option<WidgetId> {
1068        self.stack.iter().find(|o| o.id == id).map(|o| o.anchor)
1069    }
1070
1071    /// Screen rects of every overlay that is currently *interactive* —
1072    /// open and not yet fading out, the same predicate
1073    /// [`hit_test`](Self::hit_test) uses to route pointer events.
1074    /// Zero-area entries are skipped: an overlay shown this frame has
1075    /// not been through its first layout pass yet (`bounds ==
1076    /// Rect::ZERO`), and a degenerate rect must not be mistaken for a
1077    /// hit at the origin.
1078    ///
1079    /// Consumed by the paint pass, which hands the list to
1080    /// [`Widget::after_paint`](crate::widget::Widget::after_paint) via
1081    /// `WidgetTreeView` so chrome aggregators can subtract floating
1082    /// content from the regions they publish — `TitleBar` carves these
1083    /// out of the OS caption so an overlay above the title bar (the
1084    /// hamburger `MenuBar`'s revealed bar, a tall modal) stays
1085    /// clickable on Windows instead of dragging the window.
1086    pub fn interactive_rects(&self) -> Vec<Rect> {
1087        self.stack
1088            .iter()
1089            .filter(|o| {
1090                o.fade
1091                    .as_ref()
1092                    .is_none_or(|f| f.dismissing_started_real.is_none())
1093            })
1094            .map(|o| o.bounds)
1095            .filter(|r| r.width > 0.0 && r.height > 0.0)
1096            .collect()
1097    }
1098
1099    /// Get the topmost overlay.
1100    #[allow(dead_code)] // used for overlay z-ordering and focus management
1101    pub(crate) fn topmost(&self) -> Option<&ActiveOverlay> {
1102        self.stack.last()
1103    }
1104
1105    /// Check if a point hits any overlay (topmost first).
1106    /// Returns the overlay ID if hit, None if the point is outside all overlays.
1107    ///
1108    /// Overlays whose fade-out has begun are skipped — the same predicate
1109    /// [`active_ids`](Self::active_ids) uses. A dismissed-but-still-fading
1110    /// overlay lingers in the stack until
1111    /// [`process_pending_fade_dismissals`](Self::process_pending_fade_dismissals)
1112    /// removes it; treating it as hittable would route clicks into the
1113    /// vanishing content (and suppress outside-click dismissal of the
1114    /// overlays beneath it) for the whole fade duration.
1115    pub fn hit_test(&self, point: Point) -> Option<OverlayId> {
1116        for overlay in self.stack.iter().rev() {
1117            let fading_out = overlay
1118                .fade
1119                .as_ref()
1120                .is_some_and(|f| f.dismissing_started_real.is_some());
1121            if !fading_out && overlay.bounds.contains(point) {
1122                return Some(overlay.id);
1123            }
1124        }
1125        None
1126    }
1127
1128    /// Handle a click-outside event: if the click is outside all overlays
1129    /// with ClickOutside dismiss behavior, dismiss them.
1130    /// Returns the content widget IDs of dismissed overlays (empty if none)
1131    /// and the focus-restore target — the widget that was focused before
1132    /// the *bottommost* dismissed overlay opened. Topmost overlays'
1133    /// `focus_restore` would point inside an overlay that's also being
1134    /// dismissed in the same pass, which would leave focus on a
1135    /// dormant widget; the bottommost target represents focus before
1136    /// any of the dismissed overlays opened. Aligns the click-outside
1137    /// path with the Esc / ArrowLeft-cascade paths, both of which
1138    /// already restore focus from the dismissed overlay.
1139    ///
1140    /// The third return value lists the anchor widgets of the dismissed
1141    /// *click-opened* overlays (`ClickOutside` / `EscapeOrClickOutside`).
1142    /// The dispatcher consumes a primary press that lands on one of these
1143    /// anchors so the trigger merely closes its overlay rather than
1144    /// reopening it; every other dismiss-press falls through to the widget
1145    /// under the cursor (so one click both dismisses the overlay and
1146    /// activates the control beneath). Hover-opened (`PointerLeave`)
1147    /// overlays contribute no anchor — a press on their anchor passes
1148    /// through, e.g. clicking a button that still has its tooltip up.
1149    pub fn handle_click_outside(
1150        &mut self,
1151        point: Point,
1152    ) -> (Vec<WidgetId>, Option<WidgetId>, Vec<WidgetId>) {
1153        if self.stack.is_empty() {
1154            return (Vec::new(), None, Vec::new());
1155        }
1156
1157        // Dismissal is *layered*, not stack-wide. A press that lands inside
1158        // overlay `k` is still *outside* every overlay stacked above `k`, so
1159        // those upper overlays with a click-outside policy must close — e.g. a
1160        // sticky tooltip (or a combo dropdown) floating above a modal is
1161        // dismissed when the user clicks elsewhere in the modal. Overlays at
1162        // or below `k` keep their content: the press landed within the stack,
1163        // not outside it.
1164        //
1165        // `hit_index` is the topmost non-fading overlay containing the point,
1166        // or `None` for a press on the bare background (then nothing is
1167        // "below" the press and every dismissable overlay closes — the
1168        // classic outside-click). This replaces an earlier stack-wide
1169        // short-circuit that returned as soon as the press hit *any* overlay:
1170        // once a modal — or its full-viewport scrim — was open, that guard
1171        // made *no* click-outside overlay dismissable at all.
1172        let hit_index = self.stack.iter().enumerate().rev().find_map(|(i, o)| {
1173            let fading_out = o
1174                .fade
1175                .as_ref()
1176                .is_some_and(|f| f.dismissing_started_real.is_some());
1177            (!fading_out && o.bounds.contains(point)).then_some(i)
1178        });
1179
1180        // Collect the overlays this outside-click should close, and — for
1181        // the *click-opened* ones — their anchor widgets. The anchors let
1182        // the dispatcher decide whether the same press may fall through to
1183        // the widget beneath: a press on a click-opened overlay's own
1184        // anchor is consumed, since the anchor's tap handler would
1185        // otherwise reopen what this press just dismissed. Hover-opened
1186        // overlays (`PointerLeave`) are not click toggles, so their
1187        // anchors are omitted and a press there falls through.
1188        let mut to_dismiss: Vec<OverlayId> = Vec::new();
1189        let mut toggle_anchors: Vec<WidgetId> = Vec::new();
1190        for (i, o) in self.stack.iter().enumerate() {
1191            // Skip the hit overlay and everything beneath it — the press
1192            // landed inside them (or was covered by them), so they survive.
1193            if hit_index.is_some_and(|k| i <= k) {
1194                continue;
1195            }
1196            match o.dismiss {
1197                DismissBehavior::ClickOutside | DismissBehavior::EscapeOrClickOutside => {
1198                    to_dismiss.push(o.id);
1199                    toggle_anchors.push(o.anchor);
1200                }
1201                DismissBehavior::PointerLeave { .. } => to_dismiss.push(o.id),
1202                DismissBehavior::EscapeKey | DismissBehavior::Manual => {}
1203            }
1204        }
1205
1206        if to_dismiss.is_empty() {
1207            return (Vec::new(), None, Vec::new());
1208        }
1209
1210        let focus_restore = self
1211            .stack
1212            .iter()
1213            .find(|o| to_dismiss.contains(&o.id))
1214            .and_then(|o| o.focus_restore);
1215
1216        let mut all_dismissed = Vec::new();
1217        for id in to_dismiss {
1218            all_dismissed.extend(self.dismiss(id));
1219        }
1220        (all_dismissed, focus_restore, toggle_anchors)
1221    }
1222
1223    /// Compute overlay positions based on anchor bounds.
1224    /// Called after layout to position overlays correctly.
1225    /// `viewport` is (width, height) used for clamping overlays to the visible area.
1226    ///
1227    /// `anchor_bounds_fn` returns `None` when the anchor widget is no
1228    /// longer in the arena (destroyed by a host's rebuild while the
1229    /// overlay is still up). In that case the overlay's bounds are
1230    /// left untouched — keeping it at its last valid position rather
1231    /// than collapsing to the (0,0) origin from a `Rect::ZERO`
1232    /// fallback.
1233    pub fn position_overlays(
1234        &mut self,
1235        anchor_bounds_fn: impl Fn(WidgetId) -> Option<Rect>,
1236        viewport: (f32, f32),
1237        layout_direction: LayoutDirection,
1238    ) {
1239        let (vw, vh) = viewport;
1240        let rtl = matches!(layout_direction, LayoutDirection::RightToLeft);
1241        for overlay in &mut self.stack {
1242            let anchor = match anchor_bounds_fn(overlay.anchor) {
1243                Some(a) => a,
1244                None => {
1245                    // Anchor destroyed. Anchor-independent placements must still
1246                    // be positioned — e.g. a `Centered` modal opened from a menu
1247                    // item that has since closed (the menu item is the anchor,
1248                    // but `Centered` doesn't use it). Anchor-relative placements
1249                    // keep their previous bounds.
1250                    if matches!(
1251                        overlay.placement,
1252                        OverlayPlacement::Centered
1253                            | OverlayPlacement::FullViewport
1254                            | OverlayPlacement::BottomCenter
1255                            | OverlayPlacement::ViewportCorner { .. }
1256                            | OverlayPlacement::AtPointer(_)
1257                    ) {
1258                        Rect::ZERO
1259                    } else {
1260                        continue;
1261                    }
1262                }
1263            };
1264            let content_size = overlay.bounds.size(); // Will be set from content layout
1265
1266            overlay.bounds = match &overlay.placement {
1267                OverlayPlacement::Below => {
1268                    let actual_width = content_size.width.max(anchor.width);
1269                    let x = leading_aligned_x(anchor, actual_width, vw, rtl);
1270                    Rect::new(
1271                        x,
1272                        anchor.y + anchor.height + 4.0,
1273                        actual_width,
1274                        content_size.height,
1275                    )
1276                }
1277                OverlayPlacement::Above => {
1278                    let actual_width = content_size.width.max(anchor.width);
1279                    let x = leading_aligned_x(anchor, actual_width, vw, rtl);
1280                    Rect::new(
1281                        x,
1282                        anchor.y - content_size.height - 4.0,
1283                        actual_width,
1284                        content_size.height,
1285                    )
1286                }
1287                OverlayPlacement::TrailingEdge => {
1288                    // In LTR trailing is to the right; in RTL trailing is to the left.
1289                    let x = if rtl {
1290                        let x_left = anchor.x - content_size.width - 2.0;
1291                        if x_left >= 0.0 {
1292                            x_left
1293                        } else {
1294                            // Fallback: open to the leading side (right in RTL)
1295                            anchor.x + anchor.width + 2.0
1296                        }
1297                    } else {
1298                        let x_right = anchor.x + anchor.width + 2.0;
1299                        if x_right + content_size.width <= vw {
1300                            x_right
1301                        } else {
1302                            // Fallback: open to the leading side (left in LTR)
1303                            anchor.x - content_size.width - 2.0
1304                        }
1305                    };
1306                    let y = anchor.y.min(vh - content_size.height).max(0.0);
1307                    Rect::new(x, y, content_size.width, content_size.height)
1308                }
1309                OverlayPlacement::AtPointer(point) => {
1310                    // Clamp to viewport so menus don't overflow off-screen
1311                    let x = point.x.min(vw - content_size.width).max(0.0);
1312                    let y = if point.y + content_size.height <= vh {
1313                        point.y
1314                    } else {
1315                        // Not enough space below pointer — open above
1316                        (point.y - content_size.height).max(0.0)
1317                    };
1318                    Rect::new(x, y, content_size.width, content_size.height)
1319                }
1320                OverlayPlacement::NearAnchor { offset } => {
1321                    // Prefer below the anchor at `offset` + 4 px.
1322                    // Flip above when the content would otherwise spill
1323                    // past the viewport bottom — same pattern as
1324                    // `BelowPreferred`. Without this, a tooltip whose
1325                    // anchor sits near the window edge gets clipped by
1326                    // the surface bounds (overlays paint unclipped, but
1327                    // the window itself still bounds the framebuffer).
1328                    let below_y = anchor.y + anchor.height + offset.y + 4.0;
1329                    let fits_below = below_y + content_size.height <= vh;
1330                    let y = if fits_below {
1331                        below_y
1332                    } else {
1333                        // Symmetric offset above: same gap as below.
1334                        let above_y = anchor.y - content_size.height - offset.y - 4.0;
1335                        above_y.max(0.0)
1336                    };
1337                    // Horizontal anchoring is direction-aware: LTR aligns
1338                    // the content's leading (left) edge to the anchor's
1339                    // left edge + offset; RTL mirrors it, aligning the
1340                    // content's trailing (right) edge to the anchor's
1341                    // right edge - offset. The clamp then keeps it in view
1342                    // when the anchor is near a viewport edge.
1343                    let unclamped_x = if rtl {
1344                        anchor.x + anchor.width - content_size.width - offset.x
1345                    } else {
1346                        anchor.x + offset.x
1347                    };
1348                    let x = unclamped_x.min(vw - content_size.width).max(0.0);
1349                    Rect::new(x, y, content_size.width, content_size.height)
1350                }
1351                OverlayPlacement::Centered => Rect::new(
1352                    ((vw - content_size.width) / 2.0).max(0.0),
1353                    ((vh - content_size.height) / 2.0).max(0.0),
1354                    content_size.width.min(vw),
1355                    content_size.height.min(vh),
1356                ),
1357                OverlayPlacement::BottomCenter => Rect::new(
1358                    ((vw - content_size.width) / 2.0).max(0.0),
1359                    (vh - content_size.height - 24.0).max(0.0),
1360                    content_size.width.min(vw),
1361                    content_size.height.min(vh),
1362                ),
1363                OverlayPlacement::BelowPreferred => {
1364                    let below_y = anchor.y + anchor.height + 4.0;
1365                    let fits_below = below_y + content_size.height <= vh;
1366                    let y = if fits_below {
1367                        below_y
1368                    } else {
1369                        anchor.y - content_size.height - 4.0
1370                    };
1371                    let actual_width = content_size.width.max(anchor.width);
1372                    // Align leading edges, same logic as Below.
1373                    let x = if rtl {
1374                        (anchor.x + anchor.width - actual_width)
1375                            .min(vw - actual_width)
1376                            .max(0.0)
1377                    } else {
1378                        anchor.x.min(vw - actual_width).max(0.0)
1379                    };
1380                    Rect::new(x, y, actual_width, content_size.height)
1381                }
1382                OverlayPlacement::ViewportCorner { corner, margin } => {
1383                    let (x, y) = corner.resolve(
1384                        (content_size.width, content_size.height),
1385                        (vw, vh),
1386                        (margin.x, margin.y),
1387                        rtl,
1388                    );
1389                    Rect::new(
1390                        x,
1391                        y,
1392                        content_size.width.min(vw),
1393                        content_size.height.min(vh),
1394                    )
1395                }
1396                OverlayPlacement::FullViewport => Rect::new(0.0, 0.0, vw, vh),
1397            };
1398        }
1399    }
1400
1401    /// Set the content bounds for an overlay (after its content has been laid out).
1402    pub fn set_content_bounds(&mut self, id: OverlayId, size: Size) {
1403        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id) {
1404            overlay.bounds = Rect::new(overlay.bounds.x, overlay.bounds.y, size.width, size.height);
1405        }
1406    }
1407
1408    /// Get overlay by content widget ID (for routing events to the correct overlay).
1409    pub fn find_by_content(&self, content_id: WidgetId) -> Option<OverlayId> {
1410        self.stack
1411            .iter()
1412            .find(|o| o.content_id == content_id)
1413            .map(|o| o.id)
1414    }
1415
1416    /// Convenience accessor for the safe-triangle hover gate: returns
1417    /// the bounds rect of the open overlay whose root content widget
1418    /// id matches `content_id`, or `None` when no such overlay is
1419    /// active. Equivalent to `find_by_content` + `bounds_for` chained.
1420    pub fn bounds_for_content(&self, content_id: WidgetId) -> Option<Rect> {
1421        self.stack
1422            .iter()
1423            .find(|o| o.content_id == content_id)
1424            .map(|o| o.bounds)
1425    }
1426
1427    // -------------------- Safe region (submenu traversal) --------------------
1428
1429    /// Arm the safe triangle for the overlay whose root content widget
1430    /// is `content_id`, with its apex at `apex` — the point the pointer
1431    /// left the anchor at.
1432    ///
1433    /// While armed and unexpired, a pointer inside the triangle
1434    /// spanned by the apex and this overlay's near vertical edge is
1435    /// treated as still inside the overlay's region, so neither the
1436    /// pointer-leave grace nor a sibling's hover-switch dismisses it.
1437    /// Re-arming an already-armed region restarts its budget.
1438    /// No-ops when no such overlay is open.
1439    pub(crate) fn arm_safe_region(
1440        &mut self,
1441        content_id: WidgetId,
1442        apex: Point,
1443        real_now: Instant,
1444        sim_now: Instant,
1445    ) {
1446        if let Some(overlay) = self
1447            .stack
1448            .iter_mut()
1449            .find(|o| o.content_id == content_id && !o.is_dismissing())
1450        {
1451            overlay.safe_apex = Some(apex);
1452            overlay.safe_apex_started_real = Some(real_now);
1453            overlay.safe_apex_started_sim = Some(sim_now);
1454        }
1455    }
1456
1457    /// Disarm the safe triangle on the overlay with the given id. Called
1458    /// when the pointer arrives (or returns) and when the budget is
1459    /// spent — after which the overlay dismisses on the ordinary
1460    /// schedule. Straying out of the cone does **not** disarm: it only
1461    /// starts the pointer-leave grace, which a re-entry cancels. See
1462    /// [`safe_triangle`].
1463    pub(crate) fn clear_safe_region(&mut self, id: OverlayId) {
1464        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id) {
1465            overlay.safe_apex = None;
1466            overlay.safe_apex_started_real = None;
1467            overlay.safe_apex_started_sim = None;
1468        }
1469    }
1470
1471    /// The armed apex of the overlay rooted at `content_id`, if any —
1472    /// without regard to the budget, which only the tree's clocks can
1473    /// judge. Callers wanting the answer a widget may act on go through
1474    /// `WidgetTree::unexpired_safe_apex_for_content`, which is what
1475    /// fills the per-dispatch `EventContext` snapshot.
1476    pub(crate) fn safe_apex_for_content(&self, content_id: WidgetId) -> Option<Point> {
1477        self.stack
1478            .iter()
1479            .find(|o| o.content_id == content_id)
1480            .and_then(|o| o.safe_apex)
1481    }
1482
1483    /// Whether `point` currently sits inside the armed safe triangle of
1484    /// the overlay with the given id. `false` when the region is not
1485    /// armed or the overlay has no bounds yet.
1486    pub(crate) fn point_in_safe_region(&self, id: OverlayId, point: Point) -> bool {
1487        self.stack
1488            .iter()
1489            .find(|o| o.id == id)
1490            .and_then(|o| o.safe_apex.map(|apex| (apex, o.bounds)))
1491            .is_some_and(|(apex, bounds)| point_in_safe_triangle(point, apex, bounds))
1492    }
1493
1494    /// Change the dismiss behavior of an active overlay in place.
1495    ///
1496    /// Used by rich tooltips that promote from "ephemeral hover" to
1497    /// "sticky panel" after a dwell timer: at t=2s the tooltip calls
1498    /// this to swap `PointerLeave` for `EscapeOrClickOutside`, so the
1499    /// overlay stops vanishing the moment the pointer leaves the
1500    /// anchor. Also cancels any in-flight pointer-leave countdown.
1501    pub fn set_dismiss(&mut self, id: OverlayId, behavior: DismissBehavior) {
1502        if let Some(overlay) = self.stack.iter_mut().find(|o| o.id == id) {
1503            overlay.dismiss = behavior;
1504            overlay.pointer_leave_started_real = None;
1505            overlay.pointer_leave_started_sim = None;
1506        }
1507    }
1508}
1509
1510impl Default for OverlayManager {
1511    fn default() -> Self {
1512        Self::new()
1513    }
1514}
1515
1516impl std::fmt::Debug for OverlayManager {
1517    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1518        f.debug_struct("OverlayManager")
1519            .field("active_count", &self.stack.len())
1520            .finish()
1521    }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526    use super::*;
1527    use slotmap::KeyData;
1528
1529    fn fake_id(n: u64) -> WidgetId {
1530        KeyData::from_ffi(n).into()
1531    }
1532
1533    /// A `Below`/`Above` overlay must stay inside the viewport in **LTR**, not
1534    /// only RTL.
1535    ///
1536    /// The LTR arm was a bare `anchor.x`, so a popover whose trigger sat near
1537    /// the right edge — a status-bar button, a toolbar-trailing control — ran
1538    /// off the screen and lost its trailing edge. Nothing caught it because the
1539    /// RTL arm, which has always clamped, is the one that looks like it needs
1540    /// the arithmetic.
1541    #[test]
1542    fn a_wide_overlay_near_the_trailing_edge_is_clamped_into_the_viewport() {
1543        let vw = 1200.0;
1544        // A 380 px-wide popover under a 90 px button whose left edge is at 1035:
1545        // unclamped it would end at 1415, 215 px past the window.
1546        let anchor = Rect::new(1035.0, 760.0, 90.0, 28.0);
1547        let x = leading_aligned_x(anchor, 380.0, vw, false);
1548        assert!(
1549            x + 380.0 <= vw + 0.01,
1550            "overlay must not extend past the viewport: x={x}"
1551        );
1552        assert!(x >= 0.0, "and must not start off the leading edge: x={x}");
1553
1554        // Comfortably inside, the leading edge is still honoured exactly —
1555        // clamping must not nudge overlays that already fit.
1556        let inside = Rect::new(100.0, 760.0, 90.0, 28.0);
1557        assert_eq!(leading_aligned_x(inside, 380.0, vw, false), 100.0);
1558
1559        // RTL keeps aligning to the anchor's physical right edge.
1560        let x_rtl = leading_aligned_x(inside, 380.0, vw, true);
1561        assert!(x_rtl >= 0.0 && x_rtl + 380.0 <= vw + 0.01);
1562    }
1563
1564    /// A viewport narrower than the overlay pins the **leading** edge and clips
1565    /// the trailing one — losing the start of the content would hide the first
1566    /// thing the reader needs (a search field, a title).
1567    #[test]
1568    fn an_overlay_wider_than_the_viewport_keeps_its_leading_edge_visible() {
1569        let x = leading_aligned_x(Rect::new(40.0, 10.0, 60.0, 20.0), 900.0, 500.0, false);
1570        assert_eq!(x, 0.0);
1571    }
1572
1573    #[test]
1574    fn dismiss_all_fires_on_dismiss_callbacks() {
1575        // Regression: a `MenuItem`'s tap handler calls
1576        // `ctx.dismiss_all_overlays()` to close the menu after firing
1577        // its action. The dismiss callback set on the parent
1578        // `PopoverButton`/`PopoverIconButton`'s `OverlayRequest`
1579        // (which flips `popover_open` back to `false`) must fire so
1580        // the next trigger click reopens the menu instead of
1581        // observing stale-true and silently retoggling.
1582        use std::cell::Cell;
1583        use std::rc::Rc;
1584        let mut mgr = OverlayManager::new();
1585        let fired_a = Rc::new(Cell::new(0_u32));
1586        let fired_b = Rc::new(Cell::new(0_u32));
1587        let cb_a: OverlayDismissCallback = {
1588            let f = fired_a.clone();
1589            Rc::new(move || f.set(f.get() + 1))
1590        };
1591        let cb_b: OverlayDismissCallback = {
1592            let f = fired_b.clone();
1593            Rc::new(move || f.set(f.get() + 1))
1594        };
1595        mgr.show(OverlayRequest {
1596            content_id: fake_id(10),
1597            anchor: fake_id(1),
1598            placement: OverlayPlacement::Below,
1599            dismiss: DismissBehavior::ClickOutside,
1600            layer: OverlayLayer::InTree,
1601            parent_overlay: None,
1602            on_dismiss: Some(cb_a),
1603            fade_duration: None,
1604        });
1605        mgr.show(OverlayRequest {
1606            content_id: fake_id(11),
1607            anchor: fake_id(2),
1608            placement: OverlayPlacement::Below,
1609            dismiss: DismissBehavior::ClickOutside,
1610            layer: OverlayLayer::InTree,
1611            parent_overlay: None,
1612            on_dismiss: Some(cb_b),
1613            fade_duration: None,
1614        });
1615        let dismissed = mgr.dismiss_all();
1616        assert_eq!(dismissed.len(), 2);
1617        assert!(mgr.is_empty());
1618        assert_eq!(
1619            fired_a.get(),
1620            1,
1621            "first overlay's on_dismiss must fire exactly once",
1622        );
1623        assert_eq!(
1624            fired_b.get(),
1625            1,
1626            "second overlay's on_dismiss must fire exactly once",
1627        );
1628    }
1629
1630    #[test]
1631    fn show_and_dismiss() {
1632        let mut mgr = OverlayManager::new();
1633        let id = mgr.show(OverlayRequest {
1634            content_id: fake_id(10),
1635            anchor: fake_id(1),
1636            placement: OverlayPlacement::Below,
1637            dismiss: DismissBehavior::ClickOutside,
1638            layer: OverlayLayer::InTree,
1639            parent_overlay: None,
1640            on_dismiss: None,
1641            fade_duration: None,
1642        });
1643        assert_eq!(mgr.len(), 1);
1644
1645        mgr.dismiss(id);
1646        assert!(mgr.is_empty());
1647    }
1648
1649    #[test]
1650    fn cascade_dismissal() {
1651        let mut mgr = OverlayManager::new();
1652        let parent = mgr.show(OverlayRequest {
1653            content_id: fake_id(10),
1654            anchor: fake_id(1),
1655            placement: OverlayPlacement::Below,
1656            dismiss: DismissBehavior::ClickOutside,
1657            layer: OverlayLayer::InTree,
1658            parent_overlay: None,
1659            on_dismiss: None,
1660            fade_duration: None,
1661        });
1662        let _child = mgr.show(OverlayRequest {
1663            content_id: fake_id(11),
1664            anchor: fake_id(10),
1665            placement: OverlayPlacement::TrailingEdge,
1666            dismiss: DismissBehavior::ClickOutside,
1667            layer: OverlayLayer::InTree,
1668            parent_overlay: Some(parent),
1669            on_dismiss: None,
1670            fade_duration: None,
1671        });
1672        assert_eq!(mgr.len(), 2);
1673
1674        // Dismissing parent cascades to child
1675        mgr.dismiss(parent);
1676        assert!(mgr.is_empty());
1677    }
1678
1679    #[test]
1680    fn cascade_depth_is_bounded() {
1681        // A cyclic tooltip `:key` cascade (A→B→A) keeps minting nested
1682        // overlays with no natural ceiling. `MAX_OVERLAY_NESTING_DEPTH`
1683        // bounds it: once a new overlay would nest at the cap, `show`
1684        // drops it rather than growing the stack forever — and must not
1685        // panic, since this is reachable by real user clicking.
1686        let mut mgr = OverlayManager::new();
1687        let mut parent = mgr.show(OverlayRequest {
1688            content_id: fake_id(100),
1689            anchor: fake_id(1),
1690            placement: OverlayPlacement::Below,
1691            dismiss: DismissBehavior::Manual,
1692            layer: OverlayLayer::InTree,
1693            parent_overlay: None,
1694            on_dismiss: None,
1695            fade_duration: None,
1696        });
1697        // Root is depth 0; fill the chain so MAX overlays exist, the
1698        // deepest at depth MAX-1.
1699        for i in 1..MAX_OVERLAY_NESTING_DEPTH {
1700            parent = mgr.show(OverlayRequest {
1701                content_id: fake_id(100 + i as u64),
1702                anchor: fake_id(1),
1703                placement: OverlayPlacement::Below,
1704                dismiss: DismissBehavior::Manual,
1705                layer: OverlayLayer::InTree,
1706                parent_overlay: Some(parent),
1707                on_dismiss: None,
1708                fade_duration: None,
1709            });
1710        }
1711        assert_eq!(
1712            mgr.len(),
1713            MAX_OVERLAY_NESTING_DEPTH,
1714            "chain should fill exactly to the cap"
1715        );
1716
1717        // The next child would nest at depth == MAX → dropped.
1718        let dropped = mgr.show(OverlayRequest {
1719            content_id: fake_id(999),
1720            anchor: fake_id(1),
1721            placement: OverlayPlacement::Below,
1722            dismiss: DismissBehavior::Manual,
1723            layer: OverlayLayer::InTree,
1724            parent_overlay: Some(parent),
1725            on_dismiss: None,
1726            fade_duration: None,
1727        });
1728        assert_eq!(
1729            mgr.len(),
1730            MAX_OVERLAY_NESTING_DEPTH,
1731            "over-cap overlay must not be pushed"
1732        );
1733        assert!(
1734            mgr.stack.iter().all(|o| o.id != dropped),
1735            "the dropped overlay id must not appear in the stack"
1736        );
1737    }
1738
1739    #[test]
1740    fn dismiss_top() {
1741        let mut mgr = OverlayManager::new();
1742        let _a = mgr.show(OverlayRequest {
1743            content_id: fake_id(10),
1744            anchor: fake_id(1),
1745            placement: OverlayPlacement::Below,
1746            dismiss: DismissBehavior::Manual,
1747            layer: OverlayLayer::InTree,
1748            parent_overlay: None,
1749            on_dismiss: None,
1750            fade_duration: None,
1751        });
1752        let b = mgr.show(OverlayRequest {
1753            content_id: fake_id(11),
1754            anchor: fake_id(2),
1755            placement: OverlayPlacement::Below,
1756            dismiss: DismissBehavior::Manual,
1757            layer: OverlayLayer::InTree,
1758            parent_overlay: None,
1759            on_dismiss: None,
1760            fade_duration: None,
1761        });
1762
1763        let dismissed = mgr.dismiss_top();
1764        assert_eq!(dismissed.map(|(id, _, _)| id), Some(b));
1765        assert_eq!(mgr.len(), 1);
1766    }
1767
1768    #[test]
1769    fn click_outside_dismisses() {
1770        let mut mgr = OverlayManager::new();
1771        mgr.show(OverlayRequest {
1772            content_id: fake_id(10),
1773            anchor: fake_id(1),
1774            placement: OverlayPlacement::Below,
1775            dismiss: DismissBehavior::ClickOutside,
1776            layer: OverlayLayer::InTree,
1777            parent_overlay: None,
1778            on_dismiss: None,
1779            fade_duration: None,
1780        });
1781
1782        // Set overlay bounds
1783        let id = mgr.active_ids()[0];
1784        mgr.set_content_bounds(id, Size::new(100.0, 50.0));
1785
1786        // Click inside — no dismiss
1787        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(50.0, 25.0));
1788        assert!(dismissed.is_empty());
1789        assert_eq!(mgr.len(), 1);
1790
1791        // Click outside — dismissed
1792        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(500.0, 500.0));
1793        assert!(!dismissed.is_empty());
1794        assert!(mgr.is_empty());
1795    }
1796
1797    #[test]
1798    fn click_outside_returns_focus_restore() {
1799        let mut mgr = OverlayManager::new();
1800        let trigger = fake_id(99);
1801        mgr.show(OverlayRequest {
1802            content_id: fake_id(10),
1803            anchor: fake_id(1),
1804            placement: OverlayPlacement::Below,
1805            dismiss: DismissBehavior::ClickOutside,
1806            layer: OverlayLayer::InTree,
1807            parent_overlay: None,
1808            on_dismiss: None,
1809            fade_duration: None,
1810        });
1811        let id = mgr.active_ids()[0];
1812        mgr.set_content_bounds(id, Size::new(100.0, 50.0));
1813        mgr.set_top_focus_restore(trigger);
1814
1815        let (dismissed, focus_restore, _) = mgr.handle_click_outside(Point::new(500.0, 500.0));
1816        assert_eq!(dismissed.len(), 1);
1817        assert_eq!(focus_restore, Some(trigger));
1818    }
1819
1820    #[test]
1821    fn click_outside_focus_restore_picks_bottommost() {
1822        // When click-outside dismisses several stacked top-level
1823        // overlays in one pass, focus should land on the *oldest*
1824        // overlay's restore target — the focus state from before any
1825        // overlay opened. The topmost overlay's restore target points
1826        // inside the (now-dismissed) overlay below it.
1827        let mut mgr = OverlayManager::new();
1828        let pre_overlay_focus = fake_id(99);
1829        let inside_a = fake_id(50);
1830        let a = mgr.show(OverlayRequest {
1831            content_id: fake_id(10),
1832            anchor: fake_id(1),
1833            placement: OverlayPlacement::Below,
1834            dismiss: DismissBehavior::ClickOutside,
1835            layer: OverlayLayer::InTree,
1836            parent_overlay: None,
1837            on_dismiss: None,
1838            fade_duration: None,
1839        });
1840        mgr.set_content_bounds(a, Size::new(100.0, 50.0));
1841        mgr.set_top_focus_restore(pre_overlay_focus);
1842        let b = mgr.show(OverlayRequest {
1843            content_id: fake_id(11),
1844            anchor: fake_id(2),
1845            placement: OverlayPlacement::Below,
1846            dismiss: DismissBehavior::ClickOutside,
1847            layer: OverlayLayer::InTree,
1848            parent_overlay: None,
1849            on_dismiss: None,
1850            fade_duration: None,
1851        });
1852        mgr.set_content_bounds(b, Size::new(100.0, 50.0));
1853        mgr.set_top_focus_restore(inside_a);
1854
1855        let (_, focus_restore, _) = mgr.handle_click_outside(Point::new(500.0, 500.0));
1856        assert_eq!(focus_restore, Some(pre_overlay_focus));
1857    }
1858
1859    #[test]
1860    fn manual_dismiss_ignores_click_outside() {
1861        let mut mgr = OverlayManager::new();
1862        mgr.show(OverlayRequest {
1863            content_id: fake_id(10),
1864            anchor: fake_id(1),
1865            placement: OverlayPlacement::Below,
1866            dismiss: DismissBehavior::Manual,
1867            layer: OverlayLayer::InTree,
1868            parent_overlay: None,
1869            on_dismiss: None,
1870            fade_duration: None,
1871        });
1872
1873        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(500.0, 500.0));
1874        assert!(dismissed.is_empty());
1875        assert_eq!(mgr.len(), 1);
1876    }
1877
1878    #[test]
1879    fn escape_dismisses_escape_or_click_outside() {
1880        let mut mgr = OverlayManager::new();
1881        let id = mgr.show(OverlayRequest {
1882            content_id: fake_id(10),
1883            anchor: fake_id(1),
1884            placement: OverlayPlacement::Below,
1885            dismiss: DismissBehavior::EscapeOrClickOutside,
1886            layer: OverlayLayer::InTree,
1887            parent_overlay: None,
1888            on_dismiss: None,
1889            fade_duration: None,
1890        });
1891
1892        let dismissed = mgr.try_dismiss_top_on_escape();
1893        assert_eq!(dismissed.map(|(oid, _, _)| oid), Some(id));
1894        assert!(mgr.is_empty());
1895    }
1896
1897    #[test]
1898    fn escape_dismisses_escape_key_only() {
1899        let mut mgr = OverlayManager::new();
1900        let id = mgr.show(OverlayRequest {
1901            content_id: fake_id(10),
1902            anchor: fake_id(1),
1903            placement: OverlayPlacement::Below,
1904            dismiss: DismissBehavior::EscapeKey,
1905            layer: OverlayLayer::InTree,
1906            parent_overlay: None,
1907            on_dismiss: None,
1908            fade_duration: None,
1909        });
1910
1911        // Escape should dismiss
1912        let dismissed = mgr.try_dismiss_top_on_escape();
1913        assert_eq!(dismissed.map(|(oid, _, _)| oid), Some(id));
1914        assert!(mgr.is_empty());
1915    }
1916
1917    #[test]
1918    fn escape_does_not_dismiss_click_outside_only() {
1919        let mut mgr = OverlayManager::new();
1920        mgr.show(OverlayRequest {
1921            content_id: fake_id(10),
1922            anchor: fake_id(1),
1923            placement: OverlayPlacement::Below,
1924            dismiss: DismissBehavior::ClickOutside,
1925            layer: OverlayLayer::InTree,
1926            parent_overlay: None,
1927            on_dismiss: None,
1928            fade_duration: None,
1929        });
1930
1931        assert!(mgr.try_dismiss_top_on_escape().is_none());
1932        assert_eq!(mgr.len(), 1);
1933    }
1934
1935    #[test]
1936    fn escape_does_not_dismiss_manual() {
1937        let mut mgr = OverlayManager::new();
1938        mgr.show(OverlayRequest {
1939            content_id: fake_id(10),
1940            anchor: fake_id(1),
1941            placement: OverlayPlacement::Below,
1942            dismiss: DismissBehavior::Manual,
1943            layer: OverlayLayer::InTree,
1944            parent_overlay: None,
1945            on_dismiss: None,
1946            fade_duration: None,
1947        });
1948
1949        assert!(mgr.try_dismiss_top_on_escape().is_none());
1950        assert_eq!(mgr.len(), 1);
1951    }
1952
1953    #[test]
1954    fn click_outside_dismisses_escape_or_click_outside() {
1955        let mut mgr = OverlayManager::new();
1956        mgr.show(OverlayRequest {
1957            content_id: fake_id(10),
1958            anchor: fake_id(1),
1959            placement: OverlayPlacement::Below,
1960            dismiss: DismissBehavior::EscapeOrClickOutside,
1961            layer: OverlayLayer::InTree,
1962            parent_overlay: None,
1963            on_dismiss: None,
1964            fade_duration: None,
1965        });
1966
1967        let id = mgr.active_ids()[0];
1968        mgr.set_content_bounds(id, Size::new(100.0, 50.0));
1969
1970        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(500.0, 500.0));
1971        assert!(!dismissed.is_empty());
1972        assert!(mgr.is_empty());
1973    }
1974
1975    #[test]
1976    fn click_outside_reports_click_opened_anchors_only() {
1977        // An outside click dismisses both a click-opened dropdown and a
1978        // hover-opened tooltip, but only the click-opened overlay's anchor
1979        // is reported as a re-toggle guard: clicking a tooltip's anchor
1980        // should still fall through to the widget beneath.
1981        let mut mgr = OverlayManager::new();
1982        let click_anchor = fake_id(1);
1983        let hover_anchor = fake_id(2);
1984
1985        let click_overlay = mgr.show(OverlayRequest {
1986            content_id: fake_id(10),
1987            anchor: click_anchor,
1988            placement: OverlayPlacement::Below,
1989            dismiss: DismissBehavior::EscapeOrClickOutside,
1990            layer: OverlayLayer::InTree,
1991            parent_overlay: None,
1992            on_dismiss: None,
1993            fade_duration: None,
1994        });
1995        mgr.set_content_bounds(click_overlay, Size::new(100.0, 50.0));
1996
1997        let hover_overlay = mgr.show(OverlayRequest {
1998            content_id: fake_id(11),
1999            anchor: hover_anchor,
2000            placement: OverlayPlacement::Below,
2001            dismiss: DismissBehavior::PointerLeave {
2002                delay: std::time::Duration::from_millis(150),
2003            },
2004            layer: OverlayLayer::InTree,
2005            parent_overlay: None,
2006            on_dismiss: None,
2007            fade_duration: None,
2008        });
2009        mgr.set_content_bounds(hover_overlay, Size::new(100.0, 50.0));
2010
2011        let (dismissed, _focus, toggle_anchors) =
2012            mgr.handle_click_outside(Point::new(500.0, 500.0));
2013
2014        // Both overlays close on the outside click...
2015        assert_eq!(dismissed.len(), 2);
2016        assert!(mgr.is_empty());
2017        // ...but only the click-opened dropdown contributes a guard anchor.
2018        assert_eq!(toggle_anchors, vec![click_anchor]);
2019    }
2020
2021    #[test]
2022    fn click_outside_does_not_dismiss_escape_key_only() {
2023        let mut mgr = OverlayManager::new();
2024        mgr.show(OverlayRequest {
2025            content_id: fake_id(10),
2026            anchor: fake_id(1),
2027            placement: OverlayPlacement::Below,
2028            dismiss: DismissBehavior::EscapeKey,
2029            layer: OverlayLayer::InTree,
2030            parent_overlay: None,
2031            on_dismiss: None,
2032            fade_duration: None,
2033        });
2034
2035        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(500.0, 500.0));
2036        assert!(dismissed.is_empty());
2037        assert_eq!(mgr.len(), 1);
2038    }
2039
2040    #[test]
2041    fn click_outside_is_layered_over_a_modal() {
2042        // A modal card with a sticky tooltip floating above it (as a rich
2043        // tooltip becomes after it dwells). Regression for: while any modal
2044        // (or its full-viewport scrim) was open, the old stack-wide `hit_test`
2045        // short-circuit made *no* click-outside overlay dismissable, so the
2046        // sticky tooltip never closed on a click elsewhere in the modal.
2047        fn set_bounds(mgr: &mut OverlayManager, id: OverlayId, r: Rect) {
2048            mgr.stack.iter_mut().find(|o| o.id == id).unwrap().bounds = r;
2049        }
2050        // Build a fresh [modal, tooltip] stack. The modal card spans
2051        // x∈[300,900], y∈[60,740]; the sticky tooltip sits near the card's
2052        // bottom and *overflows* below it (y∈[620,760]).
2053        fn build() -> (OverlayManager, OverlayId, OverlayId) {
2054            let mut mgr = OverlayManager::new();
2055            let modal = mgr.show(OverlayRequest {
2056                content_id: fake_id(10),
2057                anchor: fake_id(1),
2058                placement: OverlayPlacement::Centered,
2059                dismiss: DismissBehavior::EscapeOrClickOutside,
2060                layer: OverlayLayer::InTree,
2061                parent_overlay: None,
2062                on_dismiss: None,
2063                fade_duration: None,
2064            });
2065            set_bounds(&mut mgr, modal, Rect::new(300.0, 60.0, 600.0, 680.0));
2066            let tooltip = mgr.show(OverlayRequest {
2067                content_id: fake_id(11),
2068                anchor: fake_id(2),
2069                placement: OverlayPlacement::Below,
2070                // A promoted sticky rich tooltip: EscapeOrClickOutside.
2071                dismiss: DismissBehavior::EscapeOrClickOutside,
2072                layer: OverlayLayer::InTree,
2073                parent_overlay: None,
2074                on_dismiss: None,
2075                fade_duration: None,
2076            });
2077            set_bounds(&mut mgr, tooltip, Rect::new(400.0, 620.0, 200.0, 140.0));
2078            (mgr, modal, tooltip)
2079        }
2080
2081        // 1. Click elsewhere inside the modal card (outside the tooltip) →
2082        //    the tooltip (stacked above) dismisses; the modal stays up.
2083        let (mut mgr, modal, _tooltip) = build();
2084        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(350.0, 100.0));
2085        assert!(dismissed.contains(&fake_id(11)), "tooltip should dismiss");
2086        assert!(
2087            mgr.active_ids().contains(&modal),
2088            "modal must survive a click inside itself"
2089        );
2090
2091        // 2. Click inside the tooltip — even the part overflowing below the
2092        //    card — leaves BOTH standing (nothing is stacked above the hit).
2093        let (mut mgr, modal, tooltip) = build();
2094        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(450.0, 750.0));
2095        assert!(
2096            dismissed.is_empty(),
2097            "clicking the tooltip dismisses nothing"
2098        );
2099        assert!(mgr.active_ids().contains(&modal));
2100        assert!(mgr.active_ids().contains(&tooltip));
2101
2102        // 3. Click the bare background (outside both) → both dismiss, as
2103        //    before (each per its own click-outside policy).
2104        let (mut mgr, _modal, _tooltip) = build();
2105        let (dismissed, _, _) = mgr.handle_click_outside(Point::new(10.0, 10.0));
2106        assert!(dismissed.contains(&fake_id(10)));
2107        assert!(dismissed.contains(&fake_id(11)));
2108        assert!(mgr.is_empty());
2109    }
2110
2111    #[test]
2112    fn active_content_ids() {
2113        let mut mgr = OverlayManager::new();
2114        mgr.show(OverlayRequest {
2115            content_id: fake_id(10),
2116            anchor: fake_id(1),
2117            placement: OverlayPlacement::Below,
2118            dismiss: DismissBehavior::Manual,
2119            layer: OverlayLayer::InTree,
2120            parent_overlay: None,
2121            on_dismiss: None,
2122            fade_duration: None,
2123        });
2124        mgr.show(OverlayRequest {
2125            content_id: fake_id(20),
2126            anchor: fake_id(2),
2127            placement: OverlayPlacement::Below,
2128            dismiss: DismissBehavior::Manual,
2129            layer: OverlayLayer::InTree,
2130            parent_overlay: None,
2131            on_dismiss: None,
2132            fade_duration: None,
2133        });
2134
2135        let ids = mgr.active_content_ids();
2136        assert_eq!(ids.len(), 2);
2137        assert_eq!(ids[0], fake_id(10));
2138        assert_eq!(ids[1], fake_id(20));
2139    }
2140
2141    #[test]
2142    fn hit_test_topmost_first() {
2143        let mut mgr = OverlayManager::new();
2144        let a = mgr.show(OverlayRequest {
2145            content_id: fake_id(10),
2146            anchor: fake_id(1),
2147            placement: OverlayPlacement::Below,
2148            dismiss: DismissBehavior::Manual,
2149            layer: OverlayLayer::InTree,
2150            parent_overlay: None,
2151            on_dismiss: None,
2152            fade_duration: None,
2153        });
2154        let b = mgr.show(OverlayRequest {
2155            content_id: fake_id(11),
2156            anchor: fake_id(2),
2157            placement: OverlayPlacement::Below,
2158            dismiss: DismissBehavior::Manual,
2159            layer: OverlayLayer::InTree,
2160            parent_overlay: None,
2161            on_dismiss: None,
2162            fade_duration: None,
2163        });
2164
2165        // Both overlays at origin with same bounds
2166        mgr.set_content_bounds(a, Size::new(100.0, 50.0));
2167        mgr.set_content_bounds(b, Size::new(100.0, 50.0));
2168
2169        // Hit test should find topmost (b)
2170        assert_eq!(mgr.hit_test(Point::new(50.0, 25.0)), Some(b));
2171    }
2172
2173    #[test]
2174    fn hit_test_skips_a_fading_out_overlay() {
2175        // Regression: dismissing a faded overlay only starts the fade-out and
2176        // defers stack removal, so the overlay lingers in the stack (and its
2177        // content stays interactive) for the fade duration. `hit_test` must
2178        // treat it as gone — matching `active_ids` — so clicks reach the
2179        // widget underneath and outside-click dismissal of lower overlays
2180        // isn't suppressed by the ghost.
2181        let mut mgr = OverlayManager::new();
2182        let id = mgr.show(OverlayRequest {
2183            content_id: fake_id(10),
2184            anchor: fake_id(1),
2185            placement: OverlayPlacement::Below,
2186            dismiss: DismissBehavior::ClickOutside,
2187            layer: OverlayLayer::InTree,
2188            parent_overlay: None,
2189            on_dismiss: None,
2190            fade_duration: Some(Duration::from_millis(150)),
2191        });
2192        mgr.set_content_bounds(id, Size::new(100.0, 50.0));
2193        let point = Point::new(50.0, 25.0);
2194
2195        // Live overlay: hittable, and reported by active_ids.
2196        assert_eq!(mgr.hit_test(point), Some(id));
2197        assert!(mgr.active_ids().contains(&id));
2198
2199        // The fade machinery is populated post-show by the framework.
2200        mgr.attach_fade(id, Signal::new(1.0), Duration::from_millis(150));
2201
2202        // Dismissing only starts the fade-out — the overlay is still in the
2203        // stack until `process_pending_fade_dismissals` fires.
2204        let dismissed = mgr.dismiss(id);
2205        assert!(dismissed.is_empty(), "fade-out defers removal");
2206        assert_eq!(mgr.stack.len(), 1, "overlay lingers during the fade");
2207
2208        // Both predicates now agree it's gone.
2209        assert_eq!(
2210            mgr.hit_test(point),
2211            None,
2212            "fading overlay no longer eats clicks"
2213        );
2214        assert!(!mgr.active_ids().contains(&id));
2215    }
2216
2217    #[test]
2218    fn centered_placement_uses_viewport_center() {
2219        let mut mgr = OverlayManager::new();
2220        let id = mgr.show(OverlayRequest {
2221            content_id: fake_id(10),
2222            anchor: fake_id(1),
2223            placement: OverlayPlacement::Centered,
2224            dismiss: DismissBehavior::Manual,
2225            layer: OverlayLayer::InTree,
2226            parent_overlay: None,
2227            on_dismiss: None,
2228            fade_duration: None,
2229        });
2230
2231        mgr.set_content_bounds(id, Size::new(240.0, 120.0));
2232        mgr.position_overlays(
2233            |_| Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
2234            (800.0, 600.0),
2235            LayoutDirection::LeftToRight,
2236        );
2237
2238        let bounds = mgr
2239            .stack
2240            .iter()
2241            .find(|overlay| overlay.id == id)
2242            .unwrap()
2243            .bounds;
2244        assert!((bounds.x - 280.0).abs() < 0.01);
2245        assert!((bounds.y - 240.0).abs() < 0.01);
2246    }
2247
2248    #[test]
2249    fn bottom_center_placement_uses_viewport_bottom_margin() {
2250        let mut mgr = OverlayManager::new();
2251        let id = mgr.show(OverlayRequest {
2252            content_id: fake_id(10),
2253            anchor: fake_id(1),
2254            placement: OverlayPlacement::BottomCenter,
2255            dismiss: DismissBehavior::Manual,
2256            layer: OverlayLayer::InTree,
2257            parent_overlay: None,
2258            on_dismiss: None,
2259            fade_duration: None,
2260        });
2261
2262        mgr.set_content_bounds(id, Size::new(240.0, 64.0));
2263        mgr.position_overlays(
2264            |_| Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
2265            (800.0, 600.0),
2266            LayoutDirection::LeftToRight,
2267        );
2268
2269        let bounds = mgr
2270            .stack
2271            .iter()
2272            .find(|overlay| overlay.id == id)
2273            .unwrap()
2274            .bounds;
2275        assert!((bounds.x - 280.0).abs() < 0.01);
2276        assert!((bounds.y - 512.0).abs() < 0.01);
2277    }
2278
2279    // --- ViewportCorner placement ---
2280
2281    fn show_corner_overlay(
2282        mgr: &mut OverlayManager,
2283        corner: Corner,
2284        margin: Vec2,
2285        size: Size,
2286    ) -> OverlayId {
2287        let id = mgr.show(OverlayRequest {
2288            content_id: fake_id(10),
2289            anchor: fake_id(1),
2290            placement: OverlayPlacement::ViewportCorner { corner, margin },
2291            dismiss: DismissBehavior::Manual,
2292            layer: OverlayLayer::InTree,
2293            parent_overlay: None,
2294            on_dismiss: None,
2295            fade_duration: None,
2296        });
2297        mgr.set_content_bounds(id, size);
2298        id
2299    }
2300
2301    fn overlay_bounds(mgr: &OverlayManager, id: OverlayId) -> Rect {
2302        mgr.stack.iter().find(|o| o.id == id).unwrap().bounds
2303    }
2304
2305    #[test]
2306    fn viewport_corner_top_leading_ltr() {
2307        let mut mgr = OverlayManager::new();
2308        let id = show_corner_overlay(
2309            &mut mgr,
2310            Corner::TopLeading,
2311            Vec2::new(24.0, 24.0),
2312            Size::new(380.0, 100.0),
2313        );
2314        mgr.position_overlays(
2315            |_| Some(Rect::ZERO),
2316            (800.0, 600.0),
2317            LayoutDirection::LeftToRight,
2318        );
2319        let b = overlay_bounds(&mgr, id);
2320        assert!((b.x - 24.0).abs() < 0.01, "x = {}", b.x);
2321        assert!((b.y - 24.0).abs() < 0.01, "y = {}", b.y);
2322    }
2323
2324    #[test]
2325    fn viewport_corner_top_trailing_ltr() {
2326        let mut mgr = OverlayManager::new();
2327        let id = show_corner_overlay(
2328            &mut mgr,
2329            Corner::TopTrailing,
2330            Vec2::new(24.0, 24.0),
2331            Size::new(380.0, 100.0),
2332        );
2333        mgr.position_overlays(
2334            |_| Some(Rect::ZERO),
2335            (800.0, 600.0),
2336            LayoutDirection::LeftToRight,
2337        );
2338        let b = overlay_bounds(&mgr, id);
2339        // 800 - 380 - 24 = 396
2340        assert!((b.x - 396.0).abs() < 0.01, "x = {}", b.x);
2341        assert!((b.y - 24.0).abs() < 0.01);
2342    }
2343
2344    #[test]
2345    fn viewport_corner_bottom_leading_ltr() {
2346        let mut mgr = OverlayManager::new();
2347        let id = show_corner_overlay(
2348            &mut mgr,
2349            Corner::BottomLeading,
2350            Vec2::new(24.0, 24.0),
2351            Size::new(380.0, 100.0),
2352        );
2353        mgr.position_overlays(
2354            |_| Some(Rect::ZERO),
2355            (800.0, 600.0),
2356            LayoutDirection::LeftToRight,
2357        );
2358        let b = overlay_bounds(&mgr, id);
2359        // 600 - 100 - 24 = 476
2360        assert!((b.x - 24.0).abs() < 0.01);
2361        assert!((b.y - 476.0).abs() < 0.01, "y = {}", b.y);
2362    }
2363
2364    #[test]
2365    fn viewport_corner_bottom_trailing_ltr() {
2366        let mut mgr = OverlayManager::new();
2367        let id = show_corner_overlay(
2368            &mut mgr,
2369            Corner::BottomTrailing,
2370            Vec2::new(24.0, 24.0),
2371            Size::new(380.0, 100.0),
2372        );
2373        mgr.position_overlays(
2374            |_| Some(Rect::ZERO),
2375            (800.0, 600.0),
2376            LayoutDirection::LeftToRight,
2377        );
2378        let b = overlay_bounds(&mgr, id);
2379        assert!((b.x - 396.0).abs() < 0.01);
2380        assert!((b.y - 476.0).abs() < 0.01);
2381    }
2382
2383    #[test]
2384    fn viewport_corner_top_trailing_rtl_flips_to_left() {
2385        let mut mgr = OverlayManager::new();
2386        let id = show_corner_overlay(
2387            &mut mgr,
2388            Corner::TopTrailing,
2389            Vec2::new(24.0, 24.0),
2390            Size::new(380.0, 100.0),
2391        );
2392        mgr.position_overlays(
2393            |_| Some(Rect::ZERO),
2394            (800.0, 600.0),
2395            LayoutDirection::RightToLeft,
2396        );
2397        let b = overlay_bounds(&mgr, id);
2398        // RTL flips Trailing to physical left
2399        assert!((b.x - 24.0).abs() < 0.01, "x = {}", b.x);
2400        assert!((b.y - 24.0).abs() < 0.01);
2401    }
2402
2403    #[test]
2404    fn viewport_corner_bottom_leading_rtl_flips_to_right() {
2405        let mut mgr = OverlayManager::new();
2406        let id = show_corner_overlay(
2407            &mut mgr,
2408            Corner::BottomLeading,
2409            Vec2::new(24.0, 24.0),
2410            Size::new(380.0, 100.0),
2411        );
2412        mgr.position_overlays(
2413            |_| Some(Rect::ZERO),
2414            (800.0, 600.0),
2415            LayoutDirection::RightToLeft,
2416        );
2417        let b = overlay_bounds(&mgr, id);
2418        assert!((b.x - 396.0).abs() < 0.01, "x = {}", b.x);
2419        assert!((b.y - 476.0).abs() < 0.01);
2420    }
2421
2422    #[test]
2423    fn viewport_corner_ignores_anchor_bounds() {
2424        let mut mgr = OverlayManager::new();
2425        let id = show_corner_overlay(
2426            &mut mgr,
2427            Corner::BottomTrailing,
2428            Vec2::new(0.0, 0.0),
2429            Size::new(100.0, 100.0),
2430        );
2431        // Even with an absurd anchor location, ViewportCorner only uses viewport.
2432        mgr.position_overlays(
2433            |_| Some(Rect::new(123.0, 456.0, 7.0, 8.0)),
2434            (800.0, 600.0),
2435            LayoutDirection::LeftToRight,
2436        );
2437        let b = overlay_bounds(&mgr, id);
2438        assert_eq!((b.x, b.y), (700.0, 500.0));
2439    }
2440
2441    #[test]
2442    fn near_anchor_horizontal_is_direction_aware() {
2443        // NearAnchor (used by tooltips): LTR aligns the content's leading
2444        // (left) edge to the anchor's left edge; RTL mirrors it, aligning
2445        // the content's trailing (right) edge to the anchor's right edge.
2446        // Anchor x=600, w=100 (right edge 700); content w=200; offset 0.
2447        // Viewport 800×600 — wide enough that the clamp doesn't bite.
2448        let anchor = Rect::new(600.0, 100.0, 100.0, 20.0);
2449        let resolved_x = |dir: LayoutDirection| {
2450            let mut mgr = OverlayManager::new();
2451            let id = mgr.show(OverlayRequest {
2452                content_id: fake_id(10),
2453                anchor: fake_id(1),
2454                placement: OverlayPlacement::NearAnchor {
2455                    offset: Vec2::new(0.0, 8.0),
2456                },
2457                dismiss: DismissBehavior::Manual,
2458                layer: OverlayLayer::InTree,
2459                parent_overlay: None,
2460                on_dismiss: None,
2461                fade_duration: None,
2462            });
2463            mgr.set_content_bounds(id, Size::new(200.0, 50.0));
2464            mgr.position_overlays(|_| Some(anchor), (800.0, 600.0), dir);
2465            overlay_bounds(&mgr, id).x
2466        };
2467        // LTR: anchor.x + offset.x = 600.
2468        assert!(
2469            (resolved_x(LayoutDirection::LeftToRight) - 600.0).abs() < 0.01,
2470            "LTR x = {}",
2471            resolved_x(LayoutDirection::LeftToRight)
2472        );
2473        // RTL: anchor.x + anchor.width - content.w - offset.x = 500.
2474        assert!(
2475            (resolved_x(LayoutDirection::RightToLeft) - 500.0).abs() < 0.01,
2476            "RTL x = {}",
2477            resolved_x(LayoutDirection::RightToLeft)
2478        );
2479    }
2480
2481    // --- Auto-dismiss pause / resume ---
2482
2483    #[test]
2484    fn pause_auto_dismiss_removes_overlay_from_deadline_set() {
2485        let mut mgr = OverlayManager::new();
2486        let id = mgr.show_for(
2487            OverlayRequest {
2488                content_id: fake_id(10),
2489                anchor: fake_id(1),
2490                placement: OverlayPlacement::Centered,
2491                dismiss: DismissBehavior::Manual,
2492                layer: OverlayLayer::InTree,
2493                parent_overlay: None,
2494                on_dismiss: None,
2495                fade_duration: None,
2496            },
2497            Duration::from_secs(10),
2498        );
2499        assert!(mgr.next_auto_dismiss_deadline().is_some());
2500        assert!(!mgr.is_auto_dismiss_paused(id));
2501
2502        mgr.pause_auto_dismiss(id);
2503        assert!(mgr.is_auto_dismiss_paused(id));
2504        assert!(
2505            mgr.next_auto_dismiss_deadline().is_none(),
2506            "paused overlay must drop out of the deadline-min query"
2507        );
2508
2509        mgr.resume_auto_dismiss(id);
2510        assert!(!mgr.is_auto_dismiss_paused(id));
2511        assert!(mgr.next_auto_dismiss_deadline().is_some());
2512    }
2513
2514    #[test]
2515    fn pause_then_resume_restores_remaining_time() {
2516        let mut mgr = OverlayManager::new();
2517        let id = mgr.show_for(
2518            OverlayRequest {
2519                content_id: fake_id(11),
2520                anchor: fake_id(1),
2521                placement: OverlayPlacement::Centered,
2522                dismiss: DismissBehavior::Manual,
2523                layer: OverlayLayer::InTree,
2524                parent_overlay: None,
2525                on_dismiss: None,
2526                fade_duration: None,
2527            },
2528            Duration::from_secs(10),
2529        );
2530
2531        mgr.pause_auto_dismiss(id);
2532        // Sleep equivalent: rely on the fact pausing right after show
2533        // captures ~10s remaining (elapsed is ~0).
2534        let overlay = mgr.stack.iter().find(|o| o.id == id).unwrap();
2535        let remaining = overlay.paused_remaining.unwrap();
2536        assert!(
2537            remaining >= Duration::from_secs(9),
2538            "remaining should be near the original 10s, got {remaining:?}"
2539        );
2540        assert!(remaining <= Duration::from_secs(10));
2541
2542        mgr.resume_auto_dismiss(id);
2543        let overlay = mgr.stack.iter().find(|o| o.id == id).unwrap();
2544        // After resume, auto_dismiss_after equals the previously-stashed
2545        // remaining, and shown_at_real has been refreshed so the new
2546        // deadline starts from "now + remaining".
2547        assert_eq!(overlay.auto_dismiss_after, Some(remaining));
2548        assert!(overlay.paused_remaining.is_none());
2549    }
2550
2551    #[test]
2552    fn pause_is_idempotent() {
2553        let mut mgr = OverlayManager::new();
2554        let id = mgr.show_for(
2555            OverlayRequest {
2556                content_id: fake_id(12),
2557                anchor: fake_id(1),
2558                placement: OverlayPlacement::Centered,
2559                dismiss: DismissBehavior::Manual,
2560                layer: OverlayLayer::InTree,
2561                parent_overlay: None,
2562                on_dismiss: None,
2563                fade_duration: None,
2564            },
2565            Duration::from_secs(10),
2566        );
2567        mgr.pause_auto_dismiss(id);
2568        let first_remaining = mgr.stack[0].paused_remaining;
2569        mgr.pause_auto_dismiss(id); // second pause must not overwrite
2570        let second_remaining = mgr.stack[0].paused_remaining;
2571        assert_eq!(
2572            first_remaining, second_remaining,
2573            "double-pause must preserve the original stashed remaining"
2574        );
2575    }
2576
2577    #[test]
2578    fn resume_on_unpaused_is_noop() {
2579        let mut mgr = OverlayManager::new();
2580        let id = mgr.show_for(
2581            OverlayRequest {
2582                content_id: fake_id(13),
2583                anchor: fake_id(1),
2584                placement: OverlayPlacement::Centered,
2585                dismiss: DismissBehavior::Manual,
2586                layer: OverlayLayer::InTree,
2587                parent_overlay: None,
2588                on_dismiss: None,
2589                fade_duration: None,
2590            },
2591            Duration::from_secs(10),
2592        );
2593        let before = mgr.stack[0].auto_dismiss_after;
2594        mgr.resume_auto_dismiss(id); // never paused
2595        let after = mgr.stack[0].auto_dismiss_after;
2596        assert_eq!(before, after);
2597    }
2598
2599    #[test]
2600    fn pause_on_persistent_overlay_is_noop() {
2601        let mut mgr = OverlayManager::new();
2602        let id = mgr.show(OverlayRequest {
2603            content_id: fake_id(14),
2604            anchor: fake_id(1),
2605            placement: OverlayPlacement::Centered,
2606            dismiss: DismissBehavior::Manual,
2607            layer: OverlayLayer::InTree,
2608            parent_overlay: None,
2609            on_dismiss: None,
2610            fade_duration: None,
2611        });
2612        // No auto_dismiss_after — pause should be a no-op.
2613        mgr.pause_auto_dismiss(id);
2614        assert!(!mgr.is_auto_dismiss_paused(id));
2615        assert!(mgr.stack[0].paused_remaining.is_none());
2616    }
2617
2618    #[test]
2619    fn pause_on_unknown_id_is_noop() {
2620        let mut mgr = OverlayManager::new();
2621        mgr.pause_auto_dismiss(OverlayId::new(9999)); // must not panic
2622        mgr.resume_auto_dismiss(OverlayId::new(9999));
2623    }
2624
2625    #[test]
2626    fn viewport_corner_zero_margin_snaps_to_edge() {
2627        let mut mgr = OverlayManager::new();
2628        let id = show_corner_overlay(
2629            &mut mgr,
2630            Corner::TopLeading,
2631            Vec2::ZERO,
2632            Size::new(50.0, 50.0),
2633        );
2634        mgr.position_overlays(
2635            |_| Some(Rect::ZERO),
2636            (800.0, 600.0),
2637            LayoutDirection::LeftToRight,
2638        );
2639        let b = overlay_bounds(&mgr, id);
2640        assert_eq!((b.x, b.y), (0.0, 0.0));
2641    }
2642}