Skip to main content

teksilo_core/
overlay.rs

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