blinc_layout/widgets/overlay.rs
1//! Overlay System - Modals, Dialogs, Context Menus, Toasts
2//!
3//! A flexible overlay infrastructure that renders in a separate pass after the main UI tree,
4//! guaranteeing overlays always appear on top regardless of z-index complexity.
5//!
6//! # Architecture
7//!
8//! - **OverlayManager**: Global registry accessible via `ctx.overlay_manager()`
9//! - **Separate Render Pass**: Overlays render after main tree for guaranteed z-ordering
10//! - **FSM-driven State**: Each overlay has Opening/Open/Closing/Closed states
11//! - **Motion Animations**: Enter/exit animations via the Motion system
12//!
13//! # Example
14//!
15//! ```ignore
16//! use blinc_layout::prelude::*;
17//!
18//! fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
19//! let overlay_manager = ctx.overlay_manager();
20//!
21//! div()
22//! .child(
23//! button("Open Modal").on_click({
24//! let mgr = overlay_manager.clone();
25//! move |_| {
26//! mgr.modal()
27//! .child(my_modal_content())
28//! .show();
29//! }
30//! })
31//! )
32//! }
33//! ```
34
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::{Arc, Mutex};
37
38use blinc_animation::{AnimationPreset, MultiKeyframeAnimation};
39use blinc_core::Color;
40use indexmap::IndexMap;
41
42use crate::div::{div, Div};
43use crate::key::InstanceKey;
44use crate::renderer::RenderTree;
45use crate::stack::stack;
46use crate::stateful::StateTransitions;
47use crate::tree::LayoutNodeId;
48
49// =============================================================================
50// Overlay Event Types
51// =============================================================================
52
53/// Custom event types for overlay state machine
54pub mod overlay_events {
55 /// Open the overlay (Closed -> Opening)
56 pub const OPEN: u32 = 20001;
57 /// Close the overlay (Open -> Closing)
58 pub const CLOSE: u32 = 20002;
59 /// Animation completed (Opening -> Open, Closing -> Closed)
60 pub const ANIMATION_COMPLETE: u32 = 20003;
61 /// Backdrop was clicked
62 pub const BACKDROP_CLICK: u32 = 20004;
63 /// Escape key pressed
64 pub const ESCAPE: u32 = 20005;
65 /// Cancel a pending close (Closing -> Open) - used when mouse re-enters hover card
66 pub const CANCEL_CLOSE: u32 = 20006;
67 /// Mouse left trigger/content - start close delay countdown (Open -> PendingClose)
68 pub const HOVER_LEAVE: u32 = 20007;
69 /// Mouse re-entered trigger/content - cancel close delay (PendingClose -> Open)
70 pub const HOVER_ENTER: u32 = 20008;
71 /// Close delay expired - now actually close (PendingClose -> Closing)
72 pub const DELAY_EXPIRED: u32 = 20009;
73}
74
75// =============================================================================
76// OverlayKind
77// =============================================================================
78
79/// Categorizes overlay behavior and default configuration
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
81pub enum OverlayKind {
82 /// Modal dialog - blocks interaction, centered, has backdrop
83 #[default]
84 Modal,
85 /// Dialog - like modal but semantic (confirm/alert)
86 Dialog,
87 /// Context menu - positioned at cursor, click-away dismisses
88 ContextMenu,
89 /// Toast notification - positioned in corner, auto-dismiss, no block
90 Toast,
91 /// Tooltip - follows cursor, no block, short-lived
92 Tooltip,
93 /// Dropdown - positioned relative to anchor element
94 Dropdown,
95}
96
97// =============================================================================
98// AnchorDirection - for positioned overlays like hover cards
99// =============================================================================
100
101/// Direction an overlay is anchored relative to a trigger element.
102/// Used to calculate correct bounds for occlusion testing.
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
104pub enum AnchorDirection {
105 /// Overlay appears above the trigger (y is the bottom edge of the overlay)
106 Top,
107 /// Overlay appears below the trigger (y is the top edge of the overlay)
108 #[default]
109 Bottom,
110 /// Overlay appears to the left of the trigger (x is the right edge)
111 Left,
112 /// Overlay appears to the right of the trigger (x is the left edge)
113 Right,
114}
115
116// =============================================================================
117// OverlayState - FSM for overlay lifecycle
118// =============================================================================
119
120/// State machine for overlay lifecycle
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
122pub enum OverlayState {
123 /// Overlay is not visible
124 #[default]
125 Closed,
126 /// Enter animation is playing
127 Opening,
128 /// Overlay is fully visible and interactive
129 Open,
130 /// Mouse left overlay/trigger, waiting for close delay to expire
131 /// Used by hover cards to allow mouse movement between trigger and content
132 PendingClose,
133 /// Exit animation is playing
134 Closing,
135}
136
137impl OverlayState {
138 /// Check if overlay should be rendered
139 pub fn is_visible(&self) -> bool {
140 !matches!(self, OverlayState::Closed)
141 }
142
143 /// Check if overlay is fully open and interactive
144 pub fn is_open(&self) -> bool {
145 matches!(self, OverlayState::Open | OverlayState::PendingClose)
146 }
147
148 /// Check if overlay is animating
149 pub fn is_animating(&self) -> bool {
150 matches!(self, OverlayState::Opening | OverlayState::Closing)
151 }
152
153 /// Check if overlay is waiting for close delay to expire
154 pub fn is_pending_close(&self) -> bool {
155 matches!(self, OverlayState::PendingClose)
156 }
157
158 /// Check if overlay is in closing state (exit animation playing)
159 pub fn is_closing(&self) -> bool {
160 matches!(self, OverlayState::Closing)
161 }
162}
163
164impl StateTransitions for OverlayState {
165 fn on_event(&self, event: u32) -> Option<Self> {
166 use overlay_events::*;
167 use OverlayState::*;
168
169 match (self, event) {
170 // Closed -> Opening: Start show animation
171 (Closed, OPEN) => Some(Opening),
172
173 // Opening -> Open: Animation finished
174 (Opening, ANIMATION_COMPLETE) => Some(Open),
175
176 // Open -> Closing: Start hide animation (immediate close)
177 (Open, CLOSE) | (Open, ESCAPE) | (Open, BACKDROP_CLICK) => Some(Closing),
178
179 // Open -> PendingClose: Mouse left, start close delay countdown
180 (Open, HOVER_LEAVE) => Some(PendingClose),
181
182 // PendingClose -> Open: Mouse re-entered, cancel close delay
183 (PendingClose, HOVER_ENTER) => Some(Open),
184
185 // PendingClose -> Closing: Close delay expired, now actually close
186 (PendingClose, DELAY_EXPIRED) => Some(Closing),
187
188 // PendingClose -> Closing: Immediate close events still work
189 (PendingClose, CLOSE) | (PendingClose, ESCAPE) | (PendingClose, BACKDROP_CLICK) => {
190 Some(Closing)
191 }
192
193 // Closing -> Closed: Animation finished, remove overlay
194 (Closing, ANIMATION_COMPLETE) => Some(Closed),
195
196 // Interrupt opening with close
197 (Opening, CLOSE) | (Opening, ESCAPE) => Some(Closing),
198
199 // Cancel close - interrupt exit animation and return to Open state
200 // Used when mouse re-enters hover card during exit animation
201 (Closing, CANCEL_CLOSE) => Some(Open),
202
203 _ => None,
204 }
205 }
206}
207
208// =============================================================================
209// OverlayPosition
210// =============================================================================
211
212/// How to position an overlay
213#[derive(Clone, Debug, Default)]
214pub enum OverlayPosition {
215 /// Center in viewport (modals, dialogs)
216 #[default]
217 Centered,
218 /// Position at specific coordinates (context menus)
219 AtPoint { x: f32, y: f32 },
220 /// Position in a corner (toasts)
221 Corner(Corner),
222 /// Position relative to an anchor element (dropdowns)
223 RelativeToAnchor {
224 anchor: LayoutNodeId,
225 offset_x: f32,
226 offset_y: f32,
227 },
228 /// Position at an edge of the viewport (sheets, drawers)
229 Edge(EdgeSide),
230}
231
232/// Edge sides for sheet/drawer overlays
233#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
234pub enum EdgeSide {
235 /// Left edge of viewport
236 #[default]
237 Left,
238 /// Right edge of viewport
239 Right,
240 /// Top edge of viewport
241 Top,
242 /// Bottom edge of viewport
243 Bottom,
244}
245
246/// Corner positions for toast notifications
247#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
248pub enum Corner {
249 TopLeft,
250 #[default]
251 TopRight,
252 BottomLeft,
253 BottomRight,
254}
255
256// =============================================================================
257// BackdropConfig
258// =============================================================================
259
260/// Configuration for overlay backdrop
261#[derive(Clone, Debug)]
262pub struct BackdropConfig {
263 /// Backdrop color (usually semi-transparent black)
264 pub color: Color,
265 /// Whether clicking backdrop closes the overlay
266 pub dismiss_on_click: bool,
267 /// Blur amount for frosted glass effect (0.0 = no blur)
268 pub blur: f32,
269}
270
271impl Default for BackdropConfig {
272 fn default() -> Self {
273 Self {
274 color: Color::rgba(0.0, 0.0, 0.0, 0.5),
275 dismiss_on_click: true,
276 blur: 0.0,
277 }
278 }
279}
280
281impl BackdropConfig {
282 /// Create a dark semi-transparent backdrop
283 pub fn dark() -> Self {
284 Self::default()
285 }
286
287 /// Create a light semi-transparent backdrop
288 pub fn light() -> Self {
289 Self {
290 color: Color::rgba(1.0, 1.0, 1.0, 0.3),
291 ..Self::default()
292 }
293 }
294
295 /// Create a backdrop that doesn't dismiss on click
296 pub fn persistent() -> Self {
297 Self {
298 dismiss_on_click: false,
299 ..Self::default()
300 }
301 }
302
303 /// Set the backdrop color
304 pub fn color(mut self, color: Color) -> Self {
305 self.color = color;
306 self
307 }
308
309 /// Set whether clicking dismisses the overlay
310 pub fn dismiss_on_click(mut self, dismiss: bool) -> Self {
311 self.dismiss_on_click = dismiss;
312 self
313 }
314
315 /// Set blur amount for frosted glass effect
316 pub fn blur(mut self, blur: f32) -> Self {
317 self.blur = blur;
318 self
319 }
320}
321
322// =============================================================================
323// OverlayAnimation
324// =============================================================================
325
326/// Animation configuration for overlay enter/exit
327#[derive(Clone)]
328pub struct OverlayAnimation {
329 /// Enter animation
330 pub enter: MultiKeyframeAnimation,
331 /// Exit animation
332 pub exit: MultiKeyframeAnimation,
333}
334
335impl Default for OverlayAnimation {
336 fn default() -> Self {
337 Self::modal()
338 }
339}
340
341impl std::fmt::Debug for OverlayAnimation {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 f.debug_struct("OverlayAnimation")
344 .field("enter", &"MultiKeyframeAnimation")
345 .field("exit", &"MultiKeyframeAnimation")
346 .finish()
347 }
348}
349
350impl OverlayAnimation {
351 /// Default modal animation (scale + fade)
352 ///
353 /// Note: Exit duration must be >= motion exit animation duration (150ms default for dialogs)
354 /// to ensure motion animations complete before overlay is removed.
355 pub fn modal() -> Self {
356 Self {
357 enter: AnimationPreset::scale_in(200),
358 exit: AnimationPreset::fade_out(170), // Slightly longer than motion exit (150ms)
359 }
360 }
361
362 /// Context menu animation (pop in)
363 ///
364 /// Note: Exit duration must be >= motion exit animation duration (100ms default)
365 /// to ensure motion animations complete before overlay is removed.
366 pub fn context_menu() -> Self {
367 Self {
368 enter: AnimationPreset::pop_in(150),
369 exit: AnimationPreset::fade_out(120), // Slightly longer than motion exit (100ms)
370 }
371 }
372
373 /// Toast animation (slide from right)
374 pub fn toast() -> Self {
375 Self {
376 enter: AnimationPreset::slide_in_right(200, 100.0),
377 exit: AnimationPreset::slide_out_right(150, 100.0),
378 }
379 }
380
381 /// Dropdown animation (fade in quickly)
382 ///
383 /// Note: Exit duration must be >= motion exit animation duration (100ms default)
384 /// to ensure motion animations complete before overlay is removed.
385 pub fn dropdown() -> Self {
386 Self {
387 enter: AnimationPreset::fade_in(100),
388 exit: AnimationPreset::fade_out(120), // Slightly longer than motion exit (100ms)
389 }
390 }
391
392 /// No animation (instant show/hide)
393 pub fn none() -> Self {
394 Self {
395 enter: AnimationPreset::fade_in(0),
396 exit: AnimationPreset::fade_out(0),
397 }
398 }
399
400 /// Custom animation
401 pub fn custom(enter: MultiKeyframeAnimation, exit: MultiKeyframeAnimation) -> Self {
402 Self { enter, exit }
403 }
404}
405
406// =============================================================================
407// OverlayConfig
408// =============================================================================
409
410/// Configuration for an overlay instance
411#[derive(Clone, Debug)]
412pub struct OverlayConfig {
413 /// Type of overlay (affects default behavior)
414 pub kind: OverlayKind,
415 /// How to position the overlay
416 pub position: OverlayPosition,
417 /// Backdrop configuration (None = no backdrop)
418 pub backdrop: Option<BackdropConfig>,
419 /// Animation configuration
420 pub animation: OverlayAnimation,
421 /// Close on Escape key press
422 pub dismiss_on_escape: bool,
423 /// Close when clicking outside the overlay content (without needing a backdrop)
424 ///
425 /// This enables click-outside detection without adding a backdrop element,
426 /// allowing scroll events to pass through to content behind the overlay.
427 /// Useful for popovers and dropdowns that should dismiss on outside click
428 /// but not block scrolling.
429 pub dismiss_on_click_outside: bool,
430 /// Close when scroll events occur
431 ///
432 /// When enabled, any scroll event will dismiss the overlay. This is useful
433 /// for popovers that are positioned relative to a trigger element - when
434 /// the trigger scrolls, the popover should close rather than staying in
435 /// place disconnected from its trigger.
436 pub dismiss_on_scroll: bool,
437 /// Move with scroll events instead of staying fixed
438 ///
439 /// When enabled, the overlay position will be updated by the scroll delta,
440 /// keeping it attached to its trigger element as the page scrolls.
441 /// This is useful for popovers that should track their trigger position.
442 pub follows_scroll: bool,
443 /// Close when mouse leaves the overlay content (for hover cards)
444 pub dismiss_on_hover_leave: bool,
445 /// Auto-dismiss after duration (for toasts)
446 pub auto_dismiss_ms: Option<u32>,
447 /// Delay before closing after mouse leaves (for hover cards)
448 /// When set, mouse leave triggers PendingClose state with this delay
449 /// before actually closing. Mouse re-entering cancels the delay.
450 pub close_delay_ms: Option<u32>,
451 /// Trap focus within overlay (for modals)
452 pub focus_trap: bool,
453 /// Z-priority (higher = more on top)
454 pub z_priority: i32,
455 /// Explicit size (None = content-sized)
456 pub size: Option<(f32, f32)>,
457 /// Motion key for content animation
458 ///
459 /// When set, the overlay will trigger exit animation on this motion key
460 /// when transitioning to Closing state. The full key will be `"motion:{motion_key}"`.
461 /// Use this with `motion_derived(motion_key)` in your content builder.
462 pub motion_key: Option<String>,
463 /// Direction the overlay is anchored relative to its trigger.
464 ///
465 /// Used to calculate correct bounds for occlusion testing. For example,
466 /// a hover card with `Top` direction has its y coordinate at the BOTTOM edge,
467 /// not the top edge.
468 pub anchor_direction: AnchorDirection,
469}
470
471impl Default for OverlayConfig {
472 fn default() -> Self {
473 Self::modal()
474 }
475}
476
477impl OverlayConfig {
478 /// Create modal configuration
479 pub fn modal() -> Self {
480 Self {
481 kind: OverlayKind::Modal,
482 position: OverlayPosition::Centered,
483 backdrop: Some(BackdropConfig::default()),
484 animation: OverlayAnimation::modal(),
485 dismiss_on_escape: true,
486 dismiss_on_click_outside: false, // Modal uses backdrop for dismiss
487 dismiss_on_scroll: false, // Modals don't dismiss on scroll
488 follows_scroll: false, // Modals don't follow scroll
489 dismiss_on_hover_leave: false,
490 auto_dismiss_ms: None,
491 close_delay_ms: None,
492 focus_trap: true,
493 z_priority: 100,
494 size: None,
495 motion_key: None,
496 anchor_direction: AnchorDirection::Bottom,
497 }
498 }
499
500 /// Create dialog configuration
501 pub fn dialog() -> Self {
502 Self {
503 kind: OverlayKind::Dialog,
504 ..Self::modal()
505 }
506 }
507
508 /// Create context menu configuration
509 pub fn context_menu() -> Self {
510 Self {
511 kind: OverlayKind::ContextMenu,
512 position: OverlayPosition::AtPoint { x: 0.0, y: 0.0 },
513 backdrop: None,
514 animation: OverlayAnimation::context_menu(),
515 dismiss_on_escape: true,
516 dismiss_on_click_outside: true, // Context menus dismiss on click outside
517 dismiss_on_scroll: true, // Context menus dismiss on scroll
518 follows_scroll: false, // Context menus dismiss rather than follow
519 dismiss_on_hover_leave: false,
520 auto_dismiss_ms: None,
521 close_delay_ms: None,
522 focus_trap: false,
523 z_priority: 200,
524 size: None,
525 motion_key: None,
526 anchor_direction: AnchorDirection::Bottom,
527 }
528 }
529
530 /// Create toast configuration
531 pub fn toast() -> Self {
532 Self {
533 kind: OverlayKind::Toast,
534 position: OverlayPosition::Corner(Corner::TopRight),
535 backdrop: None,
536 animation: OverlayAnimation::toast(),
537 dismiss_on_escape: false,
538 dismiss_on_click_outside: false, // Toasts don't dismiss on click outside
539 dismiss_on_scroll: false, // Toasts don't dismiss on scroll
540 follows_scroll: false, // Toasts stay in fixed corner position
541 dismiss_on_hover_leave: false,
542 auto_dismiss_ms: Some(3000),
543 close_delay_ms: None,
544 focus_trap: false,
545 z_priority: 300,
546 size: None,
547 motion_key: None,
548 anchor_direction: AnchorDirection::Bottom,
549 }
550 }
551
552 /// Create dropdown configuration
553 pub fn dropdown() -> Self {
554 Self {
555 kind: OverlayKind::Dropdown,
556 position: OverlayPosition::Centered, // Will be overridden by anchor or at()
557 // Transparent backdrop that dismisses on click outside
558 backdrop: Some(BackdropConfig {
559 color: blinc_core::Color::TRANSPARENT,
560 dismiss_on_click: true,
561 blur: 0.0,
562 }),
563 animation: OverlayAnimation::dropdown(),
564 dismiss_on_escape: true,
565 dismiss_on_click_outside: false, // Dropdown uses backdrop for dismiss
566 dismiss_on_scroll: false, // Dropdown uses backdrop which blocks scroll
567 follows_scroll: false, // Dropdown uses backdrop which blocks scroll
568 dismiss_on_hover_leave: false,
569 auto_dismiss_ms: None,
570 close_delay_ms: None,
571 focus_trap: false,
572 z_priority: 150,
573 size: None,
574 motion_key: None,
575 anchor_direction: AnchorDirection::Bottom,
576 }
577 }
578
579 /// Create hover card configuration (dropdown that dismisses on mouse leave)
580 ///
581 /// Hover cards are TRANSIENT overlays - they have NO backdrop and don't block
582 /// interaction with the UI below. Multiple hover cards can coexist.
583 /// Uses close_delay_ms to allow mouse movement between trigger and content.
584 pub fn hover_card() -> Self {
585 Self {
586 kind: OverlayKind::Tooltip, // Use Tooltip kind for transient behavior
587 position: OverlayPosition::Centered, // Will be overridden by at()
588 // NO backdrop - transient overlays don't block interaction
589 backdrop: None,
590 animation: OverlayAnimation::dropdown(),
591 dismiss_on_escape: true,
592 dismiss_on_click_outside: false, // Hover cards dismiss on hover leave instead
593 dismiss_on_scroll: false, // Default off, but can be enabled for popovers
594 follows_scroll: false, // Default off, but can be enabled for popovers
595 dismiss_on_hover_leave: true,
596 auto_dismiss_ms: Some(5000), // Auto-dismiss after 5 seconds as fallback
597 close_delay_ms: Some(300), // 300ms delay before closing on mouse leave
598 focus_trap: false,
599 z_priority: 150,
600 size: None,
601 motion_key: None,
602 anchor_direction: AnchorDirection::Bottom, // Will be overridden by anchor_direction()
603 }
604 }
605}
606
607// =============================================================================
608// OverlayHandle
609// =============================================================================
610
611/// Handle to a specific overlay instance for management
612#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
613pub struct OverlayHandle(u64);
614
615impl OverlayHandle {
616 /// Create a new handle with a unique ID
617 fn new(id: u64) -> Self {
618 Self(id)
619 }
620
621 /// Reconstruct a handle from a raw ID
622 ///
623 /// This is useful for storing handles in state and reconstructing them later.
624 pub fn from_raw(id: u64) -> Self {
625 Self(id)
626 }
627
628 /// Get the raw ID
629 pub fn id(&self) -> u64 {
630 self.0
631 }
632}
633
634// =============================================================================
635// ActiveOverlay
636// =============================================================================
637
638/// Callback invoked when an overlay is closed
639pub type OnCloseCallback = Arc<dyn Fn() + Send + Sync>;
640
641/// An active overlay instance
642pub struct ActiveOverlay {
643 /// Handle for this overlay
644 pub handle: OverlayHandle,
645 /// Configuration
646 pub config: OverlayConfig,
647 /// Current state
648 pub state: OverlayState,
649 /// Content builder function
650 content_builder: Box<dyn Fn() -> Div + Send + Sync>,
651 /// Time when overlay was created (for enter animation timing)
652 created_at_ms: Option<u64>,
653 /// Time when overlay was opened (for auto-dismiss)
654 opened_at_ms: Option<u64>,
655 /// Time when close animation started (for exit animation timing)
656 close_started_at_ms: Option<u64>,
657 /// Time when pending close started (for close delay countdown)
658 pending_close_at_ms: Option<u64>,
659 /// Cached content size after layout (for positioning)
660 pub cached_size: Option<(f32, f32)>,
661 /// Callback invoked when the overlay is closed (backdrop click, escape, etc.)
662 on_close: Option<OnCloseCallback>,
663 /// Flag: start close delay when Opening -> Open transition completes
664 /// Used when hover_leave is called during Opening state
665 pending_close_on_open: bool,
666 /// Accumulated scroll offset for follows_scroll overlays
667 /// Applied as a visual transform during rendering without layout rebuild
668 scroll_offset_y: f32,
669}
670
671impl ActiveOverlay {
672 /// Check if overlay should be visible
673 pub fn is_visible(&self) -> bool {
674 self.state.is_visible()
675 }
676
677 /// Build the overlay content
678 pub fn build_content(&self) -> Div {
679 (self.content_builder)()
680 }
681
682 /// Transition to a new state
683 ///
684 /// When transitioning to Closing state, this will automatically trigger
685 /// the exit animation on any motion container with the overlay's motion key
686 /// (if configured via `OverlayConfig.motion_key`).
687 pub fn transition(&mut self, event: u32) -> bool {
688 if let Some(new_state) = self.state.on_event(event) {
689 let old_state = self.state;
690 self.state = new_state;
691
692 // When transitioning TO Closing state, trigger motion exit if motion_key is configured
693 if new_state == OverlayState::Closing && old_state != OverlayState::Closing {
694 if let Some(ref key) = self.config.motion_key {
695 let full_motion_key = format!("motion:{}", key);
696 crate::selector::query_motion(&full_motion_key).exit();
697 tracing::debug!(
698 "Overlay {:?} transitioning to Closing - triggered motion exit for key={}",
699 self.handle,
700 full_motion_key
701 );
702 }
703
704 // Also trigger exit for backdrop motion if backdrop is configured
705 if self.config.backdrop.is_some() {
706 let backdrop_motion_key = format!("motion:overlay_backdrop_{}", self.handle.0);
707 crate::selector::query_motion(&backdrop_motion_key).exit();
708 tracing::debug!(
709 "Overlay {:?} transitioning to Closing - triggered backdrop motion exit",
710 self.handle
711 );
712 }
713 }
714
715 true
716 } else {
717 false
718 }
719 }
720
721 /// Get the current animation progress (0.0 to 1.0)
722 ///
723 /// Returns (progress, is_entering) where:
724 /// - progress: 0.0 = start of animation, 1.0 = end
725 /// - is_entering: true for enter animation, false for exit
726 ///
727 /// Returns None if not animating (fully visible or closed)
728 pub fn animation_progress(&self, current_time_ms: u64) -> Option<(f32, bool)> {
729 match self.state {
730 OverlayState::Opening => {
731 let duration = self.config.animation.enter.duration_ms() as f32;
732 if duration <= 0.0 {
733 return None;
734 }
735 let created_at = self.created_at_ms.unwrap_or(current_time_ms);
736 let elapsed = (current_time_ms.saturating_sub(created_at)) as f32;
737 let progress = (elapsed / duration).clamp(0.0, 1.0);
738 Some((progress, true))
739 }
740 OverlayState::Closing => {
741 let duration = self.config.animation.exit.duration_ms() as f32;
742 if duration <= 0.0 {
743 return None;
744 }
745 let close_started = self.close_started_at_ms.unwrap_or(current_time_ms);
746 let elapsed = (current_time_ms.saturating_sub(close_started)) as f32;
747 let progress = (elapsed / duration).clamp(0.0, 1.0);
748 Some((progress, false))
749 }
750 _ => None,
751 }
752 }
753}
754
755// =============================================================================
756// OverlayManagerInner
757// =============================================================================
758
759/// Inner state of the overlay manager
760pub struct OverlayManagerInner {
761 /// Active overlays indexed by handle
762 overlays: IndexMap<OverlayHandle, ActiveOverlay>,
763 /// Next overlay ID
764 next_id: AtomicU64,
765 /// Dirty flag - set when overlays change structurally (added/removed)
766 /// This triggers a full content rebuild
767 dirty: AtomicBool,
768 /// Animation dirty flag - set when animation state changes but content is same
769 /// This triggers a re-render but NOT a content rebuild
770 animation_dirty: AtomicBool,
771 /// Viewport dimensions for positioning (logical pixels)
772 viewport: (f32, f32),
773 /// DPI scale factor
774 scale_factor: f32,
775 /// Toast corner preference
776 toast_corner: Corner,
777 /// Maximum visible toasts
778 max_toasts: usize,
779 /// Gap between stacked toasts
780 toast_gap: f32,
781 /// Current time in milliseconds (set by update())
782 current_time_ms: u64,
783}
784
785impl OverlayManagerInner {
786 /// Create a new overlay manager
787 pub fn new() -> Self {
788 Self {
789 overlays: IndexMap::new(),
790 next_id: AtomicU64::new(1),
791 dirty: AtomicBool::new(false),
792 animation_dirty: AtomicBool::new(false),
793 viewport: (0.0, 0.0),
794 scale_factor: 1.0,
795 toast_corner: Corner::TopRight,
796 max_toasts: 5,
797 toast_gap: 8.0,
798 current_time_ms: 0,
799 }
800 }
801
802 /// Update viewport dimensions (in logical pixels)
803 pub fn set_viewport(&mut self, width: f32, height: f32) {
804 self.viewport = (width, height);
805 }
806
807 /// Update viewport dimensions with scale factor
808 pub fn set_viewport_with_scale(&mut self, width: f32, height: f32, scale_factor: f32) {
809 self.viewport = (width, height);
810 self.scale_factor = scale_factor;
811 }
812
813 /// Get the current scale factor
814 pub fn scale_factor(&self) -> f32 {
815 self.scale_factor
816 }
817
818 /// Set toast corner preference
819 pub fn set_toast_corner(&mut self, corner: Corner) {
820 self.toast_corner = corner;
821 }
822
823 /// Check and clear dirty flag (content changed, needs full rebuild)
824 pub fn take_dirty(&self) -> bool {
825 self.dirty.swap(false, Ordering::SeqCst)
826 }
827
828 /// Check dirty flag without clearing (for peeking before render)
829 pub fn is_dirty(&self) -> bool {
830 self.dirty.load(Ordering::SeqCst)
831 }
832
833 /// Check and clear animation dirty flag (just needs re-render, no content rebuild)
834 pub fn take_animation_dirty(&self) -> bool {
835 self.animation_dirty.swap(false, Ordering::SeqCst)
836 }
837
838 /// Check if needs any kind of redraw (content or animation)
839 pub fn needs_redraw(&self) -> bool {
840 self.dirty.load(Ordering::SeqCst) || self.animation_dirty.load(Ordering::SeqCst)
841 }
842
843 /// Mark as dirty (content changed)
844 fn mark_dirty(&self) {
845 self.dirty.store(true, Ordering::SeqCst);
846 }
847
848 /// Mark animation dirty (animation state changed but content is same)
849 fn mark_animation_dirty(&self) {
850 self.animation_dirty.store(true, Ordering::SeqCst);
851 }
852
853 /// Add a new overlay
854 pub fn add(
855 &mut self,
856 config: OverlayConfig,
857 content: impl Fn() -> Div + Send + Sync + 'static,
858 ) -> OverlayHandle {
859 self.add_with_close_callback(config, content, None)
860 }
861
862 /// Add a new overlay with a close callback
863 ///
864 /// The callback is invoked when the overlay is dismissed (backdrop click, escape, etc.)
865 pub fn add_with_close_callback(
866 &mut self,
867 config: OverlayConfig,
868 content: impl Fn() -> Div + Send + Sync + 'static,
869 on_close: Option<OnCloseCallback>,
870 ) -> OverlayHandle {
871 let id = self.next_id.fetch_add(1, Ordering::SeqCst);
872 let handle = OverlayHandle::new(id);
873
874 tracing::debug!(
875 "OverlayManager::add - adding {:?} overlay with handle {:?}",
876 config.kind,
877 handle
878 );
879
880 let overlay = ActiveOverlay {
881 handle,
882 config,
883 state: OverlayState::Opening,
884 content_builder: Box::new(content),
885 created_at_ms: None, // Will be set on first update
886 opened_at_ms: None,
887 close_started_at_ms: None,
888 pending_close_at_ms: None,
889 cached_size: None,
890 on_close,
891 pending_close_on_open: false,
892 scroll_offset_y: 0.0,
893 };
894
895 self.overlays.insert(handle, overlay);
896 self.mark_dirty();
897
898 tracing::debug!(
899 "OverlayManager::add - now have {} overlays",
900 self.overlays.len()
901 );
902
903 handle
904 }
905
906 /// Update overlay states - call this every frame
907 ///
908 /// This handles:
909 /// - Transitioning Opening -> Open after enter animation completes
910 /// - Transitioning Closing -> Closed after exit animation completes
911 /// - Auto-dismissing toasts after their duration expires
912 /// - Removing closed overlays
913 pub fn update(&mut self, current_time_ms: u64) {
914 // Store current time for use in build_overlay_layer
915 self.current_time_ms = current_time_ms;
916
917 let mut to_close = Vec::new();
918 let mut content_dirty = false;
919 let mut animation_dirty = false;
920
921 for (handle, overlay) in self.overlays.iter_mut() {
922 // Initialize created_at_ms on first update
923 if overlay.created_at_ms.is_none() {
924 overlay.created_at_ms = Some(current_time_ms);
925 content_dirty = true;
926 }
927
928 match overlay.state {
929 OverlayState::Opening => {
930 // Check if enter animation has completed
931 let enter_duration = overlay.config.animation.enter.duration_ms();
932 if let Some(created_at) = overlay.created_at_ms {
933 let elapsed = current_time_ms.saturating_sub(created_at);
934 if elapsed >= enter_duration as u64 {
935 // Animation complete, transition to Open
936 if overlay.transition(overlay_events::ANIMATION_COMPLETE) {
937 overlay.opened_at_ms = Some(current_time_ms);
938 // State transition doesn't change content, just animation
939 animation_dirty = true;
940
941 // Check if we queued a close during Opening (TOP hover card case)
942 // If mouse left before opening completed, close immediately
943 // without showing the card or playing exit animation
944 if overlay.pending_close_on_open {
945 overlay.pending_close_on_open = false;
946 tracing::debug!(
947 "Overlay {:?} just opened but pending_close_on_open - removing immediately",
948 handle
949 );
950 // Go directly to Closed (skip both Open state and exit animation)
951 // This prevents the "blink" when mouse leaves trigger before card opens
952 overlay.state = OverlayState::Closed;
953 content_dirty = true;
954 }
955 }
956 } else {
957 // Animation still in progress, keep redrawing (no content change)
958 animation_dirty = true;
959 }
960 }
961 }
962 OverlayState::Open => {
963 // Check for auto-dismiss (toasts)
964 if let Some(duration_ms) = overlay.config.auto_dismiss_ms {
965 if let Some(opened_at) = overlay.opened_at_ms {
966 if current_time_ms >= opened_at + duration_ms as u64 {
967 to_close.push(*handle);
968 }
969 }
970 }
971 }
972 OverlayState::PendingClose => {
973 // Check if close delay has expired
974 if let Some(close_delay_ms) = overlay.config.close_delay_ms {
975 if let Some(pending_close_at) = overlay.pending_close_at_ms {
976 let elapsed = current_time_ms.saturating_sub(pending_close_at);
977 if elapsed >= close_delay_ms as u64 {
978 // Delay expired, now actually start closing
979 tracing::debug!(
980 "Overlay {:?} close delay expired after {}ms, transitioning to Closing",
981 handle,
982 elapsed
983 );
984 if overlay.transition(overlay_events::DELAY_EXPIRED) {
985 animation_dirty = true;
986 }
987 }
988 // While waiting, no dirty flag needed - just keep checking
989 }
990 } else {
991 // No close delay configured, immediately close
992 if overlay.transition(overlay_events::DELAY_EXPIRED) {
993 animation_dirty = true;
994 }
995 }
996 }
997 OverlayState::Closing => {
998 // Initialize close_started_at_ms if not set
999 if overlay.close_started_at_ms.is_none() {
1000 overlay.close_started_at_ms = Some(current_time_ms);
1001 tracing::debug!(
1002 "Overlay {:?} started closing at {}ms, exit duration={}ms",
1003 handle,
1004 current_time_ms,
1005 overlay.config.animation.exit.duration_ms()
1006 );
1007 // Starting close animation - animation change, not content
1008 animation_dirty = true;
1009 }
1010
1011 // Check if exit animation has completed
1012 // Must wait for BOTH:
1013 // 1. Overlay's own exit duration
1014 // 2. Motion animation (if motion_key configured) to complete
1015 let exit_duration = overlay.config.animation.exit.duration_ms();
1016 let overlay_exit_complete =
1017 if let Some(close_started) = overlay.close_started_at_ms {
1018 let elapsed = current_time_ms.saturating_sub(close_started);
1019 elapsed >= exit_duration as u64
1020 } else {
1021 false
1022 };
1023
1024 // Check if motion animation has completed (if configured)
1025 let motion_exit_complete = if let Some(ref key) = overlay.config.motion_key {
1026 let full_motion_key = format!("motion:{}", key);
1027 let motion = crate::selector::query_motion(&full_motion_key);
1028 // Motion is complete if it's not animating (either Visible, Removed, or doesn't exist)
1029 !motion.is_animating()
1030 } else {
1031 true // No motion configured, consider it complete
1032 };
1033
1034 if overlay_exit_complete && motion_exit_complete {
1035 // Both animations complete, transition to Closed
1036 tracing::debug!(
1037 "Overlay {:?} exit complete (overlay_exit={}, motion_exit={})",
1038 handle,
1039 overlay_exit_complete,
1040 motion_exit_complete
1041 );
1042 if overlay.transition(overlay_events::ANIMATION_COMPLETE) {
1043 // Overlay will be removed - this is a content change
1044 content_dirty = true;
1045 }
1046 } else {
1047 // Animation still in progress, keep redrawing (no content change)
1048 animation_dirty = true;
1049 }
1050 }
1051 OverlayState::Closed => {
1052 // Will be removed below
1053 }
1054 }
1055 }
1056
1057 // Close expired toasts
1058 for handle in to_close {
1059 if let Some(overlay) = self.overlays.get_mut(&handle) {
1060 overlay.transition(overlay_events::CLOSE);
1061 // Starting close - animation change (content rebuild when actually removed)
1062 animation_dirty = true;
1063 }
1064 }
1065
1066 // Remove closed overlays and collect their on_close callbacks
1067 // We defer calling on_close until AFTER the overlay is fully removed
1068 // to prevent state updates from triggering UI rebuilds during exit animation
1069 let count_before = self.overlays.len();
1070 let mut callbacks_to_invoke = Vec::new();
1071 self.overlays.retain(|_, o| {
1072 if o.state == OverlayState::Closed {
1073 // Collect callback before removing
1074 if let Some(cb) = o.on_close.take() {
1075 callbacks_to_invoke.push(cb);
1076 }
1077 false // Remove this overlay
1078 } else {
1079 true // Keep this overlay
1080 }
1081 });
1082 if self.overlays.len() != count_before {
1083 // Overlays were removed - content change
1084 content_dirty = true;
1085 }
1086
1087 // Invoke on_close callbacks AFTER overlay removal is complete
1088 // This ensures exit animations play fully before triggering state updates
1089 for cb in callbacks_to_invoke {
1090 cb();
1091 }
1092
1093 if content_dirty {
1094 self.mark_dirty();
1095 } else if animation_dirty {
1096 self.mark_animation_dirty();
1097 }
1098 }
1099
1100 /// Close an overlay by handle
1101 pub fn close(&mut self, handle: OverlayHandle) {
1102 if let Some(overlay) = self.overlays.get_mut(&handle) {
1103 if overlay.transition(overlay_events::CLOSE) {
1104 // Starting close animation - animation dirty, not content
1105 self.mark_animation_dirty();
1106 }
1107 }
1108 }
1109
1110 /// Close an overlay immediately, skipping any exit animation
1111 ///
1112 /// This directly sets the overlay to Closed state so it will be
1113 /// removed on the next update cycle. Use this when you need to
1114 /// ensure an overlay is gone before opening a replacement.
1115 pub fn close_immediate(&mut self, handle: OverlayHandle) {
1116 if let Some(overlay) = self.overlays.get_mut(&handle) {
1117 // Skip animation and go directly to Closed
1118 overlay.state = OverlayState::Closed;
1119 // Call on_close callback if present
1120 if let Some(cb) = overlay.on_close.take() {
1121 cb();
1122 }
1123 // Mark animation dirty to trigger redraw without full UI rebuild
1124 self.mark_animation_dirty();
1125 }
1126 }
1127
1128 /// Cancel a pending close and return overlay to Open state
1129 ///
1130 /// Used when mouse re-enters a hover card during exit animation.
1131 /// This interrupts the exit animation and keeps the overlay visible.
1132 pub fn cancel_close(&mut self, handle: OverlayHandle) {
1133 if let Some(overlay) = self.overlays.get_mut(&handle) {
1134 if overlay.transition(overlay_events::CANCEL_CLOSE) {
1135 // Canceled close - need to reset motion animation state
1136 self.mark_animation_dirty();
1137 }
1138 }
1139 }
1140
1141 /// Trigger hover leave event - starts close delay countdown
1142 ///
1143 /// For overlays with `close_delay_ms` configured (like hover cards),
1144 /// this transitions to PendingClose state. The overlay will close
1145 /// after the delay unless `hover_enter` is called.
1146 pub fn hover_leave(&mut self, handle: OverlayHandle) {
1147 if let Some(overlay) = self.overlays.get_mut(&handle) {
1148 let old_state = overlay.state;
1149
1150 // If overlay is still Opening, queue the close for when it opens
1151 // This handles the TOP hover card case where mouse leaves trigger
1152 // before the card finishes opening (mouse moving away from card)
1153 // if old_state == OverlayState::Opening {
1154 // tracing::debug!("hover_leave: overlay is Opening, queuing close for when open");
1155 // overlay.pending_close_on_open = true;
1156 // return;
1157 // }
1158
1159 if overlay.transition(overlay_events::HOVER_LEAVE) {
1160 let new_state = overlay.state;
1161 tracing::debug!(
1162 "Overlay {:?} hover leave: {:?} -> {:?} at {}ms",
1163 handle,
1164 old_state,
1165 new_state,
1166 self.current_time_ms
1167 );
1168 // If transitioned to PendingClose, record when it started
1169 if new_state == OverlayState::PendingClose {
1170 overlay.pending_close_at_ms = Some(self.current_time_ms);
1171 }
1172 // No dirty flag needed - update loop will handle the delay
1173 }
1174 }
1175 }
1176
1177 /// Trigger hover enter event - cancels close delay countdown
1178 ///
1179 /// If overlay is in PendingClose state, this cancels the delay
1180 /// and returns to Open state.
1181 pub fn hover_enter(&mut self, handle: OverlayHandle) {
1182 if let Some(overlay) = self.overlays.get_mut(&handle) {
1183 // Cancel any queued close-on-open (mouse entered card during Opening)
1184 if overlay.pending_close_on_open {
1185 tracing::debug!("hover_enter: canceling pending_close_on_open");
1186 overlay.pending_close_on_open = false;
1187 }
1188
1189 if overlay.transition(overlay_events::HOVER_ENTER) {
1190 // Clear pending close timestamp
1191 overlay.pending_close_at_ms = None;
1192 tracing::debug!(
1193 "Overlay {:?} hover enter -> Open (canceled pending close)",
1194 handle
1195 );
1196 // No dirty flag needed - just staying open
1197 }
1198 }
1199 }
1200
1201 /// Check if an overlay is in PendingClose state or has queued close
1202 ///
1203 /// Returns true if:
1204 /// - Overlay is in PendingClose state (waiting for close delay), OR
1205 /// - Overlay is in Opening state with pending_close_on_open flag set
1206 /// (close will happen as soon as opening animation completes)
1207 pub fn is_pending_close(&self, handle: OverlayHandle) -> bool {
1208 self.overlays
1209 .get(&handle)
1210 .map(|o| o.state.is_pending_close() || o.pending_close_on_open)
1211 .unwrap_or(false)
1212 }
1213
1214 /// Check if overlay is in closing state (exit animation playing)
1215 pub fn is_closing(&self, handle: OverlayHandle) -> bool {
1216 self.overlays
1217 .get(&handle)
1218 .map(|o| o.state.is_closing())
1219 .unwrap_or(false)
1220 }
1221
1222 /// Set the cached content size for an overlay (for hit testing)
1223 ///
1224 /// This is typically called from an `on_ready` callback after the overlay
1225 /// content has been laid out, providing accurate size for backdrop click detection.
1226 pub fn set_content_size(&mut self, handle: OverlayHandle, width: f32, height: f32) {
1227 if let Some(overlay) = self.overlays.get_mut(&handle) {
1228 overlay.cached_size = Some((width, height));
1229 }
1230 }
1231
1232 /// Update positions of overlays that follow scroll
1233 ///
1234 /// This is called from the scroll handler to update positions of overlays
1235 /// with `follows_scroll: true`. The delta is subtracted from the y position
1236 /// since scrolling down means content moves up.
1237 ///
1238 /// Returns true if any overlay positions were updated.
1239 pub fn handle_scroll(&mut self, delta_y: f32) -> bool {
1240 let mut updated = false;
1241
1242 for overlay in self.overlays.values_mut() {
1243 if overlay.config.follows_scroll && overlay.state.is_visible() {
1244 // Accumulate scroll offset - scrolling down (positive delta) moves content up
1245 overlay.scroll_offset_y += delta_y;
1246 updated = true;
1247 }
1248 }
1249
1250 if updated {
1251 self.mark_animation_dirty();
1252 }
1253
1254 updated
1255 }
1256
1257 /// Get scroll offsets for all visible overlays with follows_scroll enabled
1258 ///
1259 /// Returns a list of (element_id, offset_y) pairs for rendering.
1260 /// The element_id is the unique wrapper ID for each overlay's content.
1261 pub fn get_scroll_offsets(&self) -> Vec<(String, f32)> {
1262 self.overlays
1263 .iter()
1264 .filter(|(_, o)| o.config.follows_scroll && o.state.is_visible())
1265 .map(|(handle, overlay)| {
1266 let element_id = format!("overlay_scroll_{}", handle.id());
1267 (element_id, overlay.scroll_offset_y)
1268 })
1269 .collect()
1270 }
1271
1272 /// Get bounds of all visible overlays for occlusion testing
1273 ///
1274 /// Returns a list of (x, y, width, height) rectangles for all visible overlay content.
1275 /// This can be used to determine if a hit test point is within an overlay's bounds,
1276 /// which helps block hover events on UI elements underneath overlays.
1277 ///
1278 /// Note: Uses default size (300x200) for overlays without cached size.
1279 pub fn get_visible_overlay_bounds(&self) -> Vec<(f32, f32, f32, f32)> {
1280 let (vp_width, vp_height) = self.viewport;
1281
1282 self.overlays
1283 .values()
1284 .filter(|o| o.is_visible())
1285 .filter_map(|overlay| {
1286 // Use cached size if available, otherwise use a reasonable default
1287 let (w, h) = overlay.cached_size.unwrap_or((300.0, 200.0));
1288
1289 // Calculate position based on OverlayPosition
1290 let (mut x, mut y) = match &overlay.config.position {
1291 OverlayPosition::AtPoint { x, y } => (*x, *y),
1292 OverlayPosition::Centered => {
1293 // Centered position - use viewport center minus half size
1294 ((vp_width - w) / 2.0, (vp_height - h) / 2.0)
1295 }
1296 OverlayPosition::Corner(corner) => {
1297 let margin = 16.0;
1298 match corner {
1299 Corner::TopLeft => (margin, margin),
1300 Corner::TopRight => (vp_width - w - margin, margin),
1301 Corner::BottomLeft => (margin, vp_height - h - margin),
1302 Corner::BottomRight => {
1303 (vp_width - w - margin, vp_height - h - margin)
1304 }
1305 }
1306 }
1307 OverlayPosition::RelativeToAnchor { .. } => {
1308 // We don't have anchor bounds here - return None
1309 return None;
1310 }
1311 OverlayPosition::Edge(side) => {
1312 // Edge positioning for sheets/drawers
1313 match side {
1314 EdgeSide::Left => (0.0, 0.0),
1315 EdgeSide::Right => (vp_width - w, 0.0),
1316 EdgeSide::Top => (0.0, 0.0),
1317 EdgeSide::Bottom => (0.0, vp_height - h),
1318 }
1319 }
1320 };
1321
1322 // Adjust position based on anchor direction
1323 // For AtPoint positions, the (x, y) may be a different edge depending on direction
1324 if matches!(overlay.config.position, OverlayPosition::AtPoint { .. }) {
1325 match overlay.config.anchor_direction {
1326 AnchorDirection::Top => {
1327 // y is the bottom edge of the overlay, so top edge is y - h
1328 y -= h;
1329 }
1330 AnchorDirection::Left => {
1331 // x is the right edge of the overlay, so left edge is x - w
1332 x -= w;
1333 }
1334 AnchorDirection::Bottom | AnchorDirection::Right => {
1335 // x/y already represents the top-left corner, no adjustment needed
1336 }
1337 }
1338 }
1339
1340 // Add scroll offset for overlays that follow scroll
1341 y += overlay.scroll_offset_y;
1342
1343 tracing::debug!(
1344 "get_visible_overlay_bounds: kind={:?} pos={:?} dir={:?} bounds=({}, {}, {}, {})",
1345 overlay.config.kind,
1346 overlay.config.position,
1347 overlay.config.anchor_direction,
1348 x, y, w, h
1349 );
1350
1351 Some((x, y, w, h))
1352 })
1353 .collect()
1354 }
1355
1356 /// Close the topmost overlay
1357 pub fn close_top(&mut self) {
1358 // Find highest z-priority open overlay
1359 if let Some(handle) = self
1360 .overlays
1361 .values()
1362 .filter(|o| o.state.is_open())
1363 .max_by_key(|o| o.config.z_priority)
1364 .map(|o| o.handle)
1365 {
1366 self.close(handle);
1367 }
1368 }
1369
1370 /// Close all overlays of a specific kind
1371 pub fn close_all_of(&mut self, kind: OverlayKind) {
1372 let handles: Vec<_> = self
1373 .overlays
1374 .values()
1375 .filter(|o| o.config.kind == kind && o.is_visible())
1376 .map(|o| o.handle)
1377 .collect();
1378
1379 for handle in handles {
1380 self.close(handle);
1381 }
1382 }
1383
1384 /// Close all overlays
1385 pub fn close_all(&mut self) {
1386 let handles: Vec<_> = self
1387 .overlays
1388 .values()
1389 .filter(|o| o.is_visible())
1390 .map(|o| o.handle)
1391 .collect();
1392
1393 for handle in handles {
1394 self.close(handle);
1395 }
1396 }
1397
1398 /// Remove closed overlays
1399 pub fn cleanup(&mut self) {
1400 self.overlays.retain(|_, o| o.state != OverlayState::Closed);
1401 }
1402
1403 /// Handle escape key - close topmost dismissable overlay
1404 pub fn handle_escape(&mut self) -> bool {
1405 if let Some(handle) = self
1406 .overlays
1407 .values()
1408 .filter(|o| o.state.is_open() && o.config.dismiss_on_escape)
1409 .max_by_key(|o| o.config.z_priority)
1410 .map(|o| o.handle)
1411 {
1412 if let Some(overlay) = self.overlays.get_mut(&handle) {
1413 if overlay.transition(overlay_events::ESCAPE) {
1414 // Starting close animation - animation dirty, not content
1415 // Using mark_dirty() here would cause content rebuild which restarts motion animations
1416 // Note: on_close callback is deferred until overlay is fully removed in update()
1417 self.mark_animation_dirty();
1418 return true;
1419 }
1420 }
1421 }
1422 false
1423 }
1424
1425 /// Check if any modal is blocking interaction
1426 pub fn has_blocking_overlay(&self) -> bool {
1427 self.overlays.values().any(|o| {
1428 o.is_visible()
1429 && matches!(o.config.kind, OverlayKind::Modal | OverlayKind::Dialog)
1430 && o.config.backdrop.is_some()
1431 })
1432 }
1433
1434 /// Check if any overlay with dismiss-on-click-outside behavior is visible
1435 ///
1436 /// This includes dropdowns, context menus, popovers, and other overlays
1437 /// that should be dismissed when clicking outside.
1438 pub fn has_dismissable_overlay(&self) -> bool {
1439 self.overlays.values().any(|o| {
1440 o.state.is_open()
1441 && (o.config.dismiss_on_click_outside
1442 || o.config
1443 .backdrop
1444 .as_ref()
1445 .map(|b| b.dismiss_on_click)
1446 .unwrap_or(false))
1447 })
1448 }
1449
1450 /// Handle backdrop click - close topmost overlay if it has dismiss-on-click behavior
1451 ///
1452 /// Returns true if a click was handled (overlay closed), false otherwise.
1453 /// The caller should call this when a mouse click is detected and there's a blocking overlay.
1454 pub fn handle_backdrop_click(&mut self) -> bool {
1455 // Find topmost open overlay with click-outside dismiss behavior
1456 if let Some(handle) = self
1457 .overlays
1458 .values()
1459 .filter(|o| {
1460 o.state.is_open()
1461 && (o.config.dismiss_on_click_outside
1462 || o.config
1463 .backdrop
1464 .as_ref()
1465 .map(|b| b.dismiss_on_click)
1466 .unwrap_or(false))
1467 })
1468 .max_by_key(|o| o.config.z_priority)
1469 .map(|o| o.handle)
1470 {
1471 if let Some(overlay) = self.overlays.get_mut(&handle) {
1472 if overlay.transition(overlay_events::BACKDROP_CLICK) {
1473 // Starting close animation - animation dirty, not content
1474 // Using mark_dirty() here would cause content rebuild which restarts motion animations
1475 // Note: on_close callback is deferred until overlay is fully removed in update()
1476 self.mark_animation_dirty();
1477 return true;
1478 }
1479 }
1480 }
1481 false
1482 }
1483
1484 /// Check if a click at the given position should dismiss an overlay
1485 ///
1486 /// This checks if the click is outside the content bounds of any open overlay
1487 /// with backdrop dismiss enabled. Uses cached content sizes for hit testing.
1488 ///
1489 /// # Arguments
1490 /// * `x` - Logical x coordinate
1491 /// * `y` - Logical y coordinate
1492 ///
1493 /// # Returns
1494 /// True if the click is on a backdrop (outside content), false if on content or no overlay
1495 pub fn is_backdrop_click(&self, x: f32, y: f32) -> bool {
1496 // Find topmost overlay with click-outside dismiss enabled
1497 // This includes both backdrop.dismiss_on_click AND dismiss_on_click_outside
1498 if let Some(overlay) = self
1499 .overlays
1500 .values()
1501 .filter(|o| {
1502 o.state.is_open()
1503 && (o.config.dismiss_on_click_outside
1504 || o.config
1505 .backdrop
1506 .as_ref()
1507 .map(|b| b.dismiss_on_click)
1508 .unwrap_or(false))
1509 })
1510 .max_by_key(|o| o.config.z_priority)
1511 {
1512 // Get content size (may be cached or estimated)
1513 let (content_w, content_h) = overlay.cached_size.unwrap_or_else(|| {
1514 // Fallback: estimate based on overlay config size or default
1515 overlay.config.size.unwrap_or((400.0, 300.0))
1516 });
1517
1518 // Compute content position based on overlay position type
1519 let (vp_w, vp_h) = self.viewport;
1520 let (content_x, content_y) = match &overlay.config.position {
1521 OverlayPosition::Centered => {
1522 // Center the content in viewport
1523 ((vp_w - content_w) / 2.0, (vp_h - content_h) / 2.0)
1524 }
1525 OverlayPosition::AtPoint { x: px, y: py } => {
1526 // Content is positioned at (x, y) as the top-left corner
1527 // This matches the rendering in position_content which uses .left(x).top(y)
1528 // Note: anchor_direction is used for occlusion testing in get_visible_overlay_bounds,
1529 // but here we need to match actual rendering position for hit testing
1530 // Add scroll_offset_y for overlays that follow scroll
1531 (*px, *py + overlay.scroll_offset_y)
1532 }
1533 OverlayPosition::Corner(corner) => {
1534 // Position in corner with margin
1535 let margin = 16.0;
1536 match corner {
1537 Corner::TopLeft => (margin, margin),
1538 Corner::TopRight => (vp_w - content_w - margin, margin),
1539 Corner::BottomLeft => (margin, vp_h - content_h - margin),
1540 Corner::BottomRight => {
1541 (vp_w - content_w - margin, vp_h - content_h - margin)
1542 }
1543 }
1544 }
1545 OverlayPosition::RelativeToAnchor {
1546 offset_x, offset_y, ..
1547 } => {
1548 // For anchor-based positioning, use offset as position
1549 // (Anchor lookup not yet implemented, so treat as point)
1550 (*offset_x, *offset_y)
1551 }
1552 OverlayPosition::Edge(side) => {
1553 // Edge positioning for sheets/drawers
1554 // Content is placed at the edge, spanning the full viewport in the perpendicular direction
1555 match side {
1556 EdgeSide::Left => (0.0, 0.0),
1557 EdgeSide::Right => (vp_w - content_w, 0.0),
1558 EdgeSide::Top => (0.0, 0.0),
1559 EdgeSide::Bottom => (0.0, vp_h - content_h),
1560 }
1561 }
1562 };
1563
1564 // Check if click is outside content bounds
1565 let in_content = x >= content_x
1566 && x <= content_x + content_w
1567 && y >= content_y
1568 && y <= content_y + content_h;
1569
1570 // Click is on backdrop if NOT in content
1571 !in_content
1572 } else {
1573 false
1574 }
1575 }
1576
1577 /// Handle click at position - dismisses if on backdrop
1578 ///
1579 /// Convenience method that combines `is_backdrop_click` and `handle_backdrop_click`.
1580 pub fn handle_click_at(&mut self, x: f32, y: f32) -> bool {
1581 if self.is_backdrop_click(x, y) {
1582 self.handle_backdrop_click()
1583 } else {
1584 false
1585 }
1586 }
1587
1588 /// Get overlays sorted by z-priority
1589 pub fn overlays_sorted(&self) -> Vec<&ActiveOverlay> {
1590 let mut overlays: Vec<_> = self.overlays.values().collect();
1591 overlays.sort_by_key(|o| o.config.z_priority);
1592 overlays
1593 }
1594
1595 /// Check if there are any visible overlays
1596 pub fn has_visible_overlays(&self) -> bool {
1597 self.overlays.values().any(|o| o.is_visible())
1598 }
1599
1600 /// Check if any overlay is currently animating (entering or exiting)
1601 pub fn has_animating_overlays(&self) -> bool {
1602 self.overlays.values().any(|o| o.state.is_animating())
1603 }
1604
1605 /// Get the number of overlays
1606 pub fn overlay_count(&self) -> usize {
1607 self.overlays.len()
1608 }
1609
1610 /// Build the overlay render tree (DEPRECATED - use build_overlay_layer instead)
1611 ///
1612 /// This method creates a separate RenderTree for overlays. Prefer using
1613 /// `build_overlay_layer()` which returns a Div that can be composed into
1614 /// the main UI tree for unified event routing and incremental updates.
1615 pub fn build_overlay_tree(&self) -> Option<RenderTree> {
1616 if !self.has_visible_overlays() {
1617 return None;
1618 }
1619
1620 let (width, height) = self.viewport;
1621 if width <= 0.0 || height <= 0.0 {
1622 tracing::debug!("build_overlay_tree: invalid viewport");
1623 return None;
1624 }
1625
1626 // Build stack with all visible overlays
1627 let mut root = stack().w(width).h(height);
1628
1629 for overlay in self.overlays_sorted() {
1630 if overlay.is_visible() {
1631 tracing::debug!(
1632 "build_overlay_tree: adding overlay {:?}",
1633 overlay.config.kind
1634 );
1635 root = root.child(self.build_single_overlay(overlay, width, height));
1636 }
1637 }
1638
1639 tracing::debug!("build_overlay_tree: building render tree");
1640 let mut tree = RenderTree::from_element(&root);
1641 // CRITICAL: Apply scale factor for HiDPI displays
1642 tree.set_scale_factor(self.scale_factor);
1643 // CRITICAL: Compute layout before rendering, otherwise all positions/sizes are zero
1644 tree.compute_layout(width, height);
1645 Some(tree)
1646 }
1647}
1648
1649/// The element ID used for the overlay layer container
1650pub const OVERLAY_LAYER_ID: &str = "__blinc_overlay_layer__";
1651
1652impl OverlayManagerInner {
1653 /// Build the overlay layer container for the main UI tree
1654 ///
1655 /// This ALWAYS returns a Div container with a stable ID, even when empty.
1656 /// This enables subtree rebuilds to update overlay content without
1657 /// triggering a full UI rebuild.
1658 ///
1659 /// The container uses absolute positioning so it doesn't affect main UI layout.
1660 /// When empty (no visible overlays), the container has zero size so it doesn't
1661 /// block events to the UI below.
1662 pub fn build_overlay_layer(&self) -> Div {
1663 let (width, height) = self.viewport;
1664 let has_visible = self.has_visible_overlays();
1665 let overlay_count = self.overlays.len();
1666
1667 tracing::debug!(
1668 "build_overlay_layer: viewport={}x{}, has_visible={}, overlay_count={}",
1669 width,
1670 height,
1671 has_visible,
1672 overlay_count
1673 );
1674
1675 // Container size: full viewport when overlays visible, zero when empty
1676 // Zero size ensures the empty container doesn't block events to UI below
1677 let (layer_w, layer_h) = if has_visible && width > 0.0 && height > 0.0 {
1678 (width, height)
1679 } else {
1680 (0.0, 0.0)
1681 };
1682
1683 tracing::debug!("build_overlay_layer: layer size={}x{}", layer_w, layer_h);
1684
1685 // Always return a container with a stable ID
1686 // This allows subtree rebuilds to find and update it
1687 // Use .stack_layer() to ensure overlay content renders above main UI
1688 // through z_layer increment in the interleaved rendering system
1689 // Use .pointer_events_none() so the container itself doesn't block events,
1690 // but children (individual overlays) can still capture events
1691 let mut layer = div()
1692 .id(OVERLAY_LAYER_ID)
1693 .w(layer_w)
1694 .h(layer_h)
1695 .absolute()
1696 .left(0.0)
1697 .top(0.0)
1698 .stack_layer()
1699 .pointer_events_none();
1700
1701 // Add visible overlays as children
1702 if has_visible && width > 0.0 && height > 0.0 {
1703 // Separate toasts from other overlays and group toasts by corner
1704 let mut toasts_by_corner: std::collections::HashMap<Corner, Vec<&ActiveOverlay>> =
1705 std::collections::HashMap::new();
1706 let mut non_toasts: Vec<&ActiveOverlay> = Vec::new();
1707
1708 for overlay in self.overlays_sorted() {
1709 if overlay.is_visible() {
1710 if overlay.config.kind == OverlayKind::Toast {
1711 if let OverlayPosition::Corner(corner) = overlay.config.position {
1712 toasts_by_corner.entry(corner).or_default().push(overlay);
1713 } else {
1714 // Toast without Corner position - render as regular overlay
1715 non_toasts.push(overlay);
1716 }
1717 } else {
1718 non_toasts.push(overlay);
1719 }
1720 }
1721 }
1722
1723 // Render non-toast overlays
1724 for overlay in non_toasts {
1725 layer = layer.child(self.build_single_overlay(overlay, width, height));
1726 }
1727
1728 // Render toast stacks for each corner
1729 for (corner, toasts) in toasts_by_corner {
1730 layer = layer.child(self.build_toast_stack(&toasts, corner, width, height));
1731 }
1732 }
1733
1734 layer
1735 }
1736
1737 /// Build a vertically stacked container for toasts in a corner
1738 fn build_toast_stack(
1739 &self,
1740 toasts: &[&ActiveOverlay],
1741 corner: Corner,
1742 vp_width: f32,
1743 vp_height: f32,
1744 ) -> Div {
1745 let margin = 16.0;
1746
1747 // Use absolute positioning so the toast container is independent of
1748 // other overlay siblings in the parent's Row layout.
1749 let mut container = div()
1750 .absolute()
1751 .w(vp_width)
1752 .h(vp_height)
1753 .pointer_events_none()
1754 .p(margin);
1755
1756 // Determine flex direction based on corner (top corners stack down, bottom stack up)
1757 let (container, reverse) = match corner {
1758 Corner::TopLeft => (container.items_start().justify_start(), false),
1759 Corner::TopRight => (container.items_end().justify_start(), false),
1760 Corner::BottomLeft => (container.items_start().justify_end(), true),
1761 Corner::BottomRight => (container.items_end().justify_end(), true),
1762 };
1763
1764 // Build inner stack container for toasts
1765 let mut toast_stack = div().flex_col().gap(self.toast_gap);
1766
1767 // For bottom corners, reverse the order so newest appears at bottom
1768 let toasts_ordered: Vec<_> = if reverse {
1769 toasts.iter().rev().collect()
1770 } else {
1771 toasts.iter().collect()
1772 };
1773
1774 for toast in toasts_ordered {
1775 let content = toast.build_content();
1776 // Apply size constraints if specified
1777 let content = if let Some((w, h)) = toast.config.size {
1778 content.w(w).h(h)
1779 } else {
1780 content
1781 };
1782
1783 // Wrap content with hover leave handler if dismiss_on_hover_leave is enabled
1784 let content = if toast.config.dismiss_on_hover_leave {
1785 let overlay_handle = toast.handle;
1786 let has_close_delay = toast.config.close_delay_ms.is_some();
1787 content.on_hover_leave(move |_| {
1788 if let Some(ctx) = crate::overlay_state::OverlayContext::try_get() {
1789 let mgr = ctx.overlay_manager();
1790 let mut inner = mgr.lock().unwrap();
1791 if has_close_delay {
1792 inner.hover_leave(overlay_handle);
1793 } else {
1794 inner.close(overlay_handle);
1795 }
1796 }
1797 })
1798 } else {
1799 content
1800 };
1801
1802 toast_stack = toast_stack.child(content);
1803 }
1804
1805 container.child(toast_stack)
1806 }
1807
1808 /// Build overlay layer content for subtree rebuild
1809 ///
1810 /// This is called when overlay content changes to queue a subtree rebuild
1811 /// instead of triggering a full UI rebuild.
1812 pub fn build_overlay_content(&self) -> Div {
1813 self.build_overlay_layer()
1814 }
1815
1816 /// Build a single overlay with backdrop and content
1817 fn build_single_overlay(&self, overlay: &ActiveOverlay, vp_width: f32, vp_height: f32) -> Div {
1818 // Content is built by the user - they should wrap it in motion() for animations
1819 // Motion exit is triggered explicitly via query_motion(key).exit() when
1820 // transitioning to Closing state (see transition() method).
1821 let content = overlay.build_content();
1822
1823 // Apply size constraints if specified
1824 let content = if let Some((w, h)) = overlay.config.size {
1825 content.w(w).h(h)
1826 } else {
1827 content
1828 };
1829
1830 // Wrap content with hover leave handler if dismiss_on_hover_leave is enabled
1831 let content = if overlay.config.dismiss_on_hover_leave {
1832 let overlay_handle = overlay.handle;
1833 let has_close_delay = overlay.config.close_delay_ms.is_some();
1834 content.on_hover_leave(move |_| {
1835 tracing::debug!("OVERLAY dismiss_on_hover_leave handler fired");
1836 if let Some(ctx) = crate::overlay_state::OverlayContext::try_get() {
1837 let mgr = ctx.overlay_manager();
1838 let mut inner = mgr.lock().unwrap();
1839 if has_close_delay {
1840 // Use hover_leave to start close delay countdown
1841 tracing::debug!("OVERLAY: calling hover_leave (has_close_delay=true)");
1842 inner.hover_leave(overlay_handle);
1843 } else {
1844 // Close immediately (no delay configured)
1845 // The on_close callback is deferred until after the exit animation completes
1846 // (handled in update() when overlay transitions to Closed state)
1847 inner.close(overlay_handle);
1848 }
1849 }
1850 })
1851 } else {
1852 content
1853 };
1854
1855 // Build the layer with optional backdrop
1856 if let Some(ref backdrop_config) = overlay.config.backdrop {
1857 // Use backdrop color at full opacity - motion animation handles opacity
1858 let backdrop_color = backdrop_config.color;
1859
1860 // Get animation durations from overlay config
1861 let enter_duration = overlay.config.animation.enter.duration_ms();
1862 let exit_duration = overlay.config.animation.exit.duration_ms();
1863
1864 // Create motion key for backdrop (must match the key used in transition())
1865 let backdrop_motion_key = format!("overlay_backdrop_{}", overlay.handle.0);
1866
1867 // Build backdrop div with click-to-dismiss if enabled
1868 let backdrop_div = if backdrop_config.dismiss_on_click {
1869 let overlay_handle = overlay.handle;
1870
1871 div()
1872 .absolute()
1873 .left(0.0)
1874 .top(0.0)
1875 .w(vp_width)
1876 .h(vp_height)
1877 .bg(backdrop_color)
1878 .on_click(move |_| {
1879 println!("Backdrop clicked! Dismissing overlay {:?}", overlay_handle);
1880 // Close this overlay via the global overlay manager
1881 // The on_close callback is deferred until after the exit animation completes
1882 // (handled in update() when overlay transitions to Closed state)
1883 // Do NOT call on_close here - that would trigger state updates and UI rebuild
1884 // which causes the exit animation to restart/jitter
1885 if let Some(ctx) = crate::overlay_state::OverlayContext::try_get() {
1886 ctx.overlay_manager().lock().unwrap().close(overlay_handle);
1887 }
1888 })
1889 } else {
1890 div()
1891 .absolute()
1892 .left(0.0)
1893 .top(0.0)
1894 .w(vp_width)
1895 .h(vp_height)
1896 .bg(backdrop_color)
1897 };
1898
1899 // Wrap backdrop in motion for animated opacity
1900 // Motion handles enter/exit animations, triggered via query_motion().exit() in transition()
1901 // Use motion_derived for explicit key that matches the lookup in transition()
1902 let animated_backdrop = crate::motion::motion_derived(&backdrop_motion_key)
1903 .fade_in(enter_duration)
1904 .fade_out(exit_duration)
1905 .child(backdrop_div);
1906
1907 // Use stack: first child (backdrop) renders behind, second child (content) on top
1908 div().w(vp_width).h(vp_height).child(
1909 stack()
1910 .w(vp_width)
1911 .h(vp_height)
1912 // Backdrop layer (behind) - motion container handles opacity animation
1913 .child(animated_backdrop)
1914 // Content layer (on top) - positioned according to config
1915 // Content animation is handled by user via motion() container
1916 .child(self.position_content(overlay, content, vp_width, vp_height)),
1917 )
1918 } else {
1919 // // No backdrop - wrap in viewport-sized container for proper z-ordering
1920 // // The container is pointer-events:none equivalent (no event handlers)
1921 // // so it doesn't block events to UI below
1922 // div()
1923 // .w(vp_width)
1924 // .h(vp_height)
1925 // .absolute()
1926 // .left(0.0)
1927 // .top(0.0)
1928 // .child()
1929
1930 self.position_content(overlay, content, vp_width, vp_height)
1931 }
1932 }
1933
1934 /// Position content according to overlay position config
1935 fn position_content(
1936 &self,
1937 overlay: &ActiveOverlay,
1938 content: Div,
1939 vp_width: f32,
1940 vp_height: f32,
1941 ) -> Div {
1942 match &overlay.config.position {
1943 OverlayPosition::Centered => {
1944 // Center using flexbox
1945 // pointer_events_none allows scroll events to pass through to UI below
1946 // while the actual content (child) still receives events
1947 div()
1948 .w(vp_width)
1949 .h(vp_height)
1950 .pointer_events_none()
1951 .items_center()
1952 .justify_center()
1953 .child(content)
1954 }
1955
1956 OverlayPosition::AtPoint { x, y } => {
1957 // Position content at specific point using absolute positioning within viewport
1958 // Wrap in a container with unique ID for scroll offset application
1959 let wrapper_id = format!("overlay_scroll_{}", overlay.handle.id());
1960 div()
1961 .id(&wrapper_id)
1962 .absolute()
1963 .left(*x)
1964 .top(*y)
1965 .child(content)
1966 }
1967
1968 OverlayPosition::Corner(corner) => {
1969 // Position in corner with margin
1970 let margin = 16.0;
1971 self.position_in_corner(content, *corner, vp_width, vp_height, margin)
1972 }
1973
1974 OverlayPosition::RelativeToAnchor {
1975 offset_x, offset_y, ..
1976 } => {
1977 // For now, treat as point position
1978 // TODO: Look up anchor bounds from tree
1979 // pointer_events_none allows scroll events to pass through to UI below
1980 div()
1981 .w(vp_width)
1982 .h(vp_height)
1983 .pointer_events_none()
1984 .child(content.ml(*offset_x).mt(*offset_y))
1985 }
1986
1987 OverlayPosition::Edge(side) => {
1988 // Edge positioning for sheets/drawers
1989 // The content is rendered at its natural size, positioned at the edge
1990 // pointer_events_none allows backdrop clicks to pass through the wrapper
1991 let container = div().w(vp_width).h(vp_height).pointer_events_none();
1992
1993 match side {
1994 EdgeSide::Left => container
1995 .flex_row()
1996 .items_start()
1997 .justify_start()
1998 .child(content),
1999 EdgeSide::Right => container
2000 .flex_row()
2001 .items_start()
2002 .justify_end()
2003 .child(content),
2004 EdgeSide::Top => container
2005 .flex_col()
2006 .items_start()
2007 .justify_start()
2008 .child(content),
2009 EdgeSide::Bottom => container
2010 .flex_col()
2011 .items_start()
2012 .justify_end()
2013 .child(content),
2014 }
2015 }
2016 }
2017 }
2018
2019 /// Position content in a corner
2020 fn position_in_corner(
2021 &self,
2022 content: Div,
2023 corner: Corner,
2024 vp_width: f32,
2025 vp_height: f32,
2026 margin: f32,
2027 ) -> Div {
2028 // pointer_events_none allows scroll events to pass through to UI below
2029 // while the actual content (child) still receives events
2030 let container = div().w(vp_width).h(vp_height).pointer_events_none();
2031
2032 match corner {
2033 Corner::TopLeft => container
2034 .items_start()
2035 .justify_start()
2036 .child(content.m(margin)),
2037 Corner::TopRight => container
2038 .items_end()
2039 .justify_start()
2040 .child(content.m(margin)),
2041 Corner::BottomLeft => container
2042 .items_start()
2043 .justify_end()
2044 .child(content.m(margin)),
2045 Corner::BottomRight => container.items_end().justify_end().child(content.m(margin)),
2046 }
2047 }
2048
2049 /// Layout toasts in a stack
2050 pub fn layout_toasts(&self) -> Vec<(OverlayHandle, f32, f32)> {
2051 let (vp_width, vp_height) = self.viewport;
2052 let toasts: Vec<_> = self
2053 .overlays
2054 .values()
2055 .filter(|o| o.config.kind == OverlayKind::Toast && o.is_visible())
2056 .collect();
2057
2058 let margin = 16.0;
2059 let mut positions = Vec::new();
2060 let mut y_offset = margin;
2061
2062 for (i, toast) in toasts.iter().take(self.max_toasts).enumerate() {
2063 // Estimate toast height (will be refined after layout)
2064 let estimated_height = toast.cached_size.map(|(_, h)| h).unwrap_or(60.0);
2065
2066 let (x, y) = match self.toast_corner {
2067 Corner::TopLeft => (margin, y_offset),
2068 Corner::TopRight => (vp_width - margin - 300.0, y_offset), // Assume 300px width
2069 Corner::BottomLeft => (margin, vp_height - y_offset - estimated_height),
2070 Corner::BottomRight => (
2071 vp_width - margin - 300.0,
2072 vp_height - y_offset - estimated_height,
2073 ),
2074 };
2075
2076 positions.push((toast.handle, x, y));
2077
2078 // Stack vertically
2079 y_offset += estimated_height + self.toast_gap;
2080 }
2081
2082 positions
2083 }
2084}
2085
2086impl Default for OverlayManagerInner {
2087 fn default() -> Self {
2088 Self::new()
2089 }
2090}
2091
2092// =============================================================================
2093// OverlayManager
2094// =============================================================================
2095
2096/// Thread-safe overlay manager
2097pub type OverlayManager = Arc<Mutex<OverlayManagerInner>>;
2098
2099/// Create a new overlay manager
2100pub fn overlay_manager() -> OverlayManager {
2101 Arc::new(Mutex::new(OverlayManagerInner::new()))
2102}
2103
2104// =============================================================================
2105// Builder Extension Trait
2106// =============================================================================
2107
2108/// Extension trait for OverlayManager to create builders
2109pub trait OverlayManagerExt {
2110 /// Start building a modal overlay
2111 fn modal(&self) -> ModalBuilder;
2112 /// Start building a dialog overlay
2113 fn dialog(&self) -> DialogBuilder;
2114 /// Start building a context menu overlay
2115 fn context_menu(&self) -> ContextMenuBuilder;
2116 /// Start building a toast overlay
2117 fn toast(&self) -> ToastBuilder;
2118 /// Start building a dropdown overlay
2119 fn dropdown(&self) -> DropdownBuilder;
2120 /// Start building a hover card overlay (dropdown that closes on mouse leave)
2121 fn hover_card(&self) -> DropdownBuilder;
2122
2123 /// Close an overlay by handle
2124 fn close(&self, handle: OverlayHandle);
2125 /// Close an overlay immediately, skipping any exit animation
2126 ///
2127 /// Use this when you need to ensure an overlay is removed before opening a replacement.
2128 fn close_immediate(&self, handle: OverlayHandle);
2129 /// Cancel a pending close (Closing -> Open)
2130 ///
2131 /// Used when mouse re-enters a hover card during exit animation.
2132 fn cancel_close(&self, handle: OverlayHandle);
2133 /// Trigger hover leave - starts close delay countdown (Open -> PendingClose)
2134 ///
2135 /// For overlays with close_delay_ms configured, starts the countdown.
2136 /// Use hover_enter to cancel before the delay expires.
2137 fn hover_leave(&self, handle: OverlayHandle);
2138 /// Trigger hover enter - cancels close delay countdown (PendingClose -> Open)
2139 ///
2140 /// If overlay is in PendingClose state, cancels the delay and stays open.
2141 fn hover_enter(&self, handle: OverlayHandle);
2142 /// Check if an overlay is in PendingClose state (waiting for close delay)
2143 fn is_pending_close(&self, handle: OverlayHandle) -> bool;
2144 /// Check if an overlay is in Closing state (exit animation playing)
2145 fn is_closing(&self, handle: OverlayHandle) -> bool;
2146 /// Close the topmost overlay
2147 fn close_top(&self);
2148 /// Close all overlays of a kind
2149 fn close_all_of(&self, kind: OverlayKind);
2150 /// Close all overlays
2151 fn close_all(&self);
2152 /// Handle escape key
2153 fn handle_escape(&self) -> bool;
2154 /// Handle backdrop click (dismiss if applicable)
2155 fn handle_backdrop_click(&self) -> bool;
2156 /// Handle click at position - dismisses if on backdrop
2157 fn handle_click_at(&self, x: f32, y: f32) -> bool;
2158 /// Update viewport dimensions (logical pixels)
2159 fn set_viewport(&self, width: f32, height: f32);
2160 /// Update viewport dimensions with scale factor
2161 fn set_viewport_with_scale(&self, width: f32, height: f32, scale_factor: f32);
2162 /// Build overlay render tree (DEPRECATED - use build_overlay_layer instead)
2163 fn build_overlay_tree(&self) -> Option<RenderTree>;
2164 /// Build overlay layer as a Div for composing into main UI tree (always returns a container)
2165 fn build_overlay_layer(&self) -> Div;
2166 /// Check if any blocking overlay is active
2167 fn has_blocking_overlay(&self) -> bool;
2168 /// Check if any dismissable overlay is visible (dropdown, context menu, etc.)
2169 fn has_dismissable_overlay(&self) -> bool;
2170 /// Check if any overlay is visible
2171 fn has_visible_overlays(&self) -> bool;
2172 /// Check if any overlay is currently animating (entering or exiting)
2173 fn has_animating_overlays(&self) -> bool;
2174 /// Check if a specific overlay handle is still visible
2175 fn is_visible(&self, handle: OverlayHandle) -> bool;
2176 /// Update overlay states - call every frame for animations and auto-dismiss
2177 fn update(&self, current_time_ms: u64);
2178 /// Take the dirty flag (returns true if content changed and needs full rebuild)
2179 fn take_dirty(&self) -> bool;
2180 /// Check dirty flag without clearing (for peeking before render)
2181 fn is_dirty(&self) -> bool;
2182 /// Take the animation dirty flag (returns true if animation changed but content is same)
2183 fn take_animation_dirty(&self) -> bool;
2184 /// Check if needs any kind of redraw (content or animation)
2185 fn needs_redraw(&self) -> bool;
2186 /// Set the cached content size for an overlay (for hit testing)
2187 fn set_content_size(&self, handle: OverlayHandle, width: f32, height: f32);
2188 /// Handle scroll event - updates positions of overlays with follows_scroll enabled
2189 ///
2190 /// Returns true if any overlay positions were updated.
2191 fn handle_scroll(&self, delta_y: f32) -> bool;
2192 /// Get scroll offsets for all follows_scroll overlays
2193 ///
2194 /// Returns (element_id, offset_y) pairs for rendering.
2195 fn get_scroll_offsets(&self) -> Vec<(String, f32)>;
2196 /// Mark overlay content as dirty (triggers full rebuild)
2197 ///
2198 /// Call this when state used in overlay content changes and needs to be reflected.
2199 /// This is needed because overlay content is built once and cached.
2200 ///
2201 /// WARNING: This triggers a full content rebuild which re-initializes motion animations.
2202 /// For simple visual updates like hover state changes, use `request_redraw()` instead.
2203 fn mark_content_dirty(&self);
2204
2205 /// Request a redraw without rebuilding content
2206 ///
2207 /// Use this for visual state updates that don't require the overlay tree to be rebuilt,
2208 /// such as hover state changes. This avoids re-triggering motion animations.
2209 fn request_redraw(&self);
2210
2211 /// Get bounds of all visible overlays for occlusion testing
2212 ///
2213 /// Returns a list of (x, y, width, height) rectangles for all visible overlay content.
2214 /// Use this for overlay-aware hit testing to prevent hover events on elements
2215 /// that are covered by overlays.
2216 fn get_visible_overlay_bounds(&self) -> Vec<(f32, f32, f32, f32)>;
2217}
2218
2219impl OverlayManagerExt for OverlayManager {
2220 fn modal(&self) -> ModalBuilder {
2221 ModalBuilder::new(Arc::clone(self))
2222 }
2223
2224 fn dialog(&self) -> DialogBuilder {
2225 DialogBuilder::new(Arc::clone(self))
2226 }
2227
2228 fn context_menu(&self) -> ContextMenuBuilder {
2229 ContextMenuBuilder::new(Arc::clone(self))
2230 }
2231
2232 fn toast(&self) -> ToastBuilder {
2233 ToastBuilder::new(Arc::clone(self))
2234 }
2235
2236 fn dropdown(&self) -> DropdownBuilder {
2237 DropdownBuilder::new(Arc::clone(self))
2238 }
2239
2240 fn hover_card(&self) -> DropdownBuilder {
2241 DropdownBuilder::new_hover_card(Arc::clone(self))
2242 }
2243
2244 fn close(&self, handle: OverlayHandle) {
2245 self.lock().unwrap().close(handle);
2246 }
2247
2248 fn close_immediate(&self, handle: OverlayHandle) {
2249 self.lock().unwrap().close_immediate(handle);
2250 }
2251
2252 fn cancel_close(&self, handle: OverlayHandle) {
2253 self.lock().unwrap().cancel_close(handle);
2254 }
2255
2256 fn hover_leave(&self, handle: OverlayHandle) {
2257 self.lock().unwrap().hover_leave(handle);
2258 }
2259
2260 fn hover_enter(&self, handle: OverlayHandle) {
2261 self.lock().unwrap().hover_enter(handle);
2262 }
2263
2264 fn is_pending_close(&self, handle: OverlayHandle) -> bool {
2265 self.lock().unwrap().is_pending_close(handle)
2266 }
2267
2268 fn is_closing(&self, handle: OverlayHandle) -> bool {
2269 self.lock().unwrap().is_closing(handle)
2270 }
2271
2272 fn close_top(&self) {
2273 self.lock().unwrap().close_top();
2274 }
2275
2276 fn close_all_of(&self, kind: OverlayKind) {
2277 self.lock().unwrap().close_all_of(kind);
2278 }
2279
2280 fn close_all(&self) {
2281 self.lock().unwrap().close_all();
2282 }
2283
2284 fn handle_escape(&self) -> bool {
2285 self.lock().unwrap().handle_escape()
2286 }
2287
2288 fn handle_backdrop_click(&self) -> bool {
2289 self.lock().unwrap().handle_backdrop_click()
2290 }
2291
2292 fn handle_click_at(&self, x: f32, y: f32) -> bool {
2293 self.lock().unwrap().handle_click_at(x, y)
2294 }
2295
2296 fn set_viewport(&self, width: f32, height: f32) {
2297 self.lock().unwrap().set_viewport(width, height);
2298 }
2299
2300 fn set_viewport_with_scale(&self, width: f32, height: f32, scale_factor: f32) {
2301 self.lock()
2302 .unwrap()
2303 .set_viewport_with_scale(width, height, scale_factor);
2304 }
2305
2306 fn build_overlay_tree(&self) -> Option<RenderTree> {
2307 self.lock().unwrap().build_overlay_tree()
2308 }
2309
2310 fn build_overlay_layer(&self) -> Div {
2311 self.lock().unwrap().build_overlay_layer()
2312 }
2313
2314 fn has_blocking_overlay(&self) -> bool {
2315 self.lock().unwrap().has_blocking_overlay()
2316 }
2317
2318 fn has_dismissable_overlay(&self) -> bool {
2319 self.lock().unwrap().has_dismissable_overlay()
2320 }
2321
2322 fn has_visible_overlays(&self) -> bool {
2323 self.lock().unwrap().has_visible_overlays()
2324 }
2325
2326 fn has_animating_overlays(&self) -> bool {
2327 self.lock().unwrap().has_animating_overlays()
2328 }
2329
2330 fn is_visible(&self, handle: OverlayHandle) -> bool {
2331 self.lock()
2332 .unwrap()
2333 .overlays
2334 .get(&handle)
2335 .map(|o| o.is_visible())
2336 .unwrap_or(false)
2337 }
2338
2339 fn update(&self, current_time_ms: u64) {
2340 self.lock().unwrap().update(current_time_ms);
2341 }
2342
2343 fn take_dirty(&self) -> bool {
2344 self.lock().unwrap().take_dirty()
2345 }
2346
2347 fn is_dirty(&self) -> bool {
2348 self.lock().unwrap().is_dirty()
2349 }
2350
2351 fn take_animation_dirty(&self) -> bool {
2352 self.lock().unwrap().take_animation_dirty()
2353 }
2354
2355 fn needs_redraw(&self) -> bool {
2356 self.lock().unwrap().needs_redraw()
2357 }
2358
2359 fn set_content_size(&self, handle: OverlayHandle, width: f32, height: f32) {
2360 self.lock().unwrap().set_content_size(handle, width, height);
2361 }
2362
2363 fn handle_scroll(&self, delta_y: f32) -> bool {
2364 self.lock().unwrap().handle_scroll(delta_y)
2365 }
2366
2367 fn get_scroll_offsets(&self) -> Vec<(String, f32)> {
2368 self.lock().unwrap().get_scroll_offsets()
2369 }
2370
2371 fn mark_content_dirty(&self) {
2372 self.lock().unwrap().mark_dirty();
2373 }
2374
2375 fn request_redraw(&self) {
2376 self.lock().unwrap().mark_animation_dirty();
2377 }
2378
2379 fn get_visible_overlay_bounds(&self) -> Vec<(f32, f32, f32, f32)> {
2380 self.lock().unwrap().get_visible_overlay_bounds()
2381 }
2382}
2383
2384// =============================================================================
2385// Builders
2386// =============================================================================
2387
2388/// Builder for modal overlays
2389pub struct ModalBuilder {
2390 manager: OverlayManager,
2391 config: OverlayConfig,
2392 content: Option<Box<dyn Fn() -> Div + Send + Sync>>,
2393}
2394
2395impl ModalBuilder {
2396 fn new(manager: OverlayManager) -> Self {
2397 Self {
2398 manager,
2399 config: OverlayConfig::modal(),
2400 content: None,
2401 }
2402 }
2403
2404 /// Set the content using a builder function
2405 ///
2406 /// # Example
2407 /// ```ignore
2408 /// overlay_manager.modal()
2409 /// .content(|| {
2410 /// div().p(20.0).bg(Color::WHITE)
2411 /// .child(text("Modal Content"))
2412 /// })
2413 /// .show()
2414 /// ```
2415 pub fn content<F>(mut self, f: F) -> Self
2416 where
2417 F: Fn() -> Div + Send + Sync + 'static,
2418 {
2419 self.content = Some(Box::new(f));
2420 self
2421 }
2422
2423 /// Set explicit size
2424 pub fn size(mut self, width: f32, height: f32) -> Self {
2425 self.config.size = Some((width, height));
2426 self
2427 }
2428
2429 /// Set backdrop configuration
2430 pub fn backdrop(mut self, config: BackdropConfig) -> Self {
2431 self.config.backdrop = Some(config);
2432 self
2433 }
2434
2435 /// Remove backdrop
2436 pub fn no_backdrop(mut self) -> Self {
2437 self.config.backdrop = None;
2438 self
2439 }
2440
2441 /// Set dismiss on escape
2442 pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
2443 self.config.dismiss_on_escape = dismiss;
2444 self
2445 }
2446
2447 /// Set animation
2448 pub fn animation(mut self, animation: OverlayAnimation) -> Self {
2449 self.config.animation = animation;
2450 self
2451 }
2452
2453 /// Set the motion key for triggering content exit animations
2454 ///
2455 /// When the overlay transitions to Closing state, it will automatically
2456 /// trigger exit animation on the motion with this key. Use the same key
2457 /// with `motion_derived(key)` in your content builder.
2458 pub fn motion_key(mut self, key: impl Into<String>) -> Self {
2459 self.config.motion_key = Some(key.into());
2460 self
2461 }
2462
2463 /// Set edge position for sheets and drawers
2464 ///
2465 /// This positions the overlay content at an edge of the viewport.
2466 /// Use with `.size()` to set the actual content dimensions for
2467 /// proper backdrop click detection.
2468 ///
2469 /// # Example
2470 /// ```ignore
2471 /// overlay_manager.modal()
2472 /// .edge_position(EdgeSide::Right)
2473 /// .size(320.0, viewport_height) // Sheet panel dimensions
2474 /// .content(|| sheet_panel)
2475 /// .show()
2476 /// ```
2477 pub fn edge_position(mut self, side: EdgeSide) -> Self {
2478 self.config.position = OverlayPosition::Edge(side);
2479 self
2480 }
2481
2482 /// Show the modal
2483 pub fn show(self) -> OverlayHandle {
2484 let content = self.content.unwrap_or_else(|| Box::new(div));
2485 self.manager.lock().unwrap().add(self.config, content)
2486 }
2487}
2488
2489/// Builder for dialog overlays
2490pub struct DialogBuilder {
2491 manager: OverlayManager,
2492 config: OverlayConfig,
2493 content: Option<Box<dyn Fn() -> Div + Send + Sync>>,
2494}
2495
2496impl DialogBuilder {
2497 fn new(manager: OverlayManager) -> Self {
2498 Self {
2499 manager,
2500 config: OverlayConfig::dialog(),
2501 content: None,
2502 }
2503 }
2504
2505 /// Set the content using a builder function
2506 pub fn content<F>(mut self, f: F) -> Self
2507 where
2508 F: Fn() -> Div + Send + Sync + 'static,
2509 {
2510 self.content = Some(Box::new(f));
2511 self
2512 }
2513
2514 /// Set explicit size
2515 pub fn size(mut self, width: f32, height: f32) -> Self {
2516 self.config.size = Some((width, height));
2517 self
2518 }
2519
2520 /// Show the dialog
2521 pub fn show(self) -> OverlayHandle {
2522 let content = self.content.unwrap_or_else(|| Box::new(div));
2523 self.manager.lock().unwrap().add(self.config, content)
2524 }
2525}
2526
2527/// Builder for context menu overlays
2528pub struct ContextMenuBuilder {
2529 manager: OverlayManager,
2530 config: OverlayConfig,
2531 content: Option<Box<dyn Fn() -> Div + Send + Sync>>,
2532}
2533
2534impl ContextMenuBuilder {
2535 fn new(manager: OverlayManager) -> Self {
2536 Self {
2537 manager,
2538 config: OverlayConfig::context_menu(),
2539 content: None,
2540 }
2541 }
2542
2543 /// Position at coordinates
2544 pub fn at(mut self, x: f32, y: f32) -> Self {
2545 self.config.position = OverlayPosition::AtPoint { x, y };
2546 self
2547 }
2548
2549 /// Set the content using a builder function
2550 pub fn content<F>(mut self, f: F) -> Self
2551 where
2552 F: Fn() -> Div + Send + Sync + 'static,
2553 {
2554 self.content = Some(Box::new(f));
2555 self
2556 }
2557
2558 /// Show the context menu
2559 pub fn show(self) -> OverlayHandle {
2560 let content = self.content.unwrap_or_else(|| Box::new(div));
2561 self.manager.lock().unwrap().add(self.config, content)
2562 }
2563}
2564
2565/// Builder for toast overlays
2566pub struct ToastBuilder {
2567 manager: OverlayManager,
2568 config: OverlayConfig,
2569 content: Option<Box<dyn Fn() -> Div + Send + Sync>>,
2570}
2571
2572impl ToastBuilder {
2573 fn new(manager: OverlayManager) -> Self {
2574 Self {
2575 manager,
2576 config: OverlayConfig::toast(),
2577 content: None,
2578 }
2579 }
2580
2581 /// Set auto-dismiss duration in milliseconds
2582 pub fn duration_ms(mut self, ms: u32) -> Self {
2583 self.config.auto_dismiss_ms = Some(ms);
2584 self
2585 }
2586
2587 /// Set corner position
2588 pub fn corner(mut self, corner: Corner) -> Self {
2589 self.config.position = OverlayPosition::Corner(corner);
2590 self
2591 }
2592
2593 /// Set the content using a builder function
2594 pub fn content<F>(mut self, f: F) -> Self
2595 where
2596 F: Fn() -> Div + Send + Sync + 'static,
2597 {
2598 self.content = Some(Box::new(f));
2599 self
2600 }
2601
2602 /// Set the motion key for triggering content exit animations
2603 ///
2604 /// When the overlay transitions to Closing state, it will automatically
2605 /// trigger exit animation on the motion with this key. Use the same key
2606 /// with `motion_derived(key)` in your content builder.
2607 pub fn motion_key(mut self, key: impl Into<String>) -> Self {
2608 self.config.motion_key = Some(key.into());
2609 self
2610 }
2611
2612 /// Show the toast
2613 pub fn show(self) -> OverlayHandle {
2614 let content = self.content.unwrap_or_else(|| Box::new(div));
2615 self.manager.lock().unwrap().add(self.config, content)
2616 }
2617}
2618
2619/// Builder for dropdown overlays
2620pub struct DropdownBuilder {
2621 manager: OverlayManager,
2622 config: OverlayConfig,
2623 content: Option<Box<dyn Fn() -> Div + Send + Sync>>,
2624 on_close: Option<OnCloseCallback>,
2625}
2626
2627impl DropdownBuilder {
2628 fn new(manager: OverlayManager) -> Self {
2629 Self {
2630 manager,
2631 config: OverlayConfig::dropdown(),
2632 content: None,
2633 on_close: None,
2634 }
2635 }
2636
2637 fn new_hover_card(manager: OverlayManager) -> Self {
2638 Self {
2639 manager,
2640 config: OverlayConfig::hover_card(),
2641 content: None,
2642 on_close: None,
2643 }
2644 }
2645
2646 /// Position at specific coordinates
2647 ///
2648 /// This is useful when the dropdown position is calculated from mouse position
2649 /// or other dynamic sources.
2650 pub fn at(mut self, x: f32, y: f32) -> Self {
2651 self.config.position = OverlayPosition::AtPoint { x, y };
2652 self
2653 }
2654
2655 /// Position relative to an anchor element
2656 pub fn anchor(mut self, node: LayoutNodeId) -> Self {
2657 self.config.position = OverlayPosition::RelativeToAnchor {
2658 anchor: node,
2659 offset_x: 0.0,
2660 offset_y: 0.0,
2661 };
2662 self
2663 }
2664
2665 /// Set offset from anchor
2666 pub fn offset(mut self, x: f32, y: f32) -> Self {
2667 if let OverlayPosition::RelativeToAnchor {
2668 offset_x, offset_y, ..
2669 } = &mut self.config.position
2670 {
2671 *offset_x = x;
2672 *offset_y = y;
2673 }
2674 self
2675 }
2676
2677 /// Enable dismiss on escape key
2678 pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
2679 self.config.dismiss_on_escape = dismiss;
2680 self
2681 }
2682
2683 /// Enable dismiss when mouse leaves the overlay content (for hover cards)
2684 pub fn dismiss_on_hover_leave(mut self, dismiss: bool) -> Self {
2685 self.config.dismiss_on_hover_leave = dismiss;
2686 // When using hover leave dismiss, we typically don't want a backdrop
2687 if dismiss {
2688 self.config.backdrop = None;
2689 }
2690 self
2691 }
2692
2693 /// Enable dismiss when clicking outside the overlay content (without a backdrop)
2694 ///
2695 /// This allows the overlay to be dismissed by clicking outside its content area
2696 /// WITHOUT adding a backdrop element. This means scroll events and other
2697 /// interactions pass through to the content behind the overlay.
2698 ///
2699 /// Useful for popovers that should dismiss on outside click but not block scrolling.
2700 ///
2701 /// # Example
2702 ///
2703 /// ```ignore
2704 /// mgr.hover_card()
2705 /// .dismiss_on_hover_leave(false) // Disable hover dismiss
2706 /// .dismiss_on_click_outside(true) // Enable click outside dismiss
2707 /// .content(|| popover_content())
2708 /// .show()
2709 /// ```
2710 pub fn dismiss_on_click_outside(mut self, dismiss: bool) -> Self {
2711 self.config.dismiss_on_click_outside = dismiss;
2712 self
2713 }
2714
2715 /// Make the overlay follow scroll events
2716 ///
2717 /// When enabled, the overlay position will be updated by the scroll delta,
2718 /// keeping it attached to its trigger element as the page scrolls.
2719 ///
2720 /// # Example
2721 ///
2722 /// ```ignore
2723 /// mgr.hover_card()
2724 /// .at(x, y)
2725 /// .follows_scroll(true) // Follow scroll instead of staying fixed
2726 /// .content(|| popover_content())
2727 /// .show()
2728 /// ```
2729 pub fn follows_scroll(mut self, follows: bool) -> Self {
2730 self.config.follows_scroll = follows;
2731 self
2732 }
2733
2734 /// Set auto-dismiss timeout in milliseconds
2735 ///
2736 /// When set, the overlay will automatically close after this duration.
2737 /// Set to None to disable auto-dismiss (overlay stays open until explicitly closed).
2738 pub fn auto_dismiss(mut self, ms: Option<u32>) -> Self {
2739 self.config.auto_dismiss_ms = ms;
2740 self
2741 }
2742
2743 /// Set close delay in milliseconds
2744 ///
2745 /// When set, there's a delay after mouse leaves before the overlay closes.
2746 /// Set to None to disable close delay.
2747 pub fn close_delay(mut self, ms: Option<u32>) -> Self {
2748 self.config.close_delay_ms = ms;
2749 self
2750 }
2751
2752 /// Set the expected content size for hit testing
2753 ///
2754 /// This helps with backdrop click detection by providing the expected
2755 /// size of the dropdown content. Without this, a default size is used.
2756 pub fn size(mut self, width: f32, height: f32) -> Self {
2757 self.config.size = Some((width, height));
2758 self
2759 }
2760
2761 /// Set the content using a builder function
2762 pub fn content<F>(mut self, f: F) -> Self
2763 where
2764 F: Fn() -> Div + Send + Sync + 'static,
2765 {
2766 self.content = Some(Box::new(f));
2767 self
2768 }
2769
2770 /// Set a callback to be invoked when the dropdown is closed
2771 ///
2772 /// This is called when the dropdown is dismissed via backdrop click, escape key, etc.
2773 pub fn on_close<F>(mut self, f: F) -> Self
2774 where
2775 F: Fn() + Send + Sync + 'static,
2776 {
2777 self.on_close = Some(Arc::new(f));
2778 self
2779 }
2780
2781 /// Set the motion key for triggering content exit animations
2782 ///
2783 /// When the overlay transitions to Closing state, it will automatically
2784 /// trigger exit animation on the motion with this key. Use the same key
2785 /// with `motion_derived(key)` in your content builder.
2786 ///
2787 /// # Example
2788 ///
2789 /// ```ignore
2790 /// mgr.hover_card()
2791 /// .motion_key("my_hover_card")
2792 /// .content(move || {
2793 /// motion_derived("my_hover_card")
2794 /// .enter_animation(AnimationPreset::grow_in(150))
2795 /// .exit_animation(AnimationPreset::grow_out(100))
2796 /// .child(card_content)
2797 /// })
2798 /// .show()
2799 /// ```
2800 pub fn motion_key(mut self, key: impl Into<String>) -> Self {
2801 self.config.motion_key = Some(key.into());
2802 self
2803 }
2804
2805 /// Set the anchor direction for correct occlusion testing
2806 ///
2807 /// For positioned overlays (via `.at(x, y)`), this specifies which edge
2808 /// the (x, y) point represents:
2809 /// - `Top`: overlay appears above trigger, y is the bottom edge
2810 /// - `Bottom`: overlay appears below trigger, y is the top edge (default)
2811 /// - `Left`: overlay appears left of trigger, x is the right edge
2812 /// - `Right`: overlay appears right of trigger, x is the left edge
2813 ///
2814 /// This is used to calculate correct bounds for hit test occlusion.
2815 pub fn anchor_direction(mut self, direction: AnchorDirection) -> Self {
2816 self.config.anchor_direction = direction;
2817 self
2818 }
2819
2820 /// Set the animation for enter/exit transitions
2821 ///
2822 /// Use `OverlayAnimation::none()` for instant show/hide.
2823 pub fn animation(mut self, animation: OverlayAnimation) -> Self {
2824 self.config.animation = animation;
2825 self
2826 }
2827
2828 /// Show the dropdown
2829 pub fn show(self) -> OverlayHandle {
2830 let content = self.content.unwrap_or_else(|| Box::new(div));
2831 self.manager
2832 .lock()
2833 .unwrap()
2834 .add_with_close_callback(self.config, content, self.on_close)
2835 }
2836}
2837
2838// =============================================================================
2839// Tests
2840// =============================================================================
2841
2842#[cfg(test)]
2843mod tests {
2844 use super::*;
2845
2846 #[test]
2847 fn test_overlay_state_transitions() {
2848 use overlay_events::*;
2849
2850 let mut state = OverlayState::Closed;
2851
2852 // Closed -> Opening
2853 state = state.on_event(OPEN).unwrap();
2854 assert_eq!(state, OverlayState::Opening);
2855
2856 // Opening -> Open
2857 state = state.on_event(ANIMATION_COMPLETE).unwrap();
2858 assert_eq!(state, OverlayState::Open);
2859
2860 // Open -> Closing
2861 state = state.on_event(CLOSE).unwrap();
2862 assert_eq!(state, OverlayState::Closing);
2863
2864 // Closing -> Closed
2865 state = state.on_event(ANIMATION_COMPLETE).unwrap();
2866 assert_eq!(state, OverlayState::Closed);
2867 }
2868
2869 #[test]
2870 fn test_overlay_manager_basic() {
2871 let mgr = overlay_manager();
2872
2873 // Add a modal
2874 let handle = mgr.lock().unwrap().add(OverlayConfig::modal(), div);
2875
2876 assert!(mgr.lock().unwrap().has_visible_overlays());
2877
2878 // Close it
2879 mgr.close(handle);
2880
2881 // Should still be visible (Closing state)
2882 assert!(mgr.lock().unwrap().has_visible_overlays());
2883 }
2884
2885 #[test]
2886 fn test_overlay_escape() {
2887 let mgr = overlay_manager();
2888
2889 // Add modal with dismiss_on_escape
2890 let _handle = {
2891 let mut m = mgr.lock().unwrap();
2892 let h = m.add(OverlayConfig::modal(), div);
2893 // Manually transition to Open state
2894 if let Some(o) = m.overlays.get_mut(&h) {
2895 o.state = OverlayState::Open;
2896 }
2897 h
2898 };
2899
2900 // Escape should close it
2901 assert!(mgr.handle_escape());
2902 }
2903
2904 #[test]
2905 fn test_overlay_config_defaults() {
2906 let modal = OverlayConfig::modal();
2907 assert!(modal.backdrop.is_some());
2908 assert!(modal.dismiss_on_escape);
2909 assert!(modal.focus_trap);
2910
2911 let toast = OverlayConfig::toast();
2912 assert!(toast.backdrop.is_none());
2913 assert!(!toast.dismiss_on_escape);
2914 assert!(toast.auto_dismiss_ms.is_some());
2915
2916 let context = OverlayConfig::context_menu();
2917 assert!(context.backdrop.is_none());
2918 assert!(context.dismiss_on_escape);
2919 }
2920}