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