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