Skip to main content

teksilo_core/
overlay.rs

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