Skip to main content

fret_ui_kit/
overlay_controller.rs

1use std::time::Duration;
2
3use fret_core::{AppWindowId, Rect};
4use fret_runtime::Model;
5use fret_ui::action::{
6    OnCloseAutoFocus, OnDismissRequest, OnDismissiblePointerMove, OnOpenAutoFocus,
7};
8use fret_ui::element::AnyElement;
9use fret_ui::elements::GlobalElementId;
10use fret_ui::{ElementContext, UiHost, UiTree};
11
12use crate::headless::presence::PresenceOutput;
13use crate::headless::transition::TransitionOutput;
14use crate::primitives::presence;
15use crate::window_overlays;
16
17/// Presence state for an overlay root (mount/paint vs interactive).
18///
19/// This is intentionally a small, typed wrapper so component code doesn't pass raw `present: bool`
20/// around and accidentally conflate it with `open`.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct OverlayPresence {
23    pub present: bool,
24    pub interactive: bool,
25}
26
27impl OverlayPresence {
28    pub fn hidden() -> Self {
29        Self {
30            present: false,
31            interactive: false,
32        }
33    }
34
35    pub fn instant(open: bool) -> Self {
36        Self {
37            present: open,
38            interactive: open,
39        }
40    }
41
42    pub fn from_fade(open: bool, presence: PresenceOutput) -> Self {
43        Self {
44            present: presence.present,
45            interactive: open,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum OverlayKind {
52    NonModalDismissible,
53    Modal,
54    Tooltip,
55    Hover,
56    ToastLayer,
57}
58
59#[derive(Debug, Clone)]
60pub struct ToastLayerSpec {
61    pub store: Model<window_overlays::ToastStore>,
62    pub position: window_overlays::ToastPosition,
63    pub style: window_overlays::ToastLayerStyle,
64    pub toaster_id: Option<std::sync::Arc<str>>,
65    pub visible_toasts: usize,
66    pub expand: bool,
67    pub rich_colors: bool,
68    pub invert: bool,
69    pub container_aria_label: Option<std::sync::Arc<str>>,
70    pub custom_aria_label: Option<std::sync::Arc<str>>,
71    pub offset: Option<window_overlays::ToastOffset>,
72    pub mobile_offset: Option<window_overlays::ToastOffset>,
73    pub margin: Option<fret_core::Px>,
74    pub gap: Option<fret_core::Px>,
75    pub toast_min_width: Option<fret_core::Px>,
76    pub toast_max_width: Option<fret_core::Px>,
77}
78
79pub struct OverlayRequest {
80    pub kind: OverlayKind,
81    pub id: GlobalElementId,
82    pub root_name: Option<String>,
83    pub trigger: Option<GlobalElementId>,
84    /// Extra subtrees that should be treated as "inside" for DismissableLayer-style dismissal.
85    ///
86    /// This is used to align Radix `DismissableLayerBranch` outcomes across disjoint subtrees.
87    pub dismissable_branches: Vec<GlobalElementId>,
88    /// When an outside-press observer is dispatched for this overlay, suppress normal hit-tested
89    /// pointer-down dispatch to underlay widgets for the same event.
90    pub consume_outside_pointer_events: bool,
91    /// When true, pointer events outside the overlay subtree should not reach underlay widgets
92    /// while the overlay is open (Radix `disableOutsidePointerEvents` outcome).
93    pub disable_outside_pointer_events: bool,
94    /// Whether this overlay should close when the OS window loses focus.
95    pub close_on_window_focus_lost: bool,
96    /// Whether this overlay should close when the OS window is resized (or scale factor changes).
97    pub close_on_window_resize: bool,
98    pub open: Option<Model<bool>>,
99    pub on_open_auto_focus: Option<OnOpenAutoFocus>,
100    pub on_close_auto_focus: Option<OnCloseAutoFocus>,
101    pub dismissible_on_dismiss_request: Option<OnDismissRequest>,
102    pub dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
103    pub presence: OverlayPresence,
104    pub initial_focus: Option<GlobalElementId>,
105    pub children: Vec<AnyElement>,
106    pub toast_layer: Option<ToastLayerSpec>,
107}
108
109impl std::fmt::Debug for OverlayRequest {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("OverlayRequest")
112            .field("kind", &self.kind)
113            .field("id", &self.id)
114            .field("root_name", &self.root_name)
115            .field("trigger", &self.trigger)
116            .field("dismissable_branches_len", &self.dismissable_branches.len())
117            .field(
118                "consume_outside_pointer_events",
119                &self.consume_outside_pointer_events,
120            )
121            .field(
122                "disable_outside_pointer_events",
123                &self.disable_outside_pointer_events,
124            )
125            .field("open", &self.open)
126            .field("on_open_auto_focus", &self.on_open_auto_focus.is_some())
127            .field("on_close_auto_focus", &self.on_close_auto_focus.is_some())
128            .field(
129                "dismissible_on_dismiss_request",
130                &self.dismissible_on_dismiss_request.is_some(),
131            )
132            .field(
133                "dismissible_on_pointer_move",
134                &self.dismissible_on_pointer_move.is_some(),
135            )
136            .field("presence", &self.presence)
137            .field("initial_focus", &self.initial_focus)
138            .field("children_len", &self.children.len())
139            .field("toast_layer", &self.toast_layer)
140            .finish()
141    }
142}
143
144impl OverlayRequest {
145    pub fn dismissible_popover(
146        id: GlobalElementId,
147        trigger: GlobalElementId,
148        open: Model<bool>,
149        presence: OverlayPresence,
150        children: Vec<AnyElement>,
151    ) -> Self {
152        Self {
153            kind: OverlayKind::NonModalDismissible,
154            id,
155            root_name: None,
156            trigger: Some(trigger),
157            dismissable_branches: Vec::new(),
158            consume_outside_pointer_events: false,
159            disable_outside_pointer_events: false,
160            close_on_window_focus_lost: false,
161            close_on_window_resize: false,
162            open: Some(open),
163            on_open_auto_focus: None,
164            on_close_auto_focus: None,
165            dismissible_on_dismiss_request: None,
166            dismissible_on_pointer_move: None,
167            presence,
168            initial_focus: None,
169            children,
170            toast_layer: None,
171        }
172    }
173
174    /// Dismissible overlay with non-click-through outside press behavior.
175    ///
176    /// This matches Radix-aligned "menu-like" overlays where an outside click closes the overlay
177    /// without activating the underlay (ADR 0069).
178    pub fn dismissible_menu(
179        id: GlobalElementId,
180        trigger: GlobalElementId,
181        open: Model<bool>,
182        presence: OverlayPresence,
183        children: Vec<AnyElement>,
184    ) -> Self {
185        let mut req = Self::dismissible_popover(id, trigger, open, presence, children);
186        req.consume_outside_pointer_events = true;
187        req.disable_outside_pointer_events = true;
188        req
189    }
190
191    pub fn modal(
192        id: GlobalElementId,
193        trigger: Option<GlobalElementId>,
194        open: Model<bool>,
195        presence: OverlayPresence,
196        children: Vec<AnyElement>,
197    ) -> Self {
198        Self {
199            kind: OverlayKind::Modal,
200            id,
201            root_name: None,
202            trigger,
203            dismissable_branches: Vec::new(),
204            consume_outside_pointer_events: false,
205            disable_outside_pointer_events: false,
206            close_on_window_focus_lost: false,
207            close_on_window_resize: false,
208            open: Some(open),
209            on_open_auto_focus: None,
210            on_close_auto_focus: None,
211            dismissible_on_dismiss_request: None,
212            dismissible_on_pointer_move: None,
213            presence,
214            initial_focus: None,
215            children,
216            toast_layer: None,
217        }
218    }
219
220    pub fn tooltip(
221        id: GlobalElementId,
222        open: Model<bool>,
223        presence: OverlayPresence,
224        children: Vec<AnyElement>,
225    ) -> Self {
226        Self {
227            kind: OverlayKind::Tooltip,
228            id,
229            root_name: None,
230            trigger: None,
231            dismissable_branches: Vec::new(),
232            consume_outside_pointer_events: false,
233            disable_outside_pointer_events: false,
234            close_on_window_focus_lost: false,
235            close_on_window_resize: false,
236            open: Some(open),
237            on_open_auto_focus: None,
238            on_close_auto_focus: None,
239            dismissible_on_dismiss_request: None,
240            dismissible_on_pointer_move: None,
241            presence,
242            initial_focus: None,
243            children,
244            toast_layer: None,
245        }
246    }
247
248    pub fn hover(
249        id: GlobalElementId,
250        trigger: GlobalElementId,
251        open: Model<bool>,
252        presence: OverlayPresence,
253        children: impl IntoIterator<Item = AnyElement>,
254    ) -> Self {
255        Self {
256            kind: OverlayKind::Hover,
257            id,
258            root_name: None,
259            trigger: Some(trigger),
260            dismissable_branches: Vec::new(),
261            consume_outside_pointer_events: false,
262            disable_outside_pointer_events: false,
263            close_on_window_focus_lost: false,
264            close_on_window_resize: false,
265            open: Some(open),
266            on_open_auto_focus: None,
267            on_close_auto_focus: None,
268            dismissible_on_dismiss_request: None,
269            dismissible_on_pointer_move: None,
270            presence,
271            initial_focus: None,
272            children: children.into_iter().collect(),
273            toast_layer: None,
274        }
275    }
276
277    pub fn toast_layer(id: GlobalElementId, store: Model<window_overlays::ToastStore>) -> Self {
278        Self {
279            kind: OverlayKind::ToastLayer,
280            id,
281            root_name: None,
282            trigger: None,
283            dismissable_branches: Vec::new(),
284            consume_outside_pointer_events: false,
285            disable_outside_pointer_events: false,
286            close_on_window_focus_lost: false,
287            close_on_window_resize: false,
288            open: None,
289            on_open_auto_focus: None,
290            on_close_auto_focus: None,
291            dismissible_on_dismiss_request: None,
292            dismissible_on_pointer_move: None,
293            presence: OverlayPresence::hidden(),
294            initial_focus: None,
295            children: Vec::new(),
296            toast_layer: Some(ToastLayerSpec {
297                store,
298                position: window_overlays::ToastPosition::default(),
299                style: window_overlays::ToastLayerStyle::default(),
300                toaster_id: None,
301                visible_toasts: window_overlays::DEFAULT_VISIBLE_TOASTS,
302                expand: false,
303                rich_colors: false,
304                invert: false,
305                container_aria_label: None,
306                custom_aria_label: None,
307                offset: None,
308                mobile_offset: None,
309                margin: None,
310                gap: None,
311                toast_min_width: None,
312                toast_max_width: None,
313            }),
314        }
315    }
316
317    pub fn close_on_window_focus_lost(mut self, close: bool) -> Self {
318        self.close_on_window_focus_lost = close;
319        self
320    }
321
322    pub fn close_on_window_resize(mut self, close: bool) -> Self {
323        self.close_on_window_resize = close;
324        self
325    }
326
327    pub fn toast_position(mut self, position: window_overlays::ToastPosition) -> Self {
328        let spec = self
329            .toast_layer
330            .as_mut()
331            .expect("toast_position requires a ToastLayer request");
332        spec.position = position;
333        self
334    }
335
336    pub fn toast_toaster_id(mut self, id: impl Into<std::sync::Arc<str>>) -> Self {
337        let spec = self
338            .toast_layer
339            .as_mut()
340            .expect("toast_toaster_id requires a ToastLayer request");
341        spec.toaster_id = Some(id.into());
342        self
343    }
344
345    pub fn toast_visible_toasts(mut self, visible_toasts: usize) -> Self {
346        let spec = self
347            .toast_layer
348            .as_mut()
349            .expect("toast_visible_toasts requires a ToastLayer request");
350        spec.visible_toasts = visible_toasts.max(1);
351        self
352    }
353
354    pub fn toast_expand_by_default(mut self, expand: bool) -> Self {
355        let spec = self
356            .toast_layer
357            .as_mut()
358            .expect("toast_expand_by_default requires a ToastLayer request");
359        spec.expand = expand;
360        self
361    }
362
363    pub fn toast_rich_colors(mut self, rich_colors: bool) -> Self {
364        let spec = self
365            .toast_layer
366            .as_mut()
367            .expect("toast_rich_colors requires a ToastLayer request");
368        spec.rich_colors = rich_colors;
369        self
370    }
371
372    pub fn toast_invert(mut self, invert: bool) -> Self {
373        let spec = self
374            .toast_layer
375            .as_mut()
376            .expect("toast_invert requires a ToastLayer request");
377        spec.invert = invert;
378        self
379    }
380
381    pub fn toast_container_aria_label(mut self, label: impl Into<std::sync::Arc<str>>) -> Self {
382        let spec = self
383            .toast_layer
384            .as_mut()
385            .expect("toast_container_aria_label requires a ToastLayer request");
386        spec.container_aria_label = Some(label.into());
387        self
388    }
389
390    pub fn toast_container_aria_label_opt(mut self, label: Option<std::sync::Arc<str>>) -> Self {
391        let spec = self
392            .toast_layer
393            .as_mut()
394            .expect("toast_container_aria_label_opt requires a ToastLayer request");
395        spec.container_aria_label = label;
396        self
397    }
398
399    pub fn toast_custom_aria_label_opt(mut self, label: Option<std::sync::Arc<str>>) -> Self {
400        let spec = self
401            .toast_layer
402            .as_mut()
403            .expect("toast_custom_aria_label_opt requires a ToastLayer request");
404        spec.custom_aria_label = label;
405        self
406    }
407
408    pub fn toast_offset(mut self, offset: window_overlays::ToastOffset) -> Self {
409        let spec = self
410            .toast_layer
411            .as_mut()
412            .expect("toast_offset requires a ToastLayer request");
413        spec.offset = Some(offset);
414        self
415    }
416
417    pub fn toast_offset_opt(mut self, offset: Option<window_overlays::ToastOffset>) -> Self {
418        let spec = self
419            .toast_layer
420            .as_mut()
421            .expect("toast_offset_opt requires a ToastLayer request");
422        spec.offset = offset;
423        self
424    }
425
426    pub fn toast_mobile_offset(mut self, offset: window_overlays::ToastOffset) -> Self {
427        let spec = self
428            .toast_layer
429            .as_mut()
430            .expect("toast_mobile_offset requires a ToastLayer request");
431        spec.mobile_offset = Some(offset);
432        self
433    }
434
435    pub fn toast_mobile_offset_opt(mut self, offset: Option<window_overlays::ToastOffset>) -> Self {
436        let spec = self
437            .toast_layer
438            .as_mut()
439            .expect("toast_mobile_offset_opt requires a ToastLayer request");
440        spec.mobile_offset = offset;
441        self
442    }
443
444    pub fn toast_style(mut self, style: window_overlays::ToastLayerStyle) -> Self {
445        let spec = self
446            .toast_layer
447            .as_mut()
448            .expect("toast_style requires a ToastLayer request");
449        spec.style = style;
450        self
451    }
452
453    pub fn toast_margin(mut self, margin: fret_core::Px) -> Self {
454        let spec = self
455            .toast_layer
456            .as_mut()
457            .expect("toast_margin requires a ToastLayer request");
458        spec.margin = Some(margin);
459        self
460    }
461
462    pub fn toast_gap(mut self, gap: fret_core::Px) -> Self {
463        let spec = self
464            .toast_layer
465            .as_mut()
466            .expect("toast_gap requires a ToastLayer request");
467        spec.gap = Some(gap);
468        self
469    }
470
471    pub fn toast_min_width(mut self, width: fret_core::Px) -> Self {
472        let spec = self
473            .toast_layer
474            .as_mut()
475            .expect("toast_min_width requires a ToastLayer request");
476        spec.toast_min_width = Some(width);
477        self
478    }
479
480    pub fn toast_max_width(mut self, width: fret_core::Px) -> Self {
481        let spec = self
482            .toast_layer
483            .as_mut()
484            .expect("toast_max_width requires a ToastLayer request");
485        spec.toast_max_width = Some(width);
486        self
487    }
488
489    pub fn dismissable_branches(
490        mut self,
491        branches: impl IntoIterator<Item = GlobalElementId>,
492    ) -> Self {
493        self.dismissable_branches = branches.into_iter().collect();
494        self
495    }
496
497    pub fn add_dismissable_branch(mut self, branch: GlobalElementId) -> Self {
498        if !self.dismissable_branches.contains(&branch) {
499            self.dismissable_branches.push(branch);
500        }
501        self
502    }
503
504    pub fn extend_dismissable_branches(
505        mut self,
506        branches: impl IntoIterator<Item = GlobalElementId>,
507    ) -> Self {
508        for branch in branches {
509            if !self.dismissable_branches.contains(&branch) {
510                self.dismissable_branches.push(branch);
511            }
512        }
513        self
514    }
515
516    pub fn consume_outside_pointer_events(mut self, consume: bool) -> Self {
517        self.consume_outside_pointer_events = consume;
518        self
519    }
520
521    pub fn disable_outside_pointer_events(mut self, disable: bool) -> Self {
522        self.disable_outside_pointer_events = disable;
523        self
524    }
525}
526
527/// A small, stable facade over `window_overlays` to keep overlay policy wiring out of shadcn code.
528pub struct OverlayController;
529
530/// Snapshot of overlay-related input arbitration state for a single `UiTree`.
531///
532/// This is intended for ecosystem integration points (docking, viewport tooling, policies) that
533/// need a stable way to reason about "what input gating is currently active" without depending on
534/// `window_overlays` internals.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
536pub struct OverlayArbitrationSnapshot {
537    /// Whether any non-base overlay layers are visible.
538    pub has_any_overlays: bool,
539    /// Whether a modal barrier is currently active (`blocks_underlay_input=true` on a visible layer).
540    pub modal_barrier_active: bool,
541    /// Effective pointer occlusion outcome (Radix `disableOutsidePointerEvents` style gating).
542    ///
543    /// When `modal_barrier_active=true`, this is always `PointerOcclusion::None` since the modal
544    /// barrier already blocks underlay pointer routing.
545    pub pointer_occlusion: fret_ui::tree::PointerOcclusion,
546    /// Whether any pointer is currently captured by the runtime.
547    pub pointer_capture_active: bool,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum OverlayStackEntryKind {
552    Base,
553    Popover,
554    Modal,
555    Tooltip,
556    Hover,
557    ToastLayer,
558    Unknown,
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub struct WindowOverlayStackEntry {
563    pub kind: OverlayStackEntryKind,
564    pub id: Option<GlobalElementId>,
565    pub open: bool,
566    pub visible: bool,
567    pub blocks_underlay_input: bool,
568    pub hit_testable: bool,
569    pub pointer_occlusion: fret_ui::tree::PointerOcclusion,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct WindowOverlayStackSnapshot {
574    pub arbitration: OverlayArbitrationSnapshot,
575    pub stack: Vec<WindowOverlayStackEntry>,
576    pub topmost_overlay: Option<GlobalElementId>,
577    pub topmost_popover: Option<GlobalElementId>,
578    pub topmost_modal: Option<GlobalElementId>,
579    pub topmost_pointer_occluding_overlay: Option<GlobalElementId>,
580}
581
582impl OverlayController {
583    pub fn begin_frame<H: UiHost>(app: &mut H, window: AppWindowId) {
584        window_overlays::begin_frame(app, window);
585    }
586
587    pub fn last_known_window_bounds<H: UiHost>(app: &mut H, window: AppWindowId) -> Option<Rect> {
588        window_overlays::last_known_window_bounds_for_window(app, window)
589    }
590
591    pub fn popover_root_name(id: GlobalElementId) -> String {
592        window_overlays::popover_root_name(id)
593    }
594
595    pub fn modal_root_name(id: GlobalElementId) -> String {
596        window_overlays::modal_root_name(id)
597    }
598
599    pub fn tooltip_root_name(id: GlobalElementId) -> String {
600        window_overlays::tooltip_root_name(id)
601    }
602
603    pub fn hover_overlay_root_name(id: GlobalElementId) -> String {
604        window_overlays::hover_overlay_root_name(id)
605    }
606
607    pub fn toast_layer_root_name(id: GlobalElementId) -> String {
608        window_overlays::toast_layer_root_name(id)
609    }
610
611    pub fn request<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
612        Self::request_for_window_with_owner(cx.app, cx.window, request, Some(cx.root_id()));
613    }
614
615    pub fn request_for_window<H: UiHost>(
616        app: &mut H,
617        window: AppWindowId,
618        request: OverlayRequest,
619    ) {
620        Self::request_for_window_with_owner(app, window, request, None);
621    }
622
623    fn request_for_window_with_owner<H: UiHost>(
624        app: &mut H,
625        window: AppWindowId,
626        request: OverlayRequest,
627        owner: Option<GlobalElementId>,
628    ) {
629        match request.kind {
630            OverlayKind::NonModalDismissible => {
631                let open = request
632                    .open
633                    .expect("NonModalDismissible requires open model");
634                let trigger = request
635                    .trigger
636                    .expect("NonModalDismissible requires trigger");
637                let root_name = request
638                    .root_name
639                    .unwrap_or_else(|| window_overlays::popover_root_name(request.id));
640                let req = window_overlays::DismissiblePopoverRequest {
641                    id: request.id,
642                    root_name,
643                    trigger,
644                    dismissable_branches: request.dismissable_branches,
645                    consume_outside_pointer_events: request.consume_outside_pointer_events,
646                    disable_outside_pointer_events: request.disable_outside_pointer_events,
647                    close_on_window_focus_lost: request.close_on_window_focus_lost,
648                    close_on_window_resize: request.close_on_window_resize,
649                    open,
650                    present: request.presence.present,
651                    initial_focus: request.initial_focus,
652                    on_open_auto_focus: request.on_open_auto_focus,
653                    on_close_auto_focus: request.on_close_auto_focus,
654                    on_dismiss_request: request.dismissible_on_dismiss_request,
655                    on_pointer_move: request.dismissible_on_pointer_move,
656                    children: request.children,
657                };
658                window_overlays::request_dismissible_popover_for_window_owned(
659                    app, window, req, owner,
660                );
661            }
662            OverlayKind::Modal => {
663                let open = request.open.expect("Modal requires open model");
664                let root_name = request
665                    .root_name
666                    .unwrap_or_else(|| window_overlays::modal_root_name(request.id));
667                let req = window_overlays::ModalRequest {
668                    id: request.id,
669                    root_name,
670                    trigger: request.trigger,
671                    close_on_window_focus_lost: request.close_on_window_focus_lost,
672                    close_on_window_resize: request.close_on_window_resize,
673                    open,
674                    present: request.presence.present,
675                    initial_focus: request.initial_focus,
676                    on_open_auto_focus: request.on_open_auto_focus,
677                    on_close_auto_focus: request.on_close_auto_focus,
678                    on_dismiss_request: request.dismissible_on_dismiss_request,
679                    children: request.children,
680                };
681                window_overlays::request_modal_for_window_owned(app, window, req, owner);
682            }
683            OverlayKind::Tooltip => {
684                let open = request.open.expect("Tooltip requires open model");
685                if !request.presence.present {
686                    return;
687                }
688                let root_name = request
689                    .root_name
690                    .unwrap_or_else(|| window_overlays::tooltip_root_name(request.id));
691                let req = window_overlays::TooltipRequest {
692                    id: request.id,
693                    root_name,
694                    interactive: request.presence.interactive,
695                    trigger: request.trigger,
696                    open,
697                    present: request.presence.present,
698                    on_dismiss_request: request.dismissible_on_dismiss_request,
699                    on_pointer_move: request.dismissible_on_pointer_move,
700                    children: request.children,
701                };
702                window_overlays::request_tooltip_for_window_owned(app, window, req, owner);
703            }
704            OverlayKind::Hover => {
705                let open = request.open.expect("Hover requires open model");
706                if !request.presence.present {
707                    return;
708                }
709                let trigger = request.trigger.expect("Hover requires trigger");
710                let root_name = request
711                    .root_name
712                    .unwrap_or_else(|| window_overlays::hover_overlay_root_name(request.id));
713                let req = window_overlays::HoverOverlayRequest {
714                    id: request.id,
715                    root_name,
716                    interactive: request.presence.interactive,
717                    trigger,
718                    open,
719                    present: request.presence.present,
720                    on_pointer_move: request.dismissible_on_pointer_move,
721                    children: request.children,
722                };
723                window_overlays::request_hover_overlay_for_window_owned(app, window, req, owner);
724            }
725            OverlayKind::ToastLayer => {
726                let spec = request
727                    .toast_layer
728                    .expect("ToastLayer requires toast_layer spec");
729                let root_name = request
730                    .root_name
731                    .unwrap_or_else(|| window_overlays::toast_layer_root_name(request.id));
732
733                let mut toast_req = window_overlays::ToastLayerRequest::new(request.id, spec.store)
734                    .position(spec.position)
735                    .style(spec.style)
736                    .toaster_id_opt(spec.toaster_id)
737                    .visible_toasts(spec.visible_toasts)
738                    .expand_by_default(spec.expand)
739                    .rich_colors(spec.rich_colors)
740                    .invert(spec.invert)
741                    .container_aria_label_opt(spec.container_aria_label)
742                    .custom_aria_label_opt(spec.custom_aria_label)
743                    .root_name(root_name);
744                if let Some(offset) = spec.offset {
745                    toast_req = toast_req.offset(offset);
746                }
747                if let Some(offset) = spec.mobile_offset {
748                    toast_req = toast_req.mobile_offset(offset);
749                }
750                if let Some(margin) = spec.margin {
751                    toast_req = toast_req.margin(margin);
752                }
753                if let Some(gap) = spec.gap {
754                    toast_req = toast_req.gap(gap);
755                }
756                if let Some(width) = spec.toast_min_width {
757                    toast_req = toast_req.toast_min_width(width);
758                }
759                if let Some(width) = spec.toast_max_width {
760                    toast_req = toast_req.toast_max_width(width);
761                }
762                window_overlays::request_toast_layer_for_window_owned(
763                    app, window, toast_req, owner,
764                );
765            }
766        }
767    }
768
769    pub fn render<H: UiHost + 'static>(
770        ui: &mut UiTree<H>,
771        app: &mut H,
772        services: &mut dyn fret_core::UiServices,
773        window: AppWindowId,
774        bounds: Rect,
775    ) {
776        window_overlays::render(ui, app, services, window, bounds);
777    }
778
779    /// Computes a stable snapshot of overlay-related input arbitration state from the runtime
780    /// layer stack.
781    ///
782    /// Recommended usage:
783    /// - call after `OverlayController::render(...)` (so the layer stack reflects current overlay state),
784    /// - use the snapshot to drive cross-system policies (e.g. docking/viewport suppression, diagnostics).
785    pub fn arbitration_snapshot<H: UiHost>(ui: &UiTree<H>) -> OverlayArbitrationSnapshot {
786        use fret_ui::tree::PointerOcclusion;
787
788        let runtime = ui.input_arbitration_snapshot();
789        let base_root = ui.base_root();
790        let layers = ui.debug_layers_in_paint_order();
791
792        let modal_barrier_active = runtime.modal_barrier_root.is_some();
793        OverlayArbitrationSnapshot {
794            has_any_overlays: layers
795                .iter()
796                .any(|l| l.visible && base_root.is_none_or(|base| l.root != base)),
797            modal_barrier_active,
798            pointer_capture_active: runtime.pointer_capture_active,
799            pointer_occlusion: if modal_barrier_active {
800                PointerOcclusion::None
801            } else {
802                runtime.pointer_occlusion
803            },
804        }
805    }
806
807    /// Computes an ordered, window-scoped overlay stack snapshot by combining:
808    ///
809    /// - runtime layer order (`UiTree::debug_layers_in_paint_order`),
810    /// - overlay manager state (`window_overlays`) to map layer IDs to overlay IDs/kinds.
811    ///
812    /// The intent is to give ecosystem integration points a stable "what overlays are currently
813    /// active, and in what order" view without requiring them to depend on `window_overlays`
814    /// internals.
815    pub fn stack_snapshot_for_window<H: UiHost>(
816        ui: &UiTree<H>,
817        app: &mut H,
818        window: AppWindowId,
819    ) -> WindowOverlayStackSnapshot {
820        use fret_ui::tree::PointerOcclusion;
821        use std::collections::HashMap;
822
823        let arbitration = Self::arbitration_snapshot(ui);
824        let base_root = ui.base_root();
825        let layers = ui.debug_layers_in_paint_order();
826
827        let mut by_layer = HashMap::new();
828        for entry in window_overlays::overlay_layer_entries_for_window(app, window) {
829            let kind = match entry.kind {
830                window_overlays::WindowOverlayLayerKind::Popover => OverlayStackEntryKind::Popover,
831                window_overlays::WindowOverlayLayerKind::Modal => OverlayStackEntryKind::Modal,
832                window_overlays::WindowOverlayLayerKind::Hover => OverlayStackEntryKind::Hover,
833                window_overlays::WindowOverlayLayerKind::Tooltip => OverlayStackEntryKind::Tooltip,
834                window_overlays::WindowOverlayLayerKind::ToastLayer => {
835                    OverlayStackEntryKind::ToastLayer
836                }
837            };
838            by_layer.insert(entry.layer, (kind, entry.id, entry.open));
839        }
840
841        let mut stack: Vec<WindowOverlayStackEntry> = Vec::with_capacity(layers.len());
842        for layer in layers {
843            let (kind, id, open) = if base_root == Some(layer.root) {
844                (OverlayStackEntryKind::Base, None, false)
845            } else if let Some((kind, id, open)) = by_layer.get(&layer.id).copied() {
846                (kind, Some(id), open)
847            } else {
848                (OverlayStackEntryKind::Unknown, None, false)
849            };
850            stack.push(WindowOverlayStackEntry {
851                kind,
852                id,
853                open,
854                visible: layer.visible,
855                blocks_underlay_input: layer.blocks_underlay_input,
856                hit_testable: layer.hit_testable,
857                pointer_occlusion: layer.pointer_occlusion,
858            });
859        }
860
861        let topmost_overlay = stack
862            .iter()
863            .rev()
864            .find_map(|e| (e.visible && e.id.is_some()).then_some(e.id))
865            .flatten();
866        let topmost_popover = stack
867            .iter()
868            .rev()
869            .find_map(|e| {
870                (e.visible && e.open && e.kind == OverlayStackEntryKind::Popover).then_some(e.id)
871            })
872            .flatten();
873        let topmost_modal = stack
874            .iter()
875            .rev()
876            .find_map(|e| (e.visible && e.kind == OverlayStackEntryKind::Modal).then_some(e.id))
877            .flatten();
878        let topmost_pointer_occluding_overlay = stack
879            .iter()
880            .rev()
881            .find_map(|e| {
882                (e.visible && e.pointer_occlusion != PointerOcclusion::None).then_some(e.id)
883            })
884            .flatten();
885
886        WindowOverlayStackSnapshot {
887            arbitration,
888            stack,
889            topmost_overlay,
890            topmost_popover,
891            topmost_modal,
892            topmost_pointer_occluding_overlay,
893        }
894    }
895
896    pub fn fade_presence<H: UiHost>(
897        cx: &mut ElementContext<'_, H>,
898        open: bool,
899        fade_ticks: u64,
900    ) -> PresenceOutput {
901        presence::fade_presence(cx, open, fade_ticks)
902    }
903
904    pub fn fade_presence_with_durations<H: UiHost>(
905        cx: &mut ElementContext<'_, H>,
906        open: bool,
907        open_ticks: u64,
908        close_ticks: u64,
909    ) -> PresenceOutput {
910        presence::fade_presence_with_durations(cx, open, open_ticks, close_ticks)
911    }
912
913    /// Drive a general transition timeline using the UI runtime's monotonic frame clock.
914    ///
915    /// This is the generalized form of `fade_presence*` and is useful for driving multiple
916    /// properties (opacity/scale/translation) with a shared open/close timeline.
917    #[track_caller]
918    pub fn transition<H: UiHost>(
919        cx: &mut ElementContext<'_, H>,
920        open: bool,
921        ticks: u64,
922    ) -> TransitionOutput {
923        crate::declarative::transition::drive_transition(cx, open, ticks)
924    }
925
926    /// Drive a general transition timeline with separate open/close durations.
927    #[track_caller]
928    pub fn transition_with_durations<H: UiHost>(
929        cx: &mut ElementContext<'_, H>,
930        open: bool,
931        open_ticks: u64,
932        close_ticks: u64,
933    ) -> TransitionOutput {
934        crate::declarative::transition::drive_transition_with_durations(
935            cx,
936            open,
937            open_ticks,
938            close_ticks,
939        )
940    }
941
942    /// Drive a general transition timeline with separate open/close wall-clock durations.
943    #[track_caller]
944    pub fn transition_with_durations_duration<H: UiHost>(
945        cx: &mut ElementContext<'_, H>,
946        open: bool,
947        open_duration: Duration,
948        close_duration: Duration,
949    ) -> TransitionOutput {
950        crate::declarative::transition::drive_transition_with_durations_duration(
951            cx,
952            open,
953            open_duration,
954            close_duration,
955        )
956    }
957
958    /// Drive a transition timeline with an explicit easing function.
959    ///
960    /// This enables CSS-style easing (e.g. cubic-bezier) while staying deterministic and
961    /// renderer-agnostic.
962    #[track_caller]
963    pub fn transition_with_durations_and_easing<H: UiHost>(
964        cx: &mut ElementContext<'_, H>,
965        open: bool,
966        open_ticks: u64,
967        close_ticks: u64,
968        ease: fn(f32) -> f32,
969    ) -> TransitionOutput {
970        crate::declarative::transition::drive_transition_with_durations_and_easing(
971            cx,
972            open,
973            open_ticks,
974            close_ticks,
975            ease,
976        )
977    }
978
979    /// Drive a transition timeline with an explicit easing function using wall-clock durations.
980    #[track_caller]
981    pub fn transition_with_durations_and_easing_duration<H: UiHost>(
982        cx: &mut ElementContext<'_, H>,
983        open: bool,
984        open_duration: Duration,
985        close_duration: Duration,
986        ease: fn(f32) -> f32,
987    ) -> TransitionOutput {
988        crate::declarative::transition::drive_transition_with_durations_and_easing_duration(
989            cx,
990            open,
991            open_duration,
992            close_duration,
993            ease,
994        )
995    }
996
997    #[track_caller]
998    pub fn transition_with_durations_and_cubic_bezier<H: UiHost>(
999        cx: &mut ElementContext<'_, H>,
1000        open: bool,
1001        open_ticks: u64,
1002        close_ticks: u64,
1003        bezier: fret_ui::theme::CubicBezier,
1004    ) -> TransitionOutput {
1005        crate::declarative::transition::drive_transition_with_durations_and_cubic_bezier(
1006            cx,
1007            open,
1008            open_ticks,
1009            close_ticks,
1010            bezier,
1011        )
1012    }
1013
1014    #[track_caller]
1015    pub fn transition_with_durations_and_cubic_bezier_duration<H: UiHost>(
1016        cx: &mut ElementContext<'_, H>,
1017        open: bool,
1018        open_duration: Duration,
1019        close_duration: Duration,
1020        bezier: fret_ui::theme::CubicBezier,
1021    ) -> TransitionOutput {
1022        crate::declarative::transition::drive_transition_with_durations_and_cubic_bezier_duration(
1023            cx,
1024            open,
1025            open_duration,
1026            close_duration,
1027            bezier,
1028        )
1029    }
1030
1031    pub fn toast_store<H: UiHost>(app: &mut H) -> Model<window_overlays::ToastStore> {
1032        window_overlays::toast_store(app)
1033    }
1034
1035    pub fn toast_action(
1036        host: &mut dyn fret_ui::action::UiActionHost,
1037        store: Model<window_overlays::ToastStore>,
1038        window: AppWindowId,
1039        request: window_overlays::ToastRequest,
1040    ) -> window_overlays::ToastId {
1041        window_overlays::toast_action(host, store, window, request)
1042    }
1043
1044    pub fn dismiss_toast_action(
1045        host: &mut dyn fret_ui::action::UiActionHost,
1046        store: Model<window_overlays::ToastStore>,
1047        window: AppWindowId,
1048        id: window_overlays::ToastId,
1049    ) -> bool {
1050        window_overlays::dismiss_toast_action(host, store, window, id)
1051    }
1052
1053    pub fn dismiss_all_toasts_action(
1054        host: &mut dyn fret_ui::action::UiActionHost,
1055        store: Model<window_overlays::ToastStore>,
1056        window: AppWindowId,
1057    ) -> usize {
1058        window_overlays::dismiss_all_toasts_action(host, store, window)
1059    }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065    use fret_app::App;
1066    use fret_core::{
1067        Event, KeyCode, Modifiers, Point, Px, Rect, TextBlobId, TextConstraints, TextInput,
1068        TextMetrics, TextService,
1069    };
1070    use fret_core::{PathCommand, SvgId, SvgService};
1071    use fret_core::{PathConstraints, PathId, PathMetrics, PathService, PathStyle};
1072    use fret_runtime::CommandId;
1073    use fret_runtime::Effect;
1074    use fret_runtime::{FrameId, TickId};
1075    use fret_ui::element::{LayoutStyle, Length, PointerRegionProps, PressableProps};
1076    use std::sync::Arc;
1077
1078    #[derive(Default)]
1079    struct FakeServices;
1080
1081    impl TextService for FakeServices {
1082        fn prepare(
1083            &mut self,
1084            _input: &TextInput,
1085            _constraints: TextConstraints,
1086        ) -> (TextBlobId, TextMetrics) {
1087            (
1088                TextBlobId::default(),
1089                TextMetrics {
1090                    size: fret_core::Size::new(Px(0.0), Px(0.0)),
1091                    baseline: Px(0.0),
1092                },
1093            )
1094        }
1095
1096        fn release(&mut self, _blob: TextBlobId) {}
1097    }
1098
1099    impl PathService for FakeServices {
1100        fn prepare(
1101            &mut self,
1102            _commands: &[PathCommand],
1103            _style: PathStyle,
1104            _constraints: PathConstraints,
1105        ) -> (PathId, PathMetrics) {
1106            (PathId::default(), PathMetrics::default())
1107        }
1108
1109        fn release(&mut self, _path: PathId) {}
1110    }
1111
1112    #[test]
1113    fn arbitration_snapshot_reports_modal_and_pointer_occlusion() {
1114        let window = AppWindowId::default();
1115        let mut app = App::new();
1116        let mut ui: UiTree<App> = UiTree::new();
1117        ui.set_window(window);
1118
1119        let mut services = FakeServices;
1120        let bounds = Rect::new(
1121            Point::new(Px(0.0), Px(0.0)),
1122            fret_core::Size::new(Px(300.0), Px(200.0)),
1123        );
1124
1125        OverlayController::begin_frame(&mut app, window);
1126        let base = fret_ui::declarative::render_root(
1127            &mut ui,
1128            &mut app,
1129            &mut services,
1130            window,
1131            bounds,
1132            "base",
1133            |_| Vec::new(),
1134        );
1135        ui.set_root(base);
1136        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1137
1138        let snap = OverlayController::arbitration_snapshot(&ui);
1139        assert_eq!(
1140            snap,
1141            OverlayArbitrationSnapshot {
1142                has_any_overlays: false,
1143                modal_barrier_active: false,
1144                pointer_occlusion: fret_ui::tree::PointerOcclusion::None,
1145                pointer_capture_active: false,
1146            }
1147        );
1148
1149        // Add a non-modal overlay with pointer occlusion.
1150        OverlayController::begin_frame(&mut app, window);
1151        let overlay = fret_ui::declarative::render_root(
1152            &mut ui,
1153            &mut app,
1154            &mut services,
1155            window,
1156            bounds,
1157            "overlay",
1158            |_| Vec::new(),
1159        );
1160        let overlay_layer = ui.push_overlay_root(overlay, false);
1161        ui.set_layer_pointer_occlusion(
1162            overlay_layer,
1163            fret_ui::tree::PointerOcclusion::BlockMouseExceptScroll,
1164        );
1165        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1166
1167        let snap = OverlayController::arbitration_snapshot(&ui);
1168        assert!(snap.has_any_overlays);
1169        assert!(!snap.modal_barrier_active);
1170        assert_eq!(
1171            snap.pointer_occlusion,
1172            fret_ui::tree::PointerOcclusion::BlockMouseExceptScroll
1173        );
1174
1175        // Add a modal barrier; it should override occlusion in the snapshot.
1176        OverlayController::begin_frame(&mut app, window);
1177        let modal = fret_ui::declarative::render_root(
1178            &mut ui,
1179            &mut app,
1180            &mut services,
1181            window,
1182            bounds,
1183            "modal",
1184            |_| Vec::new(),
1185        );
1186        let _modal_layer = ui.push_overlay_root(modal, true);
1187        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1188
1189        let snap = OverlayController::arbitration_snapshot(&ui);
1190        assert!(snap.has_any_overlays);
1191        assert!(snap.modal_barrier_active);
1192        assert_eq!(
1193            snap.pointer_occlusion,
1194            fret_ui::tree::PointerOcclusion::None
1195        );
1196    }
1197
1198    #[test]
1199    fn arbitration_snapshot_reports_pointer_capture_active() {
1200        let window = AppWindowId::default();
1201        let mut app = App::new();
1202        let mut ui: UiTree<App> = UiTree::new();
1203        ui.set_window(window);
1204
1205        let mut services = FakeServices;
1206        let bounds = Rect::new(
1207            Point::new(Px(0.0), Px(0.0)),
1208            fret_core::Size::new(Px(300.0), Px(200.0)),
1209        );
1210
1211        OverlayController::begin_frame(&mut app, window);
1212        let base = fret_ui::declarative::render_root(
1213            &mut ui,
1214            &mut app,
1215            &mut services,
1216            window,
1217            bounds,
1218            "base",
1219            |cx| {
1220                vec![cx.pointer_region(
1221                    PointerRegionProps {
1222                        layout: {
1223                            let mut layout = LayoutStyle::default();
1224                            layout.size.width = Length::Fill;
1225                            layout.size.height = Length::Fill;
1226                            layout
1227                        },
1228                        enabled: true,
1229                        ..Default::default()
1230                    },
1231                    |cx| {
1232                        cx.pointer_region_on_pointer_down(Arc::new(move |host, _cx, _down| {
1233                            host.capture_pointer();
1234                            true
1235                        }));
1236                        Vec::new()
1237                    },
1238                )]
1239            },
1240        );
1241        ui.set_root(base);
1242        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1243
1244        ui.dispatch_event(
1245            &mut app,
1246            &mut services,
1247            &Event::Pointer(fret_core::PointerEvent::Down {
1248                position: Point::new(Px(10.0), Px(10.0)),
1249                button: fret_core::MouseButton::Left,
1250                modifiers: fret_core::Modifiers::default(),
1251                click_count: 1,
1252                pointer_id: fret_core::PointerId(0),
1253                pointer_type: fret_core::PointerType::Mouse,
1254            }),
1255        );
1256
1257        let snap = OverlayController::arbitration_snapshot(&ui);
1258        assert!(!snap.has_any_overlays);
1259        assert!(!snap.modal_barrier_active);
1260        assert_eq!(
1261            snap.pointer_occlusion,
1262            fret_ui::tree::PointerOcclusion::None
1263        );
1264        assert!(snap.pointer_capture_active);
1265    }
1266
1267    #[test]
1268    fn stack_snapshot_reports_topmost_popover_and_modal_in_paint_order() {
1269        let window = AppWindowId::default();
1270        let mut app = App::new();
1271        let mut ui: UiTree<App> = UiTree::new();
1272        ui.set_window(window);
1273
1274        let popover_open = app.models_mut().insert(true);
1275        let modal_open = app.models_mut().insert(true);
1276
1277        let mut services = FakeServices;
1278        let bounds = Rect::new(
1279            Point::new(Px(0.0), Px(0.0)),
1280            fret_core::Size::new(Px(300.0), Px(200.0)),
1281        );
1282
1283        let mut trigger_id: Option<GlobalElementId> = None;
1284
1285        // Frame 0: base only.
1286        OverlayController::begin_frame(&mut app, window);
1287        let base = fret_ui::declarative::render_root(
1288            &mut ui,
1289            &mut app,
1290            &mut services,
1291            window,
1292            bounds,
1293            "base",
1294            |cx| {
1295                vec![cx.pressable_with_id(
1296                    PressableProps {
1297                        layout: {
1298                            let mut layout = LayoutStyle::default();
1299                            layout.size.width = Length::Px(Px(80.0));
1300                            layout.size.height = Length::Px(Px(32.0));
1301                            layout
1302                        },
1303                        ..Default::default()
1304                    },
1305                    |_cx, _st, id| {
1306                        trigger_id = Some(id);
1307                        Vec::new()
1308                    },
1309                )]
1310            },
1311        );
1312        ui.set_root(base);
1313        OverlayController::render(&mut ui, &mut app, &mut services, window, bounds);
1314        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1315
1316        let trigger_id = trigger_id.expect("trigger id");
1317
1318        // Frame 1: popover opened.
1319        OverlayController::begin_frame(&mut app, window);
1320        let popover_id = GlobalElementId(0xabc);
1321        OverlayController::request_for_window(
1322            &mut app,
1323            window,
1324            OverlayRequest::dismissible_menu(
1325                popover_id,
1326                trigger_id,
1327                popover_open.clone(),
1328                OverlayPresence::instant(true),
1329                Vec::new(),
1330            ),
1331        );
1332        OverlayController::render(&mut ui, &mut app, &mut services, window, bounds);
1333        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1334
1335        let snap = OverlayController::stack_snapshot_for_window(&ui, &mut app, window);
1336        assert_eq!(snap.topmost_popover, Some(popover_id));
1337        assert_eq!(snap.topmost_modal, None);
1338        assert_eq!(snap.topmost_overlay, Some(popover_id));
1339        assert_eq!(
1340            snap.stack.last().map(|e| (e.kind, e.id)),
1341            Some((OverlayStackEntryKind::Popover, Some(popover_id)))
1342        );
1343
1344        // Frame 2: modal opened above the popover.
1345        OverlayController::begin_frame(&mut app, window);
1346        let modal_id = GlobalElementId(0xdef);
1347        OverlayController::request_for_window(
1348            &mut app,
1349            window,
1350            OverlayRequest::dismissible_menu(
1351                popover_id,
1352                trigger_id,
1353                popover_open.clone(),
1354                OverlayPresence::instant(true),
1355                Vec::new(),
1356            ),
1357        );
1358        OverlayController::request_for_window(
1359            &mut app,
1360            window,
1361            OverlayRequest::modal(
1362                modal_id,
1363                Some(trigger_id),
1364                modal_open.clone(),
1365                OverlayPresence::instant(true),
1366                Vec::new(),
1367            ),
1368        );
1369        OverlayController::render(&mut ui, &mut app, &mut services, window, bounds);
1370        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1371
1372        let snap = OverlayController::stack_snapshot_for_window(&ui, &mut app, window);
1373        assert_eq!(snap.topmost_modal, Some(modal_id));
1374        assert_eq!(snap.topmost_overlay, Some(modal_id));
1375        assert!(
1376            snap.stack
1377                .iter()
1378                .any(|e| e.kind == OverlayStackEntryKind::Popover && e.id == Some(popover_id)),
1379            "expected popover layer to still be identifiable in the stack snapshot even if it closes on modal open"
1380        );
1381        assert_eq!(
1382            snap.stack.last().map(|e| (e.kind, e.id)),
1383            Some((OverlayStackEntryKind::Modal, Some(modal_id)))
1384        );
1385    }
1386
1387    impl SvgService for FakeServices {
1388        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
1389            SvgId::default()
1390        }
1391
1392        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
1393            true
1394        }
1395    }
1396
1397    impl fret_core::MaterialService for FakeServices {
1398        fn register_material(
1399            &mut self,
1400            _desc: fret_core::MaterialDescriptor,
1401        ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
1402            Err(fret_core::MaterialRegistrationError::Unsupported)
1403        }
1404
1405        fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
1406            true
1407        }
1408    }
1409
1410    fn dispatch_keydown_and_apply_commands(
1411        ui: &mut UiTree<App>,
1412        app: &mut App,
1413        services: &mut dyn fret_core::UiServices,
1414        key: KeyCode,
1415        modifiers: Modifiers,
1416    ) {
1417        ui.dispatch_event(
1418            app,
1419            services,
1420            &Event::KeyDown {
1421                key,
1422                modifiers,
1423                repeat: false,
1424            },
1425        );
1426
1427        for effect in app.flush_effects() {
1428            let Effect::Command { command, .. } = effect else {
1429                continue;
1430            };
1431            let _ = ui.dispatch_command(app, services, &command);
1432        }
1433    }
1434
1435    #[test]
1436    fn toast_layer_request_enables_timer_events_when_toasts_exist() {
1437        let window = AppWindowId::default();
1438        let mut app = App::new();
1439        let mut ui: UiTree<App> = UiTree::new();
1440        ui.set_window(window);
1441
1442        let mut services = FakeServices;
1443        let bounds = Rect::new(
1444            Point::new(Px(0.0), Px(0.0)),
1445            fret_core::Size::new(Px(300.0), Px(200.0)),
1446        );
1447
1448        // Base root.
1449        OverlayController::begin_frame(&mut app, window);
1450        let base = fret_ui::declarative::render_root(
1451            &mut ui,
1452            &mut app,
1453            &mut services,
1454            window,
1455            bounds,
1456            "base",
1457            |_| Vec::new(),
1458        );
1459        ui.set_root(base);
1460
1461        // Add a toast entry.
1462        let store = OverlayController::toast_store(&mut app);
1463        let _ = OverlayController::toast_action(
1464            &mut fret_ui::action::UiActionHostAdapter { app: &mut app },
1465            store.clone(),
1466            window,
1467            window_overlays::ToastRequest::new("Hello"),
1468        );
1469
1470        // Request toast layer through the controller and render.
1471        OverlayController::begin_frame(&mut app, window);
1472        OverlayController::request_for_window(
1473            &mut app,
1474            window,
1475            OverlayRequest::toast_layer(GlobalElementId(0xbeef), store)
1476                .toast_position(window_overlays::ToastPosition::BottomRight),
1477        );
1478        OverlayController::render(&mut ui, &mut app, &mut services, window, bounds);
1479        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1480
1481        let layers = ui.debug_layers_in_paint_order();
1482        assert!(
1483            layers.iter().any(|l| l.wants_timer_events),
1484            "expected at least one layer to request timer events when toasts exist"
1485        );
1486    }
1487
1488    #[test]
1489    fn modal_focus_traversal_is_scoped_to_modal_layer() {
1490        let window = AppWindowId::default();
1491        let mut app = App::new();
1492        let mut ui: UiTree<App> = UiTree::new();
1493        ui.set_window(window);
1494
1495        let mut services = FakeServices;
1496        let bounds = Rect::new(
1497            Point::new(Px(0.0), Px(0.0)),
1498            fret_core::Size::new(Px(300.0), Px(200.0)),
1499        );
1500
1501        let mut underlay_a: Option<GlobalElementId> = None;
1502        let mut underlay_b: Option<GlobalElementId> = None;
1503
1504        // Base root with two focusable pressables (underlay).
1505        OverlayController::begin_frame(&mut app, window);
1506        let base = fret_ui::declarative::render_root(
1507            &mut ui,
1508            &mut app,
1509            &mut services,
1510            window,
1511            bounds,
1512            "base",
1513            |cx| {
1514                vec![
1515                    cx.pressable_with_id(
1516                        PressableProps {
1517                            layout: {
1518                                let mut layout = LayoutStyle::default();
1519                                layout.size.width = Length::Px(Px(80.0));
1520                                layout.size.height = Length::Px(Px(32.0));
1521                                layout
1522                            },
1523                            focusable: true,
1524                            ..Default::default()
1525                        },
1526                        |_cx, _st, id| {
1527                            underlay_a = Some(id);
1528                            Vec::new()
1529                        },
1530                    ),
1531                    cx.pressable_with_id(
1532                        PressableProps {
1533                            layout: {
1534                                let mut layout = LayoutStyle::default();
1535                                layout.size.width = Length::Px(Px(80.0));
1536                                layout.size.height = Length::Px(Px(32.0));
1537                                layout
1538                            },
1539                            focusable: true,
1540                            ..Default::default()
1541                        },
1542                        |_cx, _st, id| {
1543                            underlay_b = Some(id);
1544                            Vec::new()
1545                        },
1546                    ),
1547                ]
1548            },
1549        );
1550        ui.set_root(base);
1551        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1552
1553        let underlay_a = underlay_a.expect("underlay a id");
1554        let underlay_b = underlay_b.expect("underlay b id");
1555        let underlay_a_node =
1556            fret_ui::elements::node_for_element(&mut app, window, underlay_a).expect("underlay a");
1557        let underlay_b_node =
1558            fret_ui::elements::node_for_element(&mut app, window, underlay_b).expect("underlay b");
1559
1560        // Request a modal with two focusable pressables (modal layer).
1561        let open = app.models_mut().insert(true);
1562        let mut modal_a: Option<GlobalElementId> = None;
1563        let mut modal_b: Option<GlobalElementId> = None;
1564
1565        OverlayController::begin_frame(&mut app, window);
1566        let modal_children =
1567            fret_ui::elements::with_element_cx(&mut app, window, bounds, "modal-child", |cx| {
1568                vec![
1569                    cx.pressable_with_id(
1570                        PressableProps {
1571                            layout: {
1572                                let mut layout = LayoutStyle::default();
1573                                layout.size.width = Length::Px(Px(80.0));
1574                                layout.size.height = Length::Px(Px(32.0));
1575                                layout
1576                            },
1577                            focusable: true,
1578                            ..Default::default()
1579                        },
1580                        |_cx, _st, id| {
1581                            modal_a = Some(id);
1582                            Vec::new()
1583                        },
1584                    ),
1585                    cx.pressable_with_id(
1586                        PressableProps {
1587                            layout: {
1588                                let mut layout = LayoutStyle::default();
1589                                layout.size.width = Length::Px(Px(80.0));
1590                                layout.size.height = Length::Px(Px(32.0));
1591                                layout
1592                            },
1593                            focusable: true,
1594                            ..Default::default()
1595                        },
1596                        |_cx, _st, id| {
1597                            modal_b = Some(id);
1598                            Vec::new()
1599                        },
1600                    ),
1601                ]
1602            });
1603
1604        let modal_a = modal_a.expect("modal a id");
1605        let modal_b = modal_b.expect("modal b id");
1606
1607        let mut req = OverlayRequest::modal(
1608            GlobalElementId(0x1234),
1609            None,
1610            open,
1611            OverlayPresence::instant(true),
1612            modal_children,
1613        );
1614        req.initial_focus = Some(modal_a);
1615        OverlayController::request_for_window(&mut app, window, req);
1616
1617        OverlayController::render(&mut ui, &mut app, &mut services, window, bounds);
1618        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1619
1620        let modal_a_node =
1621            fret_ui::elements::node_for_element(&mut app, window, modal_a).expect("modal a");
1622        let modal_b_node =
1623            fret_ui::elements::node_for_element(&mut app, window, modal_b).expect("modal b");
1624
1625        assert_eq!(ui.focus(), Some(modal_a_node));
1626
1627        // Focus traversal must be scoped to the modal layer while the barrier is installed.
1628        let _ = ui.dispatch_command(&mut app, &mut services, &CommandId::from("focus.next"));
1629        assert_eq!(ui.focus(), Some(modal_b_node));
1630        assert_ne!(ui.focus(), Some(underlay_a_node));
1631        assert_ne!(ui.focus(), Some(underlay_b_node));
1632
1633        let _ = ui.dispatch_command(&mut app, &mut services, &CommandId::from("focus.next"));
1634        assert_eq!(ui.focus(), Some(modal_a_node));
1635        assert_ne!(ui.focus(), Some(underlay_a_node));
1636        assert_ne!(ui.focus(), Some(underlay_b_node));
1637    }
1638
1639    #[test]
1640    fn modal_tab_keydown_cycles_focus_within_modal() {
1641        let window = AppWindowId::default();
1642        let mut app = App::new();
1643        let mut ui: UiTree<App> = UiTree::new();
1644        ui.set_window(window);
1645
1646        let mut services = FakeServices;
1647        let bounds = Rect::new(
1648            Point::new(Px(0.0), Px(0.0)),
1649            fret_core::Size::new(Px(300.0), Px(200.0)),
1650        );
1651
1652        let open = app.models_mut().insert(true);
1653        let mut modal_a: Option<GlobalElementId> = None;
1654        let mut modal_b: Option<GlobalElementId> = None;
1655
1656        // Base root is required so the window exists and input dispatch can proceed.
1657        OverlayController::begin_frame(&mut app, window);
1658        let base = fret_ui::declarative::render_root(
1659            &mut ui,
1660            &mut app,
1661            &mut services,
1662            window,
1663            bounds,
1664            "base",
1665            |_| Vec::new(),
1666        );
1667        ui.set_root(base);
1668
1669        // Request a modal with two focusables. We'll drive Tab/Shift+Tab via KeyDown events.
1670        OverlayController::begin_frame(&mut app, window);
1671        let modal_children =
1672            fret_ui::elements::with_element_cx(&mut app, window, bounds, "modal-child", |cx| {
1673                vec![
1674                    cx.pressable_with_id(
1675                        PressableProps {
1676                            layout: {
1677                                let mut layout = LayoutStyle::default();
1678                                layout.size.width = Length::Px(Px(80.0));
1679                                layout.size.height = Length::Px(Px(32.0));
1680                                layout
1681                            },
1682                            focusable: true,
1683                            ..Default::default()
1684                        },
1685                        |_cx, _st, id| {
1686                            modal_a = Some(id);
1687                            Vec::new()
1688                        },
1689                    ),
1690                    cx.pressable_with_id(
1691                        PressableProps {
1692                            layout: {
1693                                let mut layout = LayoutStyle::default();
1694                                layout.size.width = Length::Px(Px(80.0));
1695                                layout.size.height = Length::Px(Px(32.0));
1696                                layout
1697                            },
1698                            focusable: true,
1699                            ..Default::default()
1700                        },
1701                        |_cx, _st, id| {
1702                            modal_b = Some(id);
1703                            Vec::new()
1704                        },
1705                    ),
1706                ]
1707            });
1708
1709        let modal_a = modal_a.expect("modal a id");
1710        let modal_b = modal_b.expect("modal b id");
1711
1712        let mut req = OverlayRequest::modal(
1713            GlobalElementId(0x1234),
1714            None,
1715            open,
1716            OverlayPresence::instant(true),
1717            modal_children,
1718        );
1719        req.initial_focus = Some(modal_a);
1720        OverlayController::request_for_window(&mut app, window, req);
1721
1722        OverlayController::render(&mut ui, &mut app, &mut services, window, bounds);
1723        ui.layout_all(&mut app, &mut services, bounds, 1.0);
1724
1725        let modal_a_node =
1726            fret_ui::elements::node_for_element(&mut app, window, modal_a).expect("modal a");
1727        let modal_b_node =
1728            fret_ui::elements::node_for_element(&mut app, window, modal_b).expect("modal b");
1729
1730        assert_eq!(ui.focus(), Some(modal_a_node));
1731
1732        // Tab => focus.next
1733        dispatch_keydown_and_apply_commands(
1734            &mut ui,
1735            &mut app,
1736            &mut services,
1737            KeyCode::Tab,
1738            Modifiers::default(),
1739        );
1740        assert_eq!(ui.focus(), Some(modal_b_node));
1741
1742        // Tab => wraps within modal
1743        dispatch_keydown_and_apply_commands(
1744            &mut ui,
1745            &mut app,
1746            &mut services,
1747            KeyCode::Tab,
1748            Modifiers::default(),
1749        );
1750        assert_eq!(ui.focus(), Some(modal_a_node));
1751
1752        // Shift+Tab => focus.previous
1753        let mods = Modifiers {
1754            shift: true,
1755            ..Default::default()
1756        };
1757        dispatch_keydown_and_apply_commands(&mut ui, &mut app, &mut services, KeyCode::Tab, mods);
1758        assert_eq!(ui.focus(), Some(modal_b_node));
1759    }
1760
1761    #[test]
1762    fn transition_wrapper_keeps_independent_state_per_call_site() {
1763        let window = AppWindowId::default();
1764        let mut app = App::new();
1765
1766        app.set_tick_id(TickId(1));
1767        app.set_frame_id(FrameId(1));
1768
1769        let (a, b) = fret_ui::elements::with_element_cx(
1770            &mut app,
1771            window,
1772            Rect::new(
1773                Point::new(Px(0.0), Px(0.0)),
1774                fret_core::Size::new(Px(200.0), Px(120.0)),
1775            ),
1776            "overlay-transition-wrapper-independence",
1777            |cx| {
1778                let a = OverlayController::transition_with_durations(cx, true, 6, 6);
1779                let b = OverlayController::transition_with_durations(cx, false, 6, 6);
1780                (a, b)
1781            },
1782        );
1783
1784        assert!(a.present);
1785        assert!(a.animating);
1786        assert!(a.progress > 0.0 && a.progress < 1.0);
1787
1788        assert!(!b.present);
1789        assert!(!b.animating);
1790        assert_eq!(b.progress, 0.0);
1791    }
1792}