1use fret_ui::action::{
11 DismissReason, OnCloseAutoFocus, OnDismissRequest, OnDismissiblePointerMove, OnOpenAutoFocus,
12};
13use fret_ui::element::AnyElement;
14use fret_ui::elements::GlobalElementId;
15use fret_ui::{ElementContext, UiHost};
16
17use fret_runtime::Model;
18
19use std::sync::Arc;
20
21use crate::primitives::dismissable_layer;
22use crate::primitives::menu::sub;
23use crate::primitives::portal_inherited;
24use crate::{OverlayController, OverlayPresence, OverlayRequest};
25
26#[derive(Debug, Default, Clone, Copy)]
35pub struct MenuInitialFocusTargets {
36 pub keyboard_entry_focus: Option<GlobalElementId>,
37 pub pointer_content_focus: Option<GlobalElementId>,
38}
39
40impl MenuInitialFocusTargets {
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn keyboard_entry_focus(mut self, id: Option<GlobalElementId>) -> Self {
46 self.keyboard_entry_focus = id;
47 self
48 }
49
50 pub fn pointer_content_focus(mut self, id: Option<GlobalElementId>) -> Self {
51 self.pointer_content_focus = id;
52 self
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct MenuCloseAutoFocusGuardPolicy {
63 pub prevent_on_outside_press: bool,
65 pub prevent_on_focus_outside: bool,
67 pub prevent_on_escape: bool,
69}
70
71impl MenuCloseAutoFocusGuardPolicy {
72 pub fn for_modal(modal: bool) -> Self {
79 Self {
80 prevent_on_outside_press: !modal,
81 prevent_on_focus_outside: true,
82 prevent_on_escape: false,
83 }
84 }
85
86 pub fn prevent_always() -> Self {
88 Self {
89 prevent_on_outside_press: true,
90 prevent_on_focus_outside: true,
91 prevent_on_escape: true,
92 }
93 }
94
95 pub fn prevent_on_escape(mut self, prevent: bool) -> Self {
96 self.prevent_on_escape = prevent;
97 self
98 }
99}
100
101pub fn menu_close_auto_focus_guard_hooks<H: UiHost>(
110 cx: &mut ElementContext<'_, H>,
111 policy: MenuCloseAutoFocusGuardPolicy,
112 open: Model<bool>,
113 on_dismiss_request: Option<OnDismissRequest>,
114 on_close_auto_focus: Option<OnCloseAutoFocus>,
115) -> (Option<OnDismissRequest>, Option<OnCloseAutoFocus>) {
116 let dismiss_reason = cx.local_model(|| None::<DismissReason>);
117
118 let open_now = cx.app.models().get_copied(&open).unwrap_or(false);
120 if open_now {
121 let _ = cx.app.models_mut().update(&dismiss_reason, |v| *v = None);
122 }
123
124 let dismiss_handler: OnDismissRequest = {
125 let open_for_default_close = open.clone();
126 let dismiss_reason_for_hook = dismiss_reason.clone();
127 Arc::new(move |host, cx, req| {
128 if let Some(user) = on_dismiss_request.as_ref() {
129 user(host, cx, req);
130 }
131
132 if !req.default_prevented() {
133 let should_prevent = match req.reason {
134 DismissReason::OutsidePress { .. } => policy.prevent_on_outside_press,
135 DismissReason::FocusOutside => policy.prevent_on_focus_outside,
136 DismissReason::Escape => policy.prevent_on_escape,
137 _ => false,
138 };
139 let _ = host.models_mut().update(&dismiss_reason_for_hook, |v| {
140 *v = should_prevent.then_some(req.reason);
141 });
142 let _ = host
143 .models_mut()
144 .update(&open_for_default_close, |v| *v = false);
145 } else {
146 let _ = host
147 .models_mut()
148 .update(&dismiss_reason_for_hook, |v| *v = None);
149 }
150 })
151 };
152
153 let on_close_auto_focus: Option<OnCloseAutoFocus> = {
154 let dismiss_reason_for_close = dismiss_reason.clone();
155 let user = on_close_auto_focus.clone();
156 Some(Arc::new(move |host, cx, req| {
157 if let Some(user) = user.as_ref() {
158 user(host, cx, req);
159 }
160
161 let reason = host
162 .models_mut()
163 .read(&dismiss_reason_for_close, |v| *v)
164 .ok()
165 .flatten();
166 let _ = host
167 .models_mut()
168 .update(&dismiss_reason_for_close, |v| *v = None);
169
170 if req.default_prevented() {
171 return;
172 }
173
174 let should_prevent = match reason {
175 Some(DismissReason::OutsidePress { .. }) => policy.prevent_on_outside_press,
176 Some(DismissReason::FocusOutside) => policy.prevent_on_focus_outside,
177 Some(DismissReason::Escape) => policy.prevent_on_escape,
178 _ => false,
179 };
180 if should_prevent {
181 req.prevent_default();
182 }
183 }))
184 };
185
186 (Some(dismiss_handler), on_close_auto_focus)
187}
188
189fn base_menu_overlay_request(
190 id: GlobalElementId,
191 trigger: GlobalElementId,
192 open: Model<bool>,
193 presence: OverlayPresence,
194 children: Vec<AnyElement>,
195 modal: bool,
196) -> OverlayRequest {
197 let mut req = OverlayRequest::dismissible_popover(id, trigger, open, presence, children);
204 req.consume_outside_pointer_events = modal;
205 req.disable_outside_pointer_events = modal;
206 req
207}
208
209pub fn menu_overlay_root_name(id: GlobalElementId) -> String {
214 OverlayController::popover_root_name(id)
215}
216
217pub fn ensure_submenu<H: UiHost>(
222 cx: &mut ElementContext<'_, H>,
223 timer_handler_element: GlobalElementId,
224 cfg: sub::MenuSubmenuConfig,
225) -> sub::MenuSubmenuModels {
226 let models = sub::ensure_models_for(cx, timer_handler_element);
227 sub::install_timer_handler(cx, timer_handler_element, models.clone(), cfg);
228 models
229}
230
231pub fn sync_root_open_and_ensure_submenu<H: UiHost>(
237 cx: &mut ElementContext<'_, H>,
238 is_open: bool,
239 timer_handler_element: GlobalElementId,
240 cfg: sub::MenuSubmenuConfig,
241) -> sub::MenuSubmenuModels {
242 sub::sync_root_open_for(cx, timer_handler_element, is_open);
243 ensure_submenu(cx, timer_handler_element, cfg)
244}
245
246#[track_caller]
248pub fn with_root_name_sync_root_open_and_ensure_submenu<H: UiHost>(
249 cx: &mut ElementContext<'_, H>,
250 root_name: &str,
251 is_open: bool,
252 cfg: sub::MenuSubmenuConfig,
253) -> sub::MenuSubmenuModels {
254 let inherited = portal_inherited::PortalInherited::capture(cx);
255 portal_inherited::with_root_name_inheriting(cx, root_name, inherited, |cx| {
256 sync_root_open_and_ensure_submenu(cx, is_open, cx.root_id(), cfg)
257 })
258}
259
260pub fn submenu_pointer_move_handler(
262 models: sub::MenuSubmenuModels,
263 cfg: sub::MenuSubmenuConfig,
264) -> OnDismissiblePointerMove {
265 dismissable_layer::pointer_move_handler(move |host, acx, mv| {
266 sub::handle_dismissible_pointer_move(host, acx, mv, &models, cfg)
267 })
268}
269
270pub fn dismissible_menu_request<H: UiHost>(
278 cx: &mut ElementContext<'_, H>,
279 id: GlobalElementId,
280 trigger: GlobalElementId,
281 open: Model<bool>,
282 presence: OverlayPresence,
283 children: Vec<AnyElement>,
284 root_name: String,
285 initial_focus: MenuInitialFocusTargets,
286 on_open_auto_focus: Option<OnOpenAutoFocus>,
287 on_close_auto_focus: Option<OnCloseAutoFocus>,
288 dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
289) -> OverlayRequest {
290 dismissible_menu_request_with_modal(
291 cx,
292 id,
293 trigger,
294 open,
295 presence,
296 children,
297 root_name,
298 initial_focus,
299 on_open_auto_focus,
300 on_close_auto_focus,
301 dismissible_on_pointer_move,
302 true,
303 )
304}
305
306pub fn dismissible_menu_request_with_dismiss_handler<H: UiHost>(
309 cx: &mut ElementContext<'_, H>,
310 id: GlobalElementId,
311 trigger: GlobalElementId,
312 open: Model<bool>,
313 presence: OverlayPresence,
314 children: Vec<AnyElement>,
315 root_name: String,
316 initial_focus: MenuInitialFocusTargets,
317 on_open_auto_focus: Option<OnOpenAutoFocus>,
318 on_close_auto_focus: Option<OnCloseAutoFocus>,
319 on_dismiss_request: Option<OnDismissRequest>,
320 dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
321) -> OverlayRequest {
322 dismissible_menu_request_with_modal_and_dismiss_handler(
323 cx,
324 id,
325 trigger,
326 open,
327 presence,
328 children,
329 root_name,
330 initial_focus,
331 on_open_auto_focus,
332 on_close_auto_focus,
333 on_dismiss_request,
334 dismissible_on_pointer_move,
335 true,
336 )
337}
338
339pub fn dismissible_menu_request_with_modal<H: UiHost>(
346 cx: &mut ElementContext<'_, H>,
347 id: GlobalElementId,
348 trigger: GlobalElementId,
349 open: Model<bool>,
350 presence: OverlayPresence,
351 children: Vec<AnyElement>,
352 root_name: String,
353 initial_focus: MenuInitialFocusTargets,
354 on_open_auto_focus: Option<OnOpenAutoFocus>,
355 on_close_auto_focus: Option<OnCloseAutoFocus>,
356 dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
357 modal: bool,
358) -> OverlayRequest {
359 dismissible_menu_request_with_modal_and_dismiss_handler(
360 cx,
361 id,
362 trigger,
363 open,
364 presence,
365 children,
366 root_name,
367 initial_focus,
368 on_open_auto_focus,
369 on_close_auto_focus,
370 None,
371 dismissible_on_pointer_move,
372 modal,
373 )
374}
375
376pub fn dismissible_menu_request_with_modal_and_dismiss_handler<H: UiHost>(
379 cx: &mut ElementContext<'_, H>,
380 id: GlobalElementId,
381 trigger: GlobalElementId,
382 open: Model<bool>,
383 presence: OverlayPresence,
384 children: Vec<AnyElement>,
385 root_name: String,
386 initial_focus: MenuInitialFocusTargets,
387 on_open_auto_focus: Option<OnOpenAutoFocus>,
388 on_close_auto_focus: Option<OnCloseAutoFocus>,
389 on_dismiss_request: Option<OnDismissRequest>,
390 dismissible_on_pointer_move: Option<OnDismissiblePointerMove>,
391 modal: bool,
392) -> OverlayRequest {
393 let mut request = base_menu_overlay_request(id, trigger, open, presence, children, modal);
394 request.root_name = Some(root_name);
395 request.dismissible_on_dismiss_request = on_dismiss_request;
396 request.dismissible_on_pointer_move = dismissible_on_pointer_move;
397 request.on_open_auto_focus = on_open_auto_focus;
398 request.on_close_auto_focus = on_close_auto_focus;
399
400 let keyboard = fret_ui::input_modality::is_keyboard(cx.app, Some(cx.window));
401 request.initial_focus = if keyboard {
402 initial_focus.keyboard_entry_focus
403 } else {
404 initial_focus.pointer_content_focus
405 };
406 request
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 use std::sync::Arc;
414
415 use fret_app::App;
416 use fret_core::{
417 AppWindowId, Event, KeyCode, Modifiers, MouseButtons, Point, PointerEvent, PointerId,
418 PointerType, Px, Rect, Size,
419 };
420
421 #[test]
422 fn menu_modal_controls_underlay_pointer_blocking_and_click_through() {
423 let mut app = App::new();
424 let open = app.models_mut().insert(false);
425
426 let req = base_menu_overlay_request(
427 GlobalElementId(1),
428 GlobalElementId(2),
429 open.clone(),
430 OverlayPresence::hidden(),
431 Vec::new(),
432 true,
433 );
434 assert!(req.consume_outside_pointer_events);
435 assert!(req.disable_outside_pointer_events);
436
437 let req = base_menu_overlay_request(
438 GlobalElementId(1),
439 GlobalElementId(2),
440 open,
441 OverlayPresence::hidden(),
442 Vec::new(),
443 false,
444 );
445 assert!(!req.consume_outside_pointer_events);
446 assert!(!req.disable_outside_pointer_events);
447 }
448
449 #[test]
450 fn menu_request_can_install_dismiss_handler() {
451 let mut app = App::new();
452 let open = app.models_mut().insert(false);
453
454 let window = AppWindowId::default();
455 let bounds = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
456 let handler: OnDismissRequest =
457 Arc::new(|_host, _cx, _req: &mut fret_ui::action::DismissRequestCx| {});
458
459 fret_ui::elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
460 let req = dismissible_menu_request_with_modal_and_dismiss_handler(
461 cx,
462 GlobalElementId(1),
463 GlobalElementId(2),
464 open.clone(),
465 OverlayPresence::hidden(),
466 Vec::new(),
467 "menu".to_string(),
468 MenuInitialFocusTargets::new(),
469 None,
470 None,
471 Some(handler.clone()),
472 None,
473 true,
474 );
475 assert!(req.dismissible_on_dismiss_request.is_some());
476 });
477 }
478
479 #[test]
480 fn menu_request_gates_initial_focus_by_modality() {
481 let mut app = App::new();
482 let open = app.models_mut().insert(false);
483
484 let window = AppWindowId::default();
485 let bounds = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
486
487 let pointer_focus = GlobalElementId(0x111);
488 let keyboard_focus = GlobalElementId(0x222);
489
490 fret_ui::elements::with_element_cx(&mut app, window, bounds, "test", |cx| {
491 fret_ui::input_modality::update_for_event(
493 cx.app,
494 window,
495 &Event::Pointer(PointerEvent::Move {
496 position: Point::new(Px(1.0), Px(2.0)),
497 buttons: MouseButtons::default(),
498 modifiers: Modifiers::default(),
499 pointer_id: PointerId(0),
500 pointer_type: PointerType::Mouse,
501 }),
502 );
503
504 let req = dismissible_menu_request(
505 cx,
506 GlobalElementId(1),
507 GlobalElementId(2),
508 open.clone(),
509 OverlayPresence::hidden(),
510 Vec::new(),
511 "menu".to_string(),
512 MenuInitialFocusTargets::new()
513 .pointer_content_focus(Some(pointer_focus))
514 .keyboard_entry_focus(Some(keyboard_focus)),
515 None,
516 None,
517 None,
518 );
519 assert_eq!(req.initial_focus, Some(pointer_focus));
520
521 fret_ui::input_modality::update_for_event(
523 cx.app,
524 window,
525 &Event::KeyDown {
526 key: KeyCode::KeyA,
527 modifiers: Modifiers::default(),
528 repeat: false,
529 },
530 );
531 let req = dismissible_menu_request(
532 cx,
533 GlobalElementId(1),
534 GlobalElementId(2),
535 open.clone(),
536 OverlayPresence::hidden(),
537 Vec::new(),
538 "menu".to_string(),
539 MenuInitialFocusTargets::new()
540 .pointer_content_focus(Some(pointer_focus))
541 .keyboard_entry_focus(Some(keyboard_focus)),
542 None,
543 None,
544 None,
545 );
546 assert_eq!(req.initial_focus, Some(keyboard_focus));
547 });
548 }
549}