1use std::sync::Arc;
18
19use fret_runtime::Model;
20use fret_ui::action::{
21 DismissReason, DismissRequestCx, OnCloseAutoFocus, OnDismissRequest, OnOpenAutoFocus,
22};
23use fret_ui::element::{
24 AnyElement, ContainerProps, Elements, InsetStyle, LayoutStyle, Length, PositionStyle,
25 PressableProps, SizeStyle,
26};
27use fret_ui::elements::GlobalElementId;
28use fret_ui::{ElementContext, UiHost};
29
30use crate::declarative::ModelWatchExt;
31use crate::primitives::trigger_a11y;
32use crate::{IntoUiElement, OverlayController, OverlayPresence, OverlayRequest, collect_children};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct DialogCloseAutoFocusGuardPolicy {
41 pub prevent_on_outside_press: bool,
43 pub prevent_on_focus_outside: bool,
45}
46
47impl DialogCloseAutoFocusGuardPolicy {
48 pub fn for_modal(modal: bool) -> Self {
55 Self {
56 prevent_on_outside_press: !modal,
57 prevent_on_focus_outside: true,
58 }
59 }
60
61 pub fn prevent_always() -> Self {
63 Self {
64 prevent_on_outside_press: true,
65 prevent_on_focus_outside: true,
66 }
67 }
68}
69
70pub fn dialog_close_auto_focus_guard_hooks<H: UiHost>(
79 cx: &mut ElementContext<'_, H>,
80 policy: DialogCloseAutoFocusGuardPolicy,
81 open: Model<bool>,
82 on_dismiss_request: Option<OnDismissRequest>,
83 on_close_auto_focus: Option<OnCloseAutoFocus>,
84) -> (Option<OnDismissRequest>, Option<OnCloseAutoFocus>) {
85 let should_install = policy.prevent_on_outside_press
86 || policy.prevent_on_focus_outside
87 || on_dismiss_request.is_some();
88 let should_install_close = policy.prevent_on_outside_press
89 || policy.prevent_on_focus_outside
90 || on_close_auto_focus.is_some();
91
92 if !should_install && !should_install_close {
93 return (on_dismiss_request, on_close_auto_focus);
94 }
95
96 let dismiss_reason = cx.local_model(|| None::<DismissReason>);
97
98 let open_now = cx.app.models().get_copied(&open).unwrap_or(false);
100 if open_now {
101 let _ = cx.app.models_mut().update(&dismiss_reason, |v| *v = None);
102 }
103
104 let dismiss_handler: Option<OnDismissRequest> = should_install.then(|| {
105 let user_dismiss_request = on_dismiss_request.clone();
106 let open_for_default_close = open.clone();
107 let dismiss_reason_for_hook = dismiss_reason.clone();
108 let handler: OnDismissRequest = Arc::new(move |host, cx, req| {
109 if let Some(user) = user_dismiss_request.as_ref() {
110 user(host, cx, req);
111 }
112
113 if !req.default_prevented() {
114 let should_store = match req.reason {
115 DismissReason::OutsidePress { .. } => policy.prevent_on_outside_press,
116 DismissReason::FocusOutside => policy.prevent_on_focus_outside,
117 _ => false,
118 };
119 let _ = host.models_mut().update(&dismiss_reason_for_hook, |v| {
120 *v = should_store.then_some(req.reason);
121 });
122 let _ = host
123 .models_mut()
124 .update(&open_for_default_close, |v| *v = false);
125 } else {
126 let _ = host
127 .models_mut()
128 .update(&dismiss_reason_for_hook, |v| *v = None);
129 }
130 });
131 handler
132 });
133
134 let on_close_auto_focus: Option<OnCloseAutoFocus> = should_install_close.then(|| {
135 let dismiss_reason_for_close = dismiss_reason.clone();
136 let user = on_close_auto_focus.clone();
137 let handler: OnCloseAutoFocus = Arc::new(move |host, cx, req| {
138 if let Some(user) = user.as_ref() {
139 user(host, cx, req);
140 }
141
142 let reason = host
143 .models_mut()
144 .read(&dismiss_reason_for_close, |v| *v)
145 .ok()
146 .flatten();
147 let _ = host
148 .models_mut()
149 .update(&dismiss_reason_for_close, |v| *v = None);
150
151 if req.default_prevented() {
152 return;
153 }
154
155 let should_prevent = match reason {
156 Some(DismissReason::OutsidePress { .. }) => policy.prevent_on_outside_press,
157 Some(DismissReason::FocusOutside) => policy.prevent_on_focus_outside,
158 _ => false,
159 };
160 if should_prevent {
161 req.prevent_default();
162 }
163 });
164 handler
165 });
166
167 (dismiss_handler.or(on_dismiss_request), on_close_auto_focus)
168}
169
170#[derive(Clone)]
171pub struct DialogOptions {
172 pub dismiss_on_overlay_press: bool,
173 pub initial_focus: Option<GlobalElementId>,
174 pub on_open_auto_focus: Option<OnOpenAutoFocus>,
175 pub on_close_auto_focus: Option<OnCloseAutoFocus>,
176}
177
178impl std::fmt::Debug for DialogOptions {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct("DialogOptions")
181 .field("dismiss_on_overlay_press", &self.dismiss_on_overlay_press)
182 .field("initial_focus", &self.initial_focus)
183 .field("on_open_auto_focus", &self.on_open_auto_focus.is_some())
184 .field("on_close_auto_focus", &self.on_close_auto_focus.is_some())
185 .finish()
186 }
187}
188
189impl Default for DialogOptions {
190 fn default() -> Self {
191 Self {
192 dismiss_on_overlay_press: true,
193 initial_focus: None,
194 on_open_auto_focus: None,
195 on_close_auto_focus: None,
196 }
197 }
198}
199
200impl DialogOptions {
201 pub fn dismiss_on_overlay_press(mut self, dismiss_on_overlay_press: bool) -> Self {
202 self.dismiss_on_overlay_press = dismiss_on_overlay_press;
203 self
204 }
205
206 pub fn initial_focus(mut self, initial_focus: Option<GlobalElementId>) -> Self {
207 self.initial_focus = initial_focus;
208 self
209 }
210
211 pub fn on_open_auto_focus(mut self, hook: Option<OnOpenAutoFocus>) -> Self {
212 self.on_open_auto_focus = hook;
213 self
214 }
215
216 pub fn on_close_auto_focus(mut self, hook: Option<OnCloseAutoFocus>) -> Self {
217 self.on_close_auto_focus = hook;
218 self
219 }
220}
221
222pub fn dialog_root_name(id: GlobalElementId) -> String {
224 OverlayController::modal_root_name(id)
225}
226
227pub fn dialog_use_open_model<H: UiHost>(
233 cx: &mut ElementContext<'_, H>,
234 controlled_open: Option<Model<bool>>,
235 default_open: impl FnOnce() -> bool,
236) -> crate::primitives::controllable_state::ControllableModel<bool> {
237 crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
238}
239
240#[derive(Debug, Clone, Default)]
247pub struct DialogRoot {
248 open: Option<Model<bool>>,
249 default_open: bool,
250 options: DialogOptions,
251}
252
253impl DialogRoot {
254 pub fn new() -> Self {
255 Self::default()
256 }
257
258 pub fn open(mut self, open: Option<Model<bool>>) -> Self {
260 self.open = open;
261 self
262 }
263
264 pub fn default_open(mut self, default_open: bool) -> Self {
266 self.default_open = default_open;
267 self
268 }
269
270 pub fn dismiss_on_overlay_press(mut self, dismiss_on_overlay_press: bool) -> Self {
271 self.options = self
272 .options
273 .dismiss_on_overlay_press(dismiss_on_overlay_press);
274 self
275 }
276
277 pub fn initial_focus(mut self, initial_focus: Option<GlobalElementId>) -> Self {
278 self.options = self.options.initial_focus(initial_focus);
279 self
280 }
281
282 pub fn options(&self) -> DialogOptions {
283 self.options.clone()
284 }
285
286 pub fn modal_request_with_dismiss_handler<H: UiHost, I, T>(
287 &self,
288 cx: &mut ElementContext<'_, H>,
289 id: GlobalElementId,
290 trigger: GlobalElementId,
291 presence: OverlayPresence,
292 on_dismiss_request: Option<OnDismissRequest>,
293 children: I,
294 ) -> OverlayRequest
295 where
296 I: IntoIterator<Item = T>,
297 T: IntoUiElement<H>,
298 {
299 let children = collect_children(cx, children);
300 modal_dialog_request_with_options_and_dismiss_handler(
301 id,
302 trigger,
303 self.open_model(cx),
304 presence,
305 self.options.clone(),
306 on_dismiss_request,
307 children,
308 )
309 }
310
311 pub fn use_open_model<H: UiHost>(
313 &self,
314 cx: &mut ElementContext<'_, H>,
315 ) -> crate::primitives::controllable_state::ControllableModel<bool> {
316 dialog_use_open_model(cx, self.open.clone(), || self.default_open)
317 }
318
319 pub fn open_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
320 self.use_open_model(cx).model()
321 }
322
323 pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
325 let open_model = self.open_model(cx);
326 cx.watch_model(&open_model)
327 .layout()
328 .copied()
329 .unwrap_or(false)
330 }
331
332 pub fn modal_request<H: UiHost, I, T>(
333 &self,
334 cx: &mut ElementContext<'_, H>,
335 id: GlobalElementId,
336 trigger: GlobalElementId,
337 presence: OverlayPresence,
338 children: I,
339 ) -> OverlayRequest
340 where
341 I: IntoIterator<Item = T>,
342 T: IntoUiElement<H>,
343 {
344 let children = collect_children(cx, children);
345 modal_dialog_request_with_options(
346 id,
347 trigger,
348 self.open_model(cx),
349 presence,
350 self.options.clone(),
351 children,
352 )
353 }
354}
355
356pub fn apply_dialog_trigger_a11y(
360 trigger: AnyElement,
361 expanded: bool,
362 content_element: Option<GlobalElementId>,
363) -> AnyElement {
364 trigger_a11y::apply_trigger_controls_expanded(trigger, Some(expanded), content_element)
365}
366
367pub fn modal_dialog_request(
369 id: GlobalElementId,
370 trigger: GlobalElementId,
371 open: Model<bool>,
372 presence: OverlayPresence,
373 children: impl IntoIterator<Item = AnyElement>,
374) -> OverlayRequest {
375 modal_dialog_request_with_options(
376 id,
377 trigger,
378 open,
379 presence,
380 DialogOptions::default(),
381 children.into_iter().collect::<Vec<_>>(),
382 )
383}
384
385pub fn modal_dialog_request_with_options(
387 id: GlobalElementId,
388 trigger: GlobalElementId,
389 open: Model<bool>,
390 presence: OverlayPresence,
391 options: DialogOptions,
392 children: impl IntoIterator<Item = AnyElement>,
393) -> OverlayRequest {
394 let children: Vec<AnyElement> = children.into_iter().collect();
395 let mut request = OverlayRequest::modal(id, Some(trigger), open, presence, children);
396 request.root_name = Some(dialog_root_name(id));
397 request.initial_focus = options.initial_focus;
398 request.on_open_auto_focus = options.on_open_auto_focus.clone();
399 request.on_close_auto_focus = options.on_close_auto_focus.clone();
400 request
401}
402
403pub fn modal_dialog_request_with_options_and_dismiss_handler(
408 id: GlobalElementId,
409 trigger: GlobalElementId,
410 open: Model<bool>,
411 presence: OverlayPresence,
412 options: DialogOptions,
413 on_dismiss_request: Option<OnDismissRequest>,
414 children: impl IntoIterator<Item = AnyElement>,
415) -> OverlayRequest {
416 let mut request =
417 modal_dialog_request_with_options(id, trigger, open, presence, options, children);
418 request.dismissible_on_dismiss_request = on_dismiss_request;
419 request
420}
421
422pub fn modal_barrier_layout() -> LayoutStyle {
424 LayoutStyle {
425 position: PositionStyle::Absolute,
426 inset: InsetStyle {
427 top: Some(fret_core::Px(0.0)).into(),
428 right: Some(fret_core::Px(0.0)).into(),
429 bottom: Some(fret_core::Px(0.0)).into(),
430 left: Some(fret_core::Px(0.0)).into(),
431 },
432 size: SizeStyle {
433 width: Length::Fill,
434 height: Length::Fill,
435 ..Default::default()
436 },
437 ..Default::default()
438 }
439}
440
441pub fn modal_barrier<H: UiHost, I, T>(
446 cx: &mut ElementContext<'_, H>,
447 open: Model<bool>,
448 dismiss_on_press: bool,
449 children: I,
450) -> AnyElement
451where
452 I: IntoIterator<Item = T>,
453 T: IntoUiElement<H>,
454{
455 modal_barrier_with_dismiss_handler(cx, open, dismiss_on_press, None, children)
456}
457
458pub fn modal_barrier_with_dismiss_handler<H: UiHost, I, T>(
464 cx: &mut ElementContext<'_, H>,
465 open: Model<bool>,
466 dismiss_on_press: bool,
467 on_dismiss_request: Option<OnDismissRequest>,
468 children: I,
469) -> AnyElement
470where
471 I: IntoIterator<Item = T>,
472 T: IntoUiElement<H>,
473{
474 let layout = modal_barrier_layout();
475 let children = collect_children(cx, children);
476
477 if dismiss_on_press {
478 cx.pressable(
479 PressableProps {
480 layout,
481 enabled: true,
482 focusable: false,
483 ..Default::default()
484 },
485 move |cx, _st| {
486 if let Some(on_dismiss_request) = on_dismiss_request.clone() {
487 let open_for_dismiss = open.clone();
488 cx.pressable_add_on_pointer_up(Arc::new(move |host, action_cx, up| {
489 let mut req = DismissRequestCx::new(DismissReason::OutsidePress {
490 pointer: Some(fret_ui::action::OutsidePressCx {
491 pointer_id: up.pointer_id,
492 pointer_type: up.pointer_type,
493 button: up.button,
494 modifiers: up.modifiers,
495 click_count: up.click_count,
496 }),
497 });
498 on_dismiss_request(host, action_cx, &mut req);
499 if !req.default_prevented() {
500 let _ = host.models_mut().update(&open_for_dismiss, |v| *v = false);
501 }
502 fret_ui::action::PressablePointerUpResult::SkipActivate
503 }));
504 } else {
505 cx.pressable_add_on_pointer_up(Arc::new(move |host, _action_cx, _up| {
506 let _ = host.models_mut().update(&open, |v| *v = false);
507 fret_ui::action::PressablePointerUpResult::SkipActivate
508 }));
509 }
510
511 children
512 },
513 )
514 } else {
515 cx.container(
516 ContainerProps {
517 layout,
518 ..Default::default()
519 },
520 move |_cx| children,
521 )
522 }
523}
524
525pub fn modal_dialog_layer_elements<H: UiHost, I, T>(
528 cx: &mut ElementContext<'_, H>,
529 open: Model<bool>,
530 options: DialogOptions,
531 barrier_children: I,
532 content: AnyElement,
533) -> Elements
534where
535 I: IntoIterator<Item = T>,
536 T: IntoUiElement<H>,
537{
538 Elements::from([
539 modal_barrier(cx, open, options.dismiss_on_overlay_press, barrier_children),
540 content,
541 ])
542}
543
544pub fn modal_dialog_layer_elements_with_dismiss_handler<H: UiHost, I, T>(
547 cx: &mut ElementContext<'_, H>,
548 open: Model<bool>,
549 options: DialogOptions,
550 on_dismiss_request: Option<OnDismissRequest>,
551 barrier_children: I,
552 content: AnyElement,
553) -> Elements
554where
555 I: IntoIterator<Item = T>,
556 T: IntoUiElement<H>,
557{
558 Elements::from([
559 modal_barrier_with_dismiss_handler(
560 cx,
561 open,
562 options.dismiss_on_overlay_press,
563 on_dismiss_request,
564 barrier_children,
565 ),
566 content,
567 ])
568}
569
570pub fn request_modal_dialog<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
572 OverlayController::request(cx, request);
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 use fret_ui::action::DismissReason;
580 use std::sync::Arc;
581
582 use fret_app::App;
583 use fret_core::AppWindowId;
584 use fret_core::Event;
585 use fret_core::{PathCommand, SvgId, SvgService};
586 use fret_core::{PathConstraints, PathId, PathMetrics, PathService, PathStyle};
587 use fret_core::{Point, Px, Rect, Size};
588 use fret_core::{TextBlobId, TextConstraints, TextInput, TextMetrics, TextService};
589 use fret_ui::UiTree;
590 use fret_ui::element::{ContainerProps, ElementKind, LayoutStyle, Length, PressableProps};
591 use fret_ui::elements::GlobalElementId;
592
593 #[derive(Default)]
594 struct FakeServices;
595
596 impl TextService for FakeServices {
597 fn prepare(
598 &mut self,
599 _input: &TextInput,
600 _constraints: TextConstraints,
601 ) -> (TextBlobId, TextMetrics) {
602 (
603 TextBlobId::default(),
604 TextMetrics {
605 size: fret_core::Size::new(Px(0.0), Px(0.0)),
606 baseline: Px(0.0),
607 },
608 )
609 }
610
611 fn release(&mut self, _blob: TextBlobId) {}
612 }
613
614 impl PathService for FakeServices {
615 fn prepare(
616 &mut self,
617 _commands: &[PathCommand],
618 _style: PathStyle,
619 _constraints: PathConstraints,
620 ) -> (PathId, PathMetrics) {
621 (PathId::default(), PathMetrics::default())
622 }
623
624 fn release(&mut self, _path: PathId) {}
625 }
626
627 impl SvgService for FakeServices {
628 fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
629 SvgId::default()
630 }
631
632 fn unregister_svg(&mut self, _svg: SvgId) -> bool {
633 true
634 }
635 }
636
637 impl fret_core::MaterialService for FakeServices {
638 fn register_material(
639 &mut self,
640 _desc: fret_core::MaterialDescriptor,
641 ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
642 Err(fret_core::MaterialRegistrationError::Unsupported)
643 }
644
645 fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
646 true
647 }
648 }
649
650 fn bounds() -> Rect {
651 Rect::new(
652 Point::new(Px(0.0), Px(0.0)),
653 Size::new(Px(200.0), Px(120.0)),
654 )
655 }
656
657 #[test]
658 fn dialog_root_open_model_uses_controlled_model() {
659 let window = AppWindowId::default();
660 let mut app = App::new();
661 let b = bounds();
662
663 let controlled = app.models_mut().insert(true);
664
665 fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
666 let root = DialogRoot::new()
667 .open(Some(controlled.clone()))
668 .default_open(false);
669 assert_eq!(root.open_model(cx), controlled);
670 });
671 }
672
673 #[test]
674 fn dialog_root_options_builder_updates_options() {
675 let root = DialogRoot::new()
676 .dismiss_on_overlay_press(false)
677 .initial_focus(Some(GlobalElementId(0xbeef)));
678 let options = root.options();
679 assert!(!options.dismiss_on_overlay_press);
680 assert_eq!(options.initial_focus, Some(GlobalElementId(0xbeef)));
681 }
682
683 #[test]
684 fn modal_dialog_request_with_options_and_dismiss_handler_sets_dismiss_handler() {
685 let mut app = App::new();
686 let open = app.models_mut().insert(false);
687
688 let handler: OnDismissRequest = Arc::new(|_host, _cx, _req: &mut DismissRequestCx| {});
689 let req = modal_dialog_request_with_options_and_dismiss_handler(
690 GlobalElementId(0x123),
691 GlobalElementId(0x123),
692 open,
693 OverlayPresence::instant(true),
694 DialogOptions::default(),
695 Some(handler),
696 Vec::new(),
697 );
698
699 assert!(req.dismissible_on_dismiss_request.is_some());
700 }
701
702 #[test]
703 fn apply_dialog_trigger_a11y_sets_controls_and_expanded() {
704 let window = AppWindowId::default();
705 let mut app = App::new();
706 let b = bounds();
707
708 fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
709 let trigger = cx.pressable(
710 PressableProps {
711 layout: LayoutStyle::default(),
712 enabled: true,
713 focusable: true,
714 ..Default::default()
715 },
716 |_cx, _st| Vec::new(),
717 );
718
719 let content = GlobalElementId(0xdead);
720 let trigger = apply_dialog_trigger_a11y(trigger, true, Some(content));
721
722 let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
723 panic!("expected pressable trigger");
724 };
725 assert_eq!(a11y.expanded, Some(true));
726 assert_eq!(a11y.controls_element, Some(content.0));
727 });
728 }
729
730 #[test]
731 fn modal_dialog_request_sets_default_root_name() {
732 let mut app = App::new();
733 let open = app.models_mut().insert(false);
734 let id = GlobalElementId(0x123);
735 let trigger = GlobalElementId(0x456);
736
737 let req = modal_dialog_request(
738 id,
739 trigger,
740 open,
741 OverlayPresence::instant(true),
742 Vec::new(),
743 );
744 let expected = dialog_root_name(id);
745 assert_eq!(req.root_name.as_deref(), Some(expected.as_str()));
746 }
747
748 #[test]
749 fn modal_dialog_request_with_options_sets_initial_focus() {
750 let mut app = App::new();
751 let open = app.models_mut().insert(false);
752 let id = GlobalElementId(0x123);
753 let trigger = GlobalElementId(0x456);
754 let initial_focus = GlobalElementId(0xbeef);
755
756 let opts = DialogOptions::default().initial_focus(Some(initial_focus));
757 let req = modal_dialog_request_with_options(
758 id,
759 trigger,
760 open,
761 OverlayPresence::instant(true),
762 opts,
763 Vec::new(),
764 );
765 assert_eq!(req.initial_focus, Some(initial_focus));
766 }
767
768 #[test]
769 fn modal_dialog_installs_barrier_root_for_semantics_snapshot() {
770 let window = AppWindowId::default();
771 let mut app = App::new();
772 let mut ui: UiTree<App> = UiTree::new();
773 ui.set_window(window);
774
775 let mut services = FakeServices::default();
776 let b = bounds();
777
778 OverlayController::begin_frame(&mut app, window);
779 let base = fret_ui::declarative::render_root(
780 &mut ui,
781 &mut app,
782 &mut services,
783 window,
784 b,
785 "base",
786 |_cx| Vec::new(),
787 );
788 ui.set_root(base);
789
790 let open = app.models_mut().insert(true);
791 let modal_id = GlobalElementId(0xabc);
792
793 let overlay_children =
794 fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
795 let content = cx.container(ContainerProps::default(), |_cx| Vec::new());
796 modal_dialog_layer_elements(
797 cx,
798 open.clone(),
799 DialogOptions::default(),
800 Vec::<AnyElement>::new(),
801 content,
802 )
803 });
804
805 let req = modal_dialog_request(
806 modal_id,
807 modal_id,
808 open,
809 OverlayPresence::instant(true),
810 overlay_children,
811 );
812 OverlayController::request_for_window(&mut app, window, req);
813 OverlayController::render(&mut ui, &mut app, &mut services, window, b);
814
815 ui.request_semantics_snapshot();
816 ui.layout_all(&mut app, &mut services, b, 1.0);
817
818 let snap = ui.semantics_snapshot().expect("semantics snapshot");
819 let barrier_root = snap.barrier_root.expect("barrier_root");
820 assert!(
821 snap.roots
822 .iter()
823 .any(|r| r.root == barrier_root && r.blocks_underlay_input),
824 "expected barrier root to block underlay input"
825 );
826 }
827
828 #[test]
829 fn modal_barrier_can_dismiss_on_press() {
830 let window = AppWindowId::default();
831 let mut app = App::new();
832 let mut ui: UiTree<App> = UiTree::new();
833 ui.set_window(window);
834
835 let mut services = FakeServices::default();
836 let b = bounds();
837
838 OverlayController::begin_frame(&mut app, window);
839 let base = fret_ui::declarative::render_root(
840 &mut ui,
841 &mut app,
842 &mut services,
843 window,
844 b,
845 "base",
846 |_cx| Vec::new(),
847 );
848 ui.set_root(base);
849
850 let open = app.models_mut().insert(true);
851 let modal_id = GlobalElementId(0xabc);
852
853 let overlay_children =
854 fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
855 vec![modal_barrier(
856 cx,
857 open.clone(),
858 true,
859 Vec::<AnyElement>::new(),
860 )]
861 });
862
863 let req = modal_dialog_request(
864 modal_id,
865 modal_id,
866 open.clone(),
867 OverlayPresence::instant(true),
868 overlay_children,
869 );
870 OverlayController::request_for_window(&mut app, window, req);
871 OverlayController::render(&mut ui, &mut app, &mut services, window, b);
872 ui.layout_all(&mut app, &mut services, b, 1.0);
873
874 let modal_root = ui
875 .debug_layers_in_paint_order()
876 .into_iter()
877 .find(|l| l.blocks_underlay_input && l.visible)
878 .expect("modal layer root")
879 .root;
880 let barrier = ui.children(modal_root)[0];
881 let barrier_bounds = ui.debug_node_bounds(barrier).expect("barrier bounds");
882 assert!(
883 barrier_bounds.origin.x.0 <= 10.0
884 && barrier_bounds.origin.y.0 <= 10.0
885 && barrier_bounds.origin.x.0 + barrier_bounds.size.width.0 >= 10.0
886 && barrier_bounds.origin.y.0 + barrier_bounds.size.height.0 >= 10.0,
887 "expected modal barrier to cover (10, 10), got {barrier_bounds:?}"
888 );
889
890 ui.dispatch_event(
891 &mut app,
892 &mut services,
893 &Event::Pointer(fret_core::PointerEvent::Down {
894 position: Point::new(Px(10.0), Px(10.0)),
895 button: fret_core::MouseButton::Left,
896 modifiers: fret_core::Modifiers::default(),
897 click_count: 1,
898 pointer_id: fret_core::PointerId(0),
899 pointer_type: Default::default(),
900 }),
901 );
902 ui.dispatch_event(
903 &mut app,
904 &mut services,
905 &Event::Pointer(fret_core::PointerEvent::Up {
906 position: Point::new(Px(10.0), Px(10.0)),
907 button: fret_core::MouseButton::Left,
908 modifiers: fret_core::Modifiers::default(),
909 is_click: true,
910 click_count: 1,
911 pointer_id: fret_core::PointerId(0),
912 pointer_type: Default::default(),
913 }),
914 );
915
916 assert_eq!(app.models().get_copied(&open), Some(false));
917 }
918
919 #[test]
920 fn modal_barrier_can_route_dismissals_through_handler() {
921 let window = AppWindowId::default();
922 let mut app = App::new();
923 let mut ui: UiTree<App> = UiTree::new();
924 ui.set_window(window);
925
926 let mut services = FakeServices::default();
927 let b = bounds();
928
929 OverlayController::begin_frame(&mut app, window);
930 let base = fret_ui::declarative::render_root(
931 &mut ui,
932 &mut app,
933 &mut services,
934 window,
935 b,
936 "base",
937 |_cx| Vec::new(),
938 );
939 ui.set_root(base);
940
941 let open = app.models_mut().insert(true);
942 let modal_id = GlobalElementId(0xabc);
943
944 let reason_cell: Arc<std::sync::Mutex<Option<DismissReason>>> =
945 Arc::new(std::sync::Mutex::new(None));
946 let reason_cell_for_handler = reason_cell.clone();
947 let handler: OnDismissRequest = Arc::new(move |_host, _cx, req| {
948 *reason_cell_for_handler.lock().expect("reason lock") = Some(req.reason);
949 req.prevent_default();
950 });
951
952 let overlay_children =
953 fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
954 vec![modal_barrier_with_dismiss_handler(
955 cx,
956 open.clone(),
957 true,
958 Some(handler.clone()),
959 Vec::<AnyElement>::new(),
960 )]
961 });
962
963 let req = modal_dialog_request(
964 modal_id,
965 modal_id,
966 open.clone(),
967 OverlayPresence::instant(true),
968 overlay_children,
969 );
970 OverlayController::request_for_window(&mut app, window, req);
971 OverlayController::render(&mut ui, &mut app, &mut services, window, b);
972 ui.layout_all(&mut app, &mut services, b, 1.0);
973
974 let modal_root = ui
975 .debug_layers_in_paint_order()
976 .into_iter()
977 .find(|l| l.blocks_underlay_input && l.visible)
978 .expect("modal layer root")
979 .root;
980 let barrier = ui.children(modal_root)[0];
981 let barrier_bounds = ui.debug_node_bounds(barrier).expect("barrier bounds");
982 assert!(
983 barrier_bounds.origin.x.0 <= 10.0
984 && barrier_bounds.origin.y.0 <= 10.0
985 && barrier_bounds.origin.x.0 + barrier_bounds.size.width.0 >= 10.0
986 && barrier_bounds.origin.y.0 + barrier_bounds.size.height.0 >= 10.0,
987 "expected modal barrier to cover (10, 10), got {barrier_bounds:?}"
988 );
989
990 ui.dispatch_event(
991 &mut app,
992 &mut services,
993 &Event::Pointer(fret_core::PointerEvent::Down {
994 position: Point::new(Px(10.0), Px(10.0)),
995 button: fret_core::MouseButton::Left,
996 modifiers: fret_core::Modifiers::default(),
997 click_count: 1,
998 pointer_id: fret_core::PointerId(0),
999 pointer_type: Default::default(),
1000 }),
1001 );
1002 ui.dispatch_event(
1003 &mut app,
1004 &mut services,
1005 &Event::Pointer(fret_core::PointerEvent::Up {
1006 position: Point::new(Px(10.0), Px(10.0)),
1007 button: fret_core::MouseButton::Left,
1008 modifiers: fret_core::Modifiers::default(),
1009 is_click: true,
1010 click_count: 1,
1011 pointer_id: fret_core::PointerId(0),
1012 pointer_type: Default::default(),
1013 }),
1014 );
1015
1016 assert_eq!(app.models().get_copied(&open), Some(true));
1017 let reason = *reason_cell.lock().expect("reason lock");
1018 let Some(DismissReason::OutsidePress { pointer }) = reason else {
1019 panic!("expected outside-press dismissal, got {reason:?}");
1020 };
1021 let Some(cx) = pointer else {
1022 panic!("expected pointer payload for outside-press dismissal");
1023 };
1024 assert_eq!(cx.pointer_id, fret_core::PointerId(0));
1025 assert_eq!(cx.pointer_type, fret_core::PointerType::Mouse);
1026 assert_eq!(cx.button, fret_core::MouseButton::Left);
1027 assert_eq!(cx.modifiers, fret_core::Modifiers::default());
1028 assert_eq!(cx.click_count, 1);
1029 }
1030
1031 #[test]
1032 fn modal_dialog_focuses_first_focusable_descendant_by_default() {
1033 let window = AppWindowId::default();
1034 let mut app = App::new();
1035 let mut ui: UiTree<App> = UiTree::new();
1036 ui.set_window(window);
1037
1038 let mut services = FakeServices::default();
1039 let b = bounds();
1040
1041 OverlayController::begin_frame(&mut app, window);
1042 let base = fret_ui::declarative::render_root(
1043 &mut ui,
1044 &mut app,
1045 &mut services,
1046 window,
1047 b,
1048 "base",
1049 |_cx| Vec::new(),
1050 );
1051 ui.set_root(base);
1052
1053 let open = app.models_mut().insert(true);
1054 let modal_id = GlobalElementId(0xabc);
1055
1056 let mut focusable_element: Option<GlobalElementId> = None;
1057 let overlay_children =
1058 fret_ui::elements::with_element_cx(&mut app, window, b, "modal", |cx| {
1059 let content = cx.pressable_with_id(
1060 PressableProps {
1061 layout: {
1062 let mut layout = LayoutStyle::default();
1063 layout.size.width = Length::Px(Px(80.0));
1064 layout.size.height = Length::Px(Px(32.0));
1065 layout
1066 },
1067 enabled: true,
1068 focusable: true,
1069 ..Default::default()
1070 },
1071 |_cx, _st, id| {
1072 focusable_element = Some(id);
1073 Vec::new()
1074 },
1075 );
1076
1077 modal_dialog_layer_elements(
1078 cx,
1079 open.clone(),
1080 DialogOptions::default(),
1081 Vec::<AnyElement>::new(),
1082 content,
1083 )
1084 });
1085 let focusable_element = focusable_element.expect("focusable element id");
1086
1087 let req = modal_dialog_request(
1088 modal_id,
1089 modal_id,
1090 open,
1091 OverlayPresence::instant(true),
1092 overlay_children,
1093 );
1094 OverlayController::request_for_window(&mut app, window, req);
1095 OverlayController::render(&mut ui, &mut app, &mut services, window, b);
1096 ui.layout_all(&mut app, &mut services, b, 1.0);
1097
1098 let focused = ui.focus();
1099 let expected = fret_ui::elements::node_for_element(&mut app, window, focusable_element);
1100 assert_eq!(focused, expected);
1101 }
1102}