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