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