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