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