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