Skip to main content

fret_ui_kit/primitives/
dismissable_layer.rs

1//! DismissableLayer (Radix-aligned outcomes).
2//!
3//! In the DOM, Radix's DismissableLayer composes Escape and outside-interaction dismissal hooks.
4//! In Fret, the runtime substrate provides those mechanisms via:
5//!
6//! - Escape routing: `fret-ui` event dispatch.
7//! - Outside-press observer pass: ADR 0069 (observer phase pointer events).
8//!
9//! This module provides a stable, Radix-named primitive surface for component-layer policy.
10
11use std::collections::HashSet;
12use std::sync::Arc;
13
14use fret_core::{AppWindowId, NodeId, Rect, UiServices};
15use fret_ui::elements::GlobalElementId;
16use fret_ui::{ElementContext, UiHost, UiTree};
17
18use crate::IntoUiElement;
19
20pub use fret_ui::action::{
21    ActionCx, DismissReason, DismissRequestCx, OnDismissRequest, UiActionHost,
22};
23pub use fret_ui::action::{OnDismissiblePointerMove, PointerMoveCx};
24
25/// Render a full-window dismissable root that provides Escape + outside-press dismissal hooks.
26///
27/// This is a Radix-aligned naming alias for `render_dismissible_root_with_hooks`.
28#[allow(clippy::too_many_arguments)]
29pub fn render_dismissable_root_with_hooks<H: UiHost + 'static, I, T>(
30    ui: &mut UiTree<H>,
31    app: &mut H,
32    services: &mut dyn UiServices,
33    window: AppWindowId,
34    bounds: Rect,
35    root_name: &str,
36    render: impl FnOnce(&mut ElementContext<'_, H>) -> I,
37) -> fret_core::NodeId
38where
39    I: IntoIterator<Item = T>,
40    T: IntoUiElement<H>,
41{
42    crate::declarative::dismissible::render_dismissible_root_with_hooks(
43        ui, app, services, window, bounds, root_name, render,
44    )
45}
46
47/// Installs an `on_dismiss_request` handler for the current dismissable root.
48///
49/// This is a naming-aligned wrapper around `ElementContext::dismissible_on_dismiss_request`.
50pub fn on_dismiss_request<H: UiHost>(cx: &mut ElementContext<'_, H>, handler: OnDismissRequest) {
51    cx.dismissible_on_dismiss_request(handler);
52}
53
54/// Installs an `on_pointer_move` observer for the current dismissable root.
55///
56/// This is intended for overlay policy code (e.g. submenu safe-hover corridors) that needs pointer
57/// movement even when the overlay content is click-through.
58pub fn on_pointer_move<H: UiHost>(
59    cx: &mut ElementContext<'_, H>,
60    handler: OnDismissiblePointerMove,
61) {
62    cx.dismissible_on_pointer_move(handler);
63}
64
65/// Convenience builder for an `OnDismissRequest` handler.
66pub fn handler(
67    f: impl Fn(&mut dyn UiActionHost, ActionCx, &mut DismissRequestCx) + 'static,
68) -> OnDismissRequest {
69    Arc::new(f)
70}
71
72/// Convenience builder for an `OnDismissiblePointerMove` handler.
73pub fn pointer_move_handler(
74    f: impl Fn(&mut dyn UiActionHost, ActionCx, PointerMoveCx) -> bool + 'static,
75) -> OnDismissiblePointerMove {
76    Arc::new(f)
77}
78
79/// Resolve `DismissableLayerBranch` roots (Radix outcome) into `NodeId`s for the outside-press
80/// observer pass (ADR 0069).
81///
82/// Notes:
83/// - Missing nodes are ignored (e.g. branch element not mounted yet).
84/// - Duplicates are removed while preserving first-seen order.
85pub fn resolve_branch_nodes_for_trigger_and_elements<H: UiHost>(
86    app: &mut H,
87    window: AppWindowId,
88    trigger: GlobalElementId,
89    branches: &[GlobalElementId],
90) -> Vec<NodeId> {
91    let mut out: Vec<NodeId> = Vec::with_capacity(1 + branches.len());
92    if let Some(node) = fret_ui::elements::live_node_for_element(app, window, trigger) {
93        out.push(node);
94    }
95    out.extend(
96        branches
97            .iter()
98            .filter_map(|branch| fret_ui::elements::live_node_for_element(app, window, *branch)),
99    );
100    let mut seen: HashSet<NodeId> = HashSet::with_capacity(out.len());
101    out.retain(|id| seen.insert(*id));
102    out
103}
104
105/// Resolve `DismissableLayerBranch` roots (Radix outcome) into `NodeId`s for the outside-press
106/// observer pass (ADR 0069), without implicitly treating a trigger as a branch.
107///
108/// This is useful for non-click-through overlays that also disable outside pointer interactions
109/// (menu-like `modal=true` outcomes): the trigger should be treated as "outside" so a press on the
110/// trigger can close the overlay without activating the underlay.
111pub fn resolve_branch_nodes_for_elements<H: UiHost>(
112    app: &mut H,
113    window: AppWindowId,
114    branches: &[GlobalElementId],
115) -> Vec<NodeId> {
116    let mut out: Vec<NodeId> = branches
117        .iter()
118        .filter_map(|branch| fret_ui::elements::live_node_for_element(app, window, *branch))
119        .collect();
120    let mut seen: HashSet<NodeId> = HashSet::with_capacity(out.len());
121    out.retain(|id| seen.insert(*id));
122    out
123}
124
125/// Resolve dismissable layer branch roots for a popover-like overlay request.
126///
127/// This matches Radix semantics used by menu/popover recipes:
128///
129/// - Click-through overlays treat the trigger as an implicit branch so a trigger click doesn't
130///   first dismiss the overlay and then immediately re-open it.
131/// - Menu-like overlays that disable outside pointer interactions should *not* treat the trigger
132///   as a branch: the trigger press must be considered "outside" so it can close the overlay
133///   without activating the underlay.
134pub fn resolve_branch_nodes_for_popover_request<H: UiHost>(
135    app: &mut H,
136    window: AppWindowId,
137    trigger: GlobalElementId,
138    branches: &[GlobalElementId],
139    disable_outside_pointer_events: bool,
140) -> Vec<NodeId> {
141    if disable_outside_pointer_events {
142        resolve_branch_nodes_for_elements(app, window, branches)
143    } else {
144        resolve_branch_nodes_for_trigger_and_elements(app, window, trigger, branches)
145    }
146}
147
148/// Returns true if `focus` is inside the dismissable layer subtree, or inside any branch subtree.
149pub fn focus_is_inside_layer_or_branches<H: UiHost>(
150    ui: &UiTree<H>,
151    layer_root: NodeId,
152    focus: NodeId,
153    branch_roots: &[NodeId],
154) -> bool {
155    ui.is_descendant(layer_root, focus)
156        || branch_roots
157            .iter()
158            .copied()
159            .any(|branch| ui.is_descendant(branch, focus))
160}
161
162/// Returns true if focus changed since `last_focus` and is now outside the layer + branches.
163///
164/// This is the Radix `onFocusOutside` outcome, expressed using Fret overlay orchestration.
165pub fn should_dismiss_on_focus_outside<H: UiHost>(
166    ui: &UiTree<H>,
167    layer_root: NodeId,
168    focus_now: Option<NodeId>,
169    last_focus: Option<NodeId>,
170    branch_roots: &[NodeId],
171) -> bool {
172    let Some(focus) = focus_now else {
173        return false;
174    };
175    // If we don't have a previous focus sample, we can't express a focus change yet.
176    // Avoid treating this as an "outside" event (Radix `onFocusOutside` is edge-triggered).
177    let Some(last_focus) = last_focus else {
178        return false;
179    };
180    // During tree construction / overlay synthesis, focus can transiently point at a stale node
181    // that is not currently in the tree. Treat this as "unstable focus" and avoid triggering a
182    // focus-outside dismissal; the focus will be repaired or reassigned on subsequent frames.
183    if ui.node_layer(focus).is_none() {
184        return false;
185    }
186    if last_focus == focus {
187        return false;
188    }
189    !focus_is_inside_layer_or_branches(ui, layer_root, focus, branch_roots)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use fret_app::App;
196    use fret_core::{
197        AppWindowId, PathCommand, PathConstraints, PathId, PathMetrics, PathService, PathStyle,
198        Point, Px, Rect, Size, SvgId, SvgService, TextBlobId, TextConstraints, TextInput,
199        TextMetrics, TextService,
200    };
201    use fret_ui::element::{LayoutStyle, Length, PressableProps, SemanticsProps};
202    use std::cell::Cell;
203    use std::rc::Rc;
204
205    #[derive(Default)]
206    struct FakeServices;
207
208    impl TextService for FakeServices {
209        fn prepare(
210            &mut self,
211            _input: &TextInput,
212            _constraints: TextConstraints,
213        ) -> (TextBlobId, TextMetrics) {
214            (
215                TextBlobId::default(),
216                TextMetrics {
217                    size: Size::new(Px(0.0), Px(0.0)),
218                    baseline: Px(0.0),
219                },
220            )
221        }
222
223        fn release(&mut self, _blob: TextBlobId) {}
224    }
225
226    impl PathService for FakeServices {
227        fn prepare(
228            &mut self,
229            _commands: &[PathCommand],
230            _style: PathStyle,
231            _constraints: PathConstraints,
232        ) -> (PathId, PathMetrics) {
233            (PathId::default(), PathMetrics::default())
234        }
235
236        fn release(&mut self, _path: PathId) {}
237    }
238
239    impl SvgService for FakeServices {
240        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
241            SvgId::default()
242        }
243
244        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
245            true
246        }
247    }
248
249    impl fret_core::MaterialService for FakeServices {
250        fn register_material(
251            &mut self,
252            _desc: fret_core::MaterialDescriptor,
253        ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
254            Err(fret_core::MaterialRegistrationError::Unsupported)
255        }
256
257        fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
258            true
259        }
260    }
261
262    fn bounds() -> Rect {
263        Rect::new(
264            Point::new(Px(0.0), Px(0.0)),
265            Size::new(Px(200.0), Px(120.0)),
266        )
267    }
268
269    #[test]
270    fn resolve_branch_nodes_dedupes_and_preserves_order() {
271        let window = AppWindowId::default();
272        let mut app = App::new();
273        let mut ui: UiTree<App> = UiTree::new();
274        ui.set_window(window);
275
276        let mut services = FakeServices;
277        let b = bounds();
278
279        let mut trigger: Option<GlobalElementId> = None;
280        let mut branch_a: Option<GlobalElementId> = None;
281        let mut branch_b: Option<GlobalElementId> = None;
282
283        let root = fret_ui::declarative::render_root(
284            &mut ui,
285            &mut app,
286            &mut services,
287            window,
288            b,
289            "test",
290            |cx| {
291                let props = PressableProps {
292                    layout: {
293                        let mut layout = LayoutStyle::default();
294                        layout.size.width = Length::Px(Px(10.0));
295                        layout.size.height = Length::Px(Px(10.0));
296                        layout
297                    },
298                    focusable: true,
299                    ..Default::default()
300                };
301
302                vec![
303                    cx.pressable_with_id(props.clone(), |_cx, _st, id| {
304                        trigger = Some(id);
305                        Vec::new()
306                    }),
307                    cx.pressable_with_id(props.clone(), |_cx, _st, id| {
308                        branch_a = Some(id);
309                        Vec::new()
310                    }),
311                    cx.pressable_with_id(props, |_cx, _st, id| {
312                        branch_b = Some(id);
313                        Vec::new()
314                    }),
315                ]
316            },
317        );
318        ui.set_root(root);
319        ui.layout_all(&mut app, &mut services, b, 1.0);
320
321        let trigger = trigger.expect("trigger id");
322        let branch_a = branch_a.expect("branch a id");
323        let branch_b = branch_b.expect("branch b id");
324
325        let trigger_node =
326            fret_ui::elements::node_for_element(&mut app, window, trigger).expect("trigger node");
327        let branch_a_node =
328            fret_ui::elements::node_for_element(&mut app, window, branch_a).expect("branch a node");
329        let branch_b_node =
330            fret_ui::elements::node_for_element(&mut app, window, branch_b).expect("branch b node");
331
332        let out = resolve_branch_nodes_for_trigger_and_elements(
333            &mut app,
334            window,
335            trigger,
336            &[branch_a, trigger, branch_b, branch_a],
337        );
338
339        assert_eq!(out, vec![trigger_node, branch_a_node, branch_b_node]);
340    }
341
342    #[test]
343    fn focus_inside_layer_or_branch_is_treated_as_inside() {
344        let window = AppWindowId::default();
345        let mut app = App::new();
346        let mut ui: UiTree<App> = UiTree::new();
347        ui.set_window(window);
348
349        let mut services = FakeServices;
350        let b = bounds();
351
352        let mut layer_root: Option<GlobalElementId> = None;
353        let mut branch_root: Option<GlobalElementId> = None;
354        let mut in_layer: Option<GlobalElementId> = None;
355        let mut in_branch: Option<GlobalElementId> = None;
356        let mut outside: Option<GlobalElementId> = None;
357
358        let focusable = PressableProps {
359            layout: LayoutStyle::default(),
360            focusable: true,
361            ..Default::default()
362        };
363
364        let root = fret_ui::declarative::render_root(
365            &mut ui,
366            &mut app,
367            &mut services,
368            window,
369            b,
370            "test",
371            |cx| {
372                vec![
373                    cx.semantics_with_id(SemanticsProps::default(), |cx, id| {
374                        layer_root = Some(id);
375                        vec![cx.pressable_with_id(focusable.clone(), |_cx, _st, id| {
376                            in_layer = Some(id);
377                            Vec::new()
378                        })]
379                    }),
380                    cx.semantics_with_id(SemanticsProps::default(), |cx, id| {
381                        branch_root = Some(id);
382                        vec![cx.pressable_with_id(focusable.clone(), |_cx, _st, id| {
383                            in_branch = Some(id);
384                            Vec::new()
385                        })]
386                    }),
387                    cx.pressable_with_id(focusable, |_cx, _st, id| {
388                        outside = Some(id);
389                        Vec::new()
390                    }),
391                ]
392            },
393        );
394        ui.set_root(root);
395        ui.layout_all(&mut app, &mut services, b, 1.0);
396
397        let layer_root = layer_root.expect("layer root");
398        let branch_root = branch_root.expect("branch root");
399        let in_layer = in_layer.expect("in layer");
400        let in_branch = in_branch.expect("in branch");
401        let outside = outside.expect("outside");
402
403        let layer_root_node =
404            fret_ui::elements::node_for_element(&mut app, window, layer_root).expect("layer node");
405        let branch_root_node = fret_ui::elements::node_for_element(&mut app, window, branch_root)
406            .expect("branch node");
407        let in_layer_node =
408            fret_ui::elements::node_for_element(&mut app, window, in_layer).expect("in layer node");
409        let in_branch_node = fret_ui::elements::node_for_element(&mut app, window, in_branch)
410            .expect("in branch node");
411        let outside_node =
412            fret_ui::elements::node_for_element(&mut app, window, outside).expect("outside node");
413
414        assert!(focus_is_inside_layer_or_branches(
415            &ui,
416            layer_root_node,
417            in_layer_node,
418            &[branch_root_node]
419        ));
420        assert!(focus_is_inside_layer_or_branches(
421            &ui,
422            layer_root_node,
423            in_branch_node,
424            &[branch_root_node]
425        ));
426        assert!(!focus_is_inside_layer_or_branches(
427            &ui,
428            layer_root_node,
429            outside_node,
430            &[branch_root_node]
431        ));
432    }
433
434    #[test]
435    fn should_not_dismiss_on_focus_outside_without_last_focus_sample() {
436        let window = AppWindowId::default();
437        let mut app = App::new();
438        let mut ui: UiTree<App> = UiTree::new();
439        ui.set_window(window);
440
441        let mut services = FakeServices;
442        let b = bounds();
443
444        let mut layer_root: Option<GlobalElementId> = None;
445        let mut outside: Option<GlobalElementId> = None;
446
447        let focusable = PressableProps {
448            layout: LayoutStyle::default(),
449            focusable: true,
450            ..Default::default()
451        };
452
453        let root = fret_ui::declarative::render_root(
454            &mut ui,
455            &mut app,
456            &mut services,
457            window,
458            b,
459            "test",
460            |cx| {
461                vec![
462                    cx.semantics_with_id(SemanticsProps::default(), |_cx, id| {
463                        layer_root = Some(id);
464                        Vec::new()
465                    }),
466                    cx.pressable_with_id(focusable, |_cx, _st, id| {
467                        outside = Some(id);
468                        Vec::new()
469                    }),
470                ]
471            },
472        );
473        ui.set_root(root);
474        ui.layout_all(&mut app, &mut services, b, 1.0);
475
476        let layer_root = layer_root.expect("layer root");
477        let outside = outside.expect("outside");
478
479        let layer_root_node =
480            fret_ui::elements::node_for_element(&mut app, window, layer_root).expect("layer node");
481        let outside_node =
482            fret_ui::elements::node_for_element(&mut app, window, outside).expect("outside node");
483
484        assert!(!should_dismiss_on_focus_outside(
485            &ui,
486            layer_root_node,
487            Some(outside_node),
488            None,
489            &[]
490        ));
491    }
492
493    #[test]
494    fn should_dismiss_on_focus_outside_when_focus_changes_to_outside() {
495        let window = AppWindowId::default();
496        let mut app = App::new();
497        let mut ui: UiTree<App> = UiTree::new();
498        ui.set_window(window);
499
500        let mut services = FakeServices;
501        let b = bounds();
502
503        let mut layer_root: Option<GlobalElementId> = None;
504        let mut in_layer: Option<GlobalElementId> = None;
505        let mut outside: Option<GlobalElementId> = None;
506
507        let focusable = PressableProps {
508            layout: LayoutStyle::default(),
509            focusable: true,
510            ..Default::default()
511        };
512
513        let root = fret_ui::declarative::render_root(
514            &mut ui,
515            &mut app,
516            &mut services,
517            window,
518            b,
519            "test",
520            |cx| {
521                vec![
522                    cx.semantics_with_id(SemanticsProps::default(), |cx, id| {
523                        layer_root = Some(id);
524                        vec![cx.pressable_with_id(focusable.clone(), |_cx, _st, id| {
525                            in_layer = Some(id);
526                            Vec::new()
527                        })]
528                    }),
529                    cx.pressable_with_id(focusable, |_cx, _st, id| {
530                        outside = Some(id);
531                        Vec::new()
532                    }),
533                ]
534            },
535        );
536        ui.set_root(root);
537        ui.layout_all(&mut app, &mut services, b, 1.0);
538
539        let layer_root = layer_root.expect("layer root");
540        let in_layer = in_layer.expect("in layer");
541        let outside = outside.expect("outside");
542
543        let layer_root_node =
544            fret_ui::elements::node_for_element(&mut app, window, layer_root).expect("layer node");
545        let in_layer_node =
546            fret_ui::elements::node_for_element(&mut app, window, in_layer).expect("in layer node");
547        let outside_node =
548            fret_ui::elements::node_for_element(&mut app, window, outside).expect("outside node");
549
550        assert!(should_dismiss_on_focus_outside(
551            &ui,
552            layer_root_node,
553            Some(outside_node),
554            Some(in_layer_node),
555            &[]
556        ));
557    }
558
559    #[test]
560    fn resolve_branch_nodes_ignores_removed_trigger_with_only_last_known_mapping() {
561        let window = AppWindowId::default();
562        let mut app = App::new();
563        let mut ui: UiTree<App> = UiTree::new();
564        ui.set_window(window);
565
566        let mut services = FakeServices;
567        let b = bounds();
568
569        let trigger: Rc<Cell<Option<GlobalElementId>>> = Rc::new(Cell::new(None));
570        let branch: Rc<Cell<Option<GlobalElementId>>> = Rc::new(Cell::new(None));
571        let show_trigger = Cell::new(true);
572
573        let props = PressableProps {
574            layout: LayoutStyle::default(),
575            focusable: true,
576            ..Default::default()
577        };
578
579        let render_frame =
580            |ui: &mut UiTree<App>, app: &mut App, services: &mut dyn fret_core::UiServices| {
581                let trigger = trigger.clone();
582                let branch = branch.clone();
583                fret_ui::declarative::render_root(ui, app, services, window, b, "test", |cx| {
584                    let mut out = Vec::new();
585                    if show_trigger.get() {
586                        out.push(cx.keyed("trigger", |cx| {
587                            cx.pressable_with_id(props.clone(), |_cx, _st, id| {
588                                trigger.set(Some(id));
589                                Vec::new()
590                            })
591                        }));
592                    }
593                    out.push(cx.keyed("branch", |cx| {
594                        cx.pressable_with_id(props.clone(), |_cx, _st, id| {
595                            branch.set(Some(id));
596                            Vec::new()
597                        })
598                    }));
599                    out
600                })
601            };
602
603        let root = render_frame(&mut ui, &mut app, &mut services);
604        ui.set_root(root);
605        ui.layout_all(&mut app, &mut services, b, 1.0);
606
607        let trigger = trigger.get().expect("trigger id");
608
609        show_trigger.set(false);
610        app.set_frame_id(fret_runtime::FrameId(app.frame_id().0.saturating_add(1)));
611        let root = render_frame(&mut ui, &mut app, &mut services);
612        ui.set_root(root);
613        ui.layout_all(&mut app, &mut services, b, 1.0);
614
615        let branch = branch.get().expect("branch id");
616        let branch_node =
617            fret_ui::elements::node_for_element(&mut app, window, branch).expect("branch node");
618
619        let out =
620            resolve_branch_nodes_for_trigger_and_elements(&mut app, window, trigger, &[branch]);
621
622        assert_eq!(
623            out,
624            vec![branch_node],
625            "expected branch resolution to ignore a removed trigger that only still has a last-known node mapping"
626        );
627    }
628}