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