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