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