Skip to main content

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}