1use std::sync::Arc;
15use std::time::Duration;
16
17use fret_core::{Point, Px, Rect, Size};
18use fret_runtime::{Effect, Model, TimerToken};
19use fret_ui::action::{ActionCx, PointerMoveCx, UiActionHost, UiFocusActionHost};
20use fret_ui::overlay_placement::{
21 Align, AnchoredPanelOptions, Offset, ShiftOptions, Side, StickyMode, anchored_panel_layout,
22};
23use fret_ui::{ElementContext, GlobalElementId, UiHost};
24
25use crate::overlay;
26use crate::primitives::direction::LayoutDirection;
27use crate::primitives::menu::pointer_grace_intent;
28
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct MenuSubmenuGeometry {
31 pub reference: Rect,
32 pub floating: Rect,
33}
34
35pub const DEFAULT_POINTER_GRACE_TIMEOUT: Duration = Duration::from_millis(300);
37
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct MenuSubmenuConfig {
40 pub safe_hover_buffer: Px,
41 pub open_delay: Duration,
45 pub close_delay: Duration,
46 pub focus_delay: Duration,
47 pub pointer_grace_timeout: Duration,
49}
50
51impl MenuSubmenuConfig {
52 pub fn new(
53 safe_hover_buffer: Px,
54 open_delay: Duration,
55 close_delay: Duration,
56 focus_delay: Duration,
57 ) -> Self {
58 Self {
59 safe_hover_buffer,
60 open_delay,
61 close_delay,
62 focus_delay,
63 pointer_grace_timeout: DEFAULT_POINTER_GRACE_TIMEOUT,
64 }
65 }
66
67 pub fn pointer_grace_timeout(mut self, timeout: Duration) -> Self {
68 self.pointer_grace_timeout = timeout;
69 self
70 }
71}
72
73impl Default for MenuSubmenuConfig {
74 fn default() -> Self {
75 Self {
76 safe_hover_buffer: Px(6.0),
77 open_delay: Duration::from_millis(100),
78 close_delay: Duration::from_millis(120),
79 focus_delay: Duration::from_millis(0),
80 pointer_grace_timeout: DEFAULT_POINTER_GRACE_TIMEOUT,
81 }
82 }
83}
84
85pub fn default_submenu_bounds(
87 outer: Rect,
88 trigger_anchor: Rect,
89 desired: Size,
90 dir: LayoutDirection,
91) -> Rect {
92 let desired_h = desired.height.0.max(0.0);
98 let outer_bottom = outer.origin.y.0 + outer.size.height.0.max(0.0);
99 let trigger_top = trigger_anchor.origin.y.0;
100 let align = if trigger_top + desired_h > outer_bottom {
101 Align::End
102 } else {
103 Align::Start
104 };
105
106 let direction = match dir {
107 LayoutDirection::Ltr => fret_ui::overlay_placement::LayoutDirection::Ltr,
108 LayoutDirection::Rtl => fret_ui::overlay_placement::LayoutDirection::Rtl,
109 };
110 let side = match dir {
111 LayoutDirection::Ltr => Side::Right,
112 LayoutDirection::Rtl => Side::Left,
113 };
114
115 let options = AnchoredPanelOptions {
119 direction,
120 offset: Offset::default(),
121 shift: ShiftOptions {
122 main_axis: false,
123 cross_axis: true,
124 },
125 arrow: None,
126 collision: Default::default(),
127 sticky: StickyMode::Partial,
128 };
129
130 anchored_panel_layout(
131 outer,
132 trigger_anchor,
133 desired,
134 Px(2.0),
135 side,
136 align,
137 options,
138 )
139 .rect
140}
141
142pub fn estimated_panel_height_for_row_count(
148 row_height: Px,
149 row_count: usize,
150 max_height: Px,
151) -> Px {
152 let rows = row_count.max(1) as f32;
153 let min_h = row_height.0.max(0.0);
154 let max_h = max_height.0.max(min_h);
155 Px((row_height.0 * rows).clamp(min_h, max_h))
156}
157
158pub fn estimated_desired_size_for_row_count(
160 desired_width: Px,
161 row_height: Px,
162 row_count: usize,
163 max_height: Px,
164) -> Size {
165 Size::new(
166 Px(desired_width.0.max(0.0)),
167 estimated_panel_height_for_row_count(row_height, row_count, max_height),
168 )
169}
170
171pub fn clear_focus_target_in_models<H: UiHost>(
172 cx: &mut ElementContext<'_, H>,
173 models: &MenuSubmenuModels,
174) {
175 clear_focus_target(cx, &models.focus_target);
176}
177
178pub fn sync_open_geometry_from_trigger_if_present<H: UiHost>(
183 cx: &mut ElementContext<'_, H>,
184 models: &MenuSubmenuModels,
185 outer: Rect,
186 desired: Size,
187) {
188 let trigger = cx
189 .app
190 .models_mut()
191 .read(&models.trigger, |v| *v)
192 .ok()
193 .flatten();
194 if let Some(trigger) = trigger {
195 set_geometry_from_element_anchor_if_present(cx, trigger, models, outer, desired);
196 }
197}
198
199pub fn with_open_submenu<H: UiHost, R>(
200 cx: &mut ElementContext<'_, H>,
201 models: &MenuSubmenuModels,
202 outer: Rect,
203 desired: Size,
204 f: impl FnOnce(&mut ElementContext<'_, H>, Arc<str>, MenuSubmenuGeometry) -> R,
205) -> Option<R> {
206 let open_value = cx
207 .app
208 .models_mut()
209 .read(&models.open_value, |v| v.clone())
210 .ok()
211 .flatten()?;
212
213 clear_focus_target_in_models(cx, models);
214
215 let geometry = resolve_open_geometry(cx, models, outer, desired)?;
216 Some(f(cx, open_value, geometry))
217}
218
219pub fn with_open_submenu_synced<H: UiHost, R>(
222 cx: &mut ElementContext<'_, H>,
223 models: &MenuSubmenuModels,
224 outer: Rect,
225 desired: Size,
226 f: impl FnOnce(&mut ElementContext<'_, H>, Arc<str>, MenuSubmenuGeometry) -> R,
227) -> Option<R> {
228 let open_value = cx
229 .app
230 .models_mut()
231 .read(&models.open_value, |v| v.clone())
232 .ok()
233 .flatten()?;
234
235 clear_focus_target_in_models(cx, models);
236 sync_open_geometry_from_trigger_if_present(cx, models, outer, desired);
237
238 let geometry = resolve_open_geometry(cx, models, outer, desired)?;
239 Some(f(cx, open_value, geometry))
240}
241
242pub fn resolve_open_geometry<H: UiHost>(
243 cx: &mut ElementContext<'_, H>,
244 models: &MenuSubmenuModels,
245 outer: Rect,
246 desired: Size,
247) -> Option<MenuSubmenuGeometry> {
248 let geometry = cx
249 .app
250 .models_mut()
251 .read(&models.geometry, |v| *v)
252 .ok()
253 .flatten();
254 if let Some(geometry) = geometry {
255 return Some(geometry);
256 }
257
258 let trigger = cx
259 .app
260 .models_mut()
261 .read(&models.trigger, |v| *v)
262 .ok()
263 .flatten()?;
264 let trigger_anchor = overlay::anchor_bounds_for_element(cx, trigger)?;
265 let dir = crate::primitives::direction::use_direction_in_scope(cx, None);
266 let placed = default_submenu_bounds(outer, trigger_anchor, desired, dir);
267 let geometry = MenuSubmenuGeometry {
268 reference: trigger_anchor,
269 floating: placed,
270 };
271 set_geometry_if_changed(cx, geometry, &models.geometry);
272 Some(geometry)
273}
274
275pub fn set_geometry_from_element_anchor_if_present<H: UiHost>(
281 cx: &mut ElementContext<'_, H>,
282 element: GlobalElementId,
283 models: &MenuSubmenuModels,
284 outer: Rect,
285 desired: Size,
286) {
287 let Some(anchor) = overlay::anchor_bounds_for_element(cx, element) else {
288 return;
289 };
290
291 let dir = crate::primitives::direction::use_direction_in_scope(cx, None);
292 let floating = default_submenu_bounds(outer, anchor, desired, dir);
293 let geometry = MenuSubmenuGeometry {
294 reference: anchor,
295 floating,
296 };
297 set_geometry_if_changed(cx, geometry, &models.geometry);
298}
299
300#[derive(Debug, Clone)]
301pub struct MenuSubmenuModels {
302 pub open_value: Model<Option<Arc<str>>>,
303 pub trigger: Model<Option<GlobalElementId>>,
304 pub last_pointer: Model<Option<Point>>,
305 pub geometry: Model<Option<MenuSubmenuGeometry>>,
306 pub close_timer: Model<Option<TimerToken>>,
307 pub pointer_dir: Model<Option<pointer_grace_intent::GraceSide>>,
308 pub pointer_grace_intent: Model<Option<pointer_grace_intent::GraceIntent>>,
309 pub pointer_grace_timer: Model<Option<TimerToken>>,
310 pub focus_target: Model<Option<GlobalElementId>>,
311 pub focus_timer: Model<Option<TimerToken>>,
312 pub focus_retry_attempts: Model<u32>,
313 pub pending_open_value: Model<Option<Arc<str>>>,
314 pub pending_open_trigger: Model<Option<GlobalElementId>>,
315 pub open_timer: Model<Option<TimerToken>>,
316}
317
318#[derive(Default)]
319struct MenuSubmenuState {
320 open_value: Option<Model<Option<Arc<str>>>>,
321 trigger: Option<Model<Option<GlobalElementId>>>,
322 last_pointer: Option<Model<Option<Point>>>,
323 geometry: Option<Model<Option<MenuSubmenuGeometry>>>,
324 close_timer: Option<Model<Option<TimerToken>>>,
325 pointer_dir: Option<Model<Option<pointer_grace_intent::GraceSide>>>,
326 pointer_grace_intent: Option<Model<Option<pointer_grace_intent::GraceIntent>>>,
327 pointer_grace_timer: Option<Model<Option<TimerToken>>>,
328 focus_target: Option<Model<Option<GlobalElementId>>>,
329 focus_timer: Option<Model<Option<TimerToken>>>,
330 focus_retry_attempts: Option<Model<u32>>,
331 pending_open_value: Option<Model<Option<Arc<str>>>>,
332 pending_open_trigger: Option<Model<Option<GlobalElementId>>>,
333 open_timer: Option<Model<Option<TimerToken>>>,
334 was_open: bool,
335}
336
337fn cancel_timer(host: &mut dyn UiActionHost, timer: &Model<Option<TimerToken>>) {
338 let token = host.models_mut().read(timer, |v| *v).ok().flatten();
339 if let Some(token) = token {
340 host.push_effect(Effect::CancelTimer { token });
341 }
342 let _ = host.models_mut().update(timer, |v| *v = None);
343}
344
345fn cancel_timer_in_element_context<H: UiHost>(
346 cx: &mut ElementContext<'_, H>,
347 timer: &Model<Option<TimerToken>>,
348) {
349 let token = cx.app.models_mut().read(timer, |v| *v).ok().flatten();
350 if let Some(token) = token {
351 cx.app.push_effect(Effect::CancelTimer { token });
352 }
353 let _ = cx.app.models_mut().update(timer, |v| *v = None);
354}
355
356fn cancel_timer_if_matches(
357 host: &mut dyn UiActionHost,
358 timer: &Model<Option<TimerToken>>,
359 token: TimerToken,
360) {
361 let armed = host.models_mut().read(timer, |v| *v).ok().flatten();
362 if armed != Some(token) {
363 return;
364 }
365 let _ = host.models_mut().update(timer, |v| *v = None);
366}
367
368pub fn cancel_close_timer(host: &mut dyn UiActionHost, close_timer: &Model<Option<TimerToken>>) {
369 cancel_timer(host, close_timer);
370}
371
372pub fn cancel_focus_timer(host: &mut dyn UiActionHost, focus_timer: &Model<Option<TimerToken>>) {
373 cancel_timer(host, focus_timer);
374}
375
376pub fn sync_root_open_for<H: UiHost>(
377 cx: &mut ElementContext<'_, H>,
378 timer_handler_element: GlobalElementId,
379 is_open: bool,
380) {
381 let was_open = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
382 st.was_open
383 });
384 if is_open && !was_open {
385 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
386 st.open_value.clone()
387 }) {
388 let _ = cx.app.models_mut().update(&model, |v| *v = None);
389 }
390 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
391 st.trigger.clone()
392 }) {
393 let _ = cx.app.models_mut().update(&model, |v| *v = None);
394 }
395 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
396 st.last_pointer.clone()
397 }) {
398 let _ = cx.app.models_mut().update(&model, |v| *v = None);
399 }
400 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
401 st.geometry.clone()
402 }) {
403 let _ = cx.app.models_mut().update(&model, |v| *v = None);
404 }
405 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
406 st.close_timer.clone()
407 }) {
408 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
409 if let Some(token) = token {
410 cx.app.push_effect(Effect::CancelTimer { token });
411 }
412 let _ = cx.app.models_mut().update(&model, |v| *v = None);
413 }
414 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
415 st.focus_timer.clone()
416 }) {
417 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
418 if let Some(token) = token {
419 cx.app.push_effect(Effect::CancelTimer { token });
420 }
421 let _ = cx.app.models_mut().update(&model, |v| *v = None);
422 }
423 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
424 st.open_timer.clone()
425 }) {
426 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
427 if let Some(token) = token {
428 cx.app.push_effect(Effect::CancelTimer { token });
429 }
430 let _ = cx.app.models_mut().update(&model, |v| *v = None);
431 }
432 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
433 st.pointer_dir.clone()
434 }) {
435 let _ = cx.app.models_mut().update(&model, |v| *v = None);
436 }
437 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
438 st.pointer_grace_intent.clone()
439 }) {
440 let _ = cx.app.models_mut().update(&model, |v| *v = None);
441 }
442 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
443 st.pointer_grace_timer.clone()
444 }) {
445 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
446 if let Some(token) = token {
447 cx.app.push_effect(Effect::CancelTimer { token });
448 }
449 let _ = cx.app.models_mut().update(&model, |v| *v = None);
450 }
451 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
452 st.pending_open_value.clone()
453 }) {
454 let _ = cx.app.models_mut().update(&model, |v| *v = None);
455 }
456 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
457 st.pending_open_trigger.clone()
458 }) {
459 let _ = cx.app.models_mut().update(&model, |v| *v = None);
460 }
461 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
462 st.focus_target.clone()
463 }) {
464 let _ = cx.app.models_mut().update(&model, |v| *v = None);
465 }
466 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
467 st.was_open = true
468 });
469 } else if !is_open && was_open {
470 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
471 st.close_timer.clone()
472 }) {
473 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
474 if let Some(token) = token {
475 cx.app.push_effect(Effect::CancelTimer { token });
476 }
477 let _ = cx.app.models_mut().update(&model, |v| *v = None);
478 }
479 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
480 st.focus_timer.clone()
481 }) {
482 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
483 if let Some(token) = token {
484 cx.app.push_effect(Effect::CancelTimer { token });
485 }
486 let _ = cx.app.models_mut().update(&model, |v| *v = None);
487 }
488 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
489 st.open_timer.clone()
490 }) {
491 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
492 if let Some(token) = token {
493 cx.app.push_effect(Effect::CancelTimer { token });
494 }
495 let _ = cx.app.models_mut().update(&model, |v| *v = None);
496 }
497 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
498 st.pointer_dir.clone()
499 }) {
500 let _ = cx.app.models_mut().update(&model, |v| *v = None);
501 }
502 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
503 st.pointer_grace_intent.clone()
504 }) {
505 let _ = cx.app.models_mut().update(&model, |v| *v = None);
506 }
507 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
508 st.pointer_grace_timer.clone()
509 }) {
510 let token = cx.app.models_mut().read(&model, |v| *v).ok().flatten();
511 if let Some(token) = token {
512 cx.app.push_effect(Effect::CancelTimer { token });
513 }
514 let _ = cx.app.models_mut().update(&model, |v| *v = None);
515 }
516 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
517 st.pending_open_value.clone()
518 }) {
519 let _ = cx.app.models_mut().update(&model, |v| *v = None);
520 }
521 if let Some(model) = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
522 st.pending_open_trigger.clone()
523 }) {
524 let _ = cx.app.models_mut().update(&model, |v| *v = None);
525 }
526 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
527 st.was_open = false
528 });
529 }
530}
531
532pub fn sync_root_open<H: UiHost>(cx: &mut ElementContext<'_, H>, is_open: bool) {
533 sync_root_open_for(cx, cx.root_id(), is_open);
534}
535
536pub fn ensure_models_for<H: UiHost>(
537 cx: &mut ElementContext<'_, H>,
538 timer_handler_element: GlobalElementId,
539) -> MenuSubmenuModels {
540 let open_value = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
541 st.open_value.clone()
542 });
543 let open_value = if let Some(open_value) = open_value {
544 open_value
545 } else {
546 let open_value = cx.app.models_mut().insert(None);
547 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
548 st.open_value = Some(open_value.clone());
549 });
550 open_value
551 };
552
553 let trigger = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
554 st.trigger.clone()
555 });
556 let trigger = if let Some(trigger) = trigger {
557 trigger
558 } else {
559 let trigger = cx.app.models_mut().insert(None);
560 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
561 st.trigger = Some(trigger.clone());
562 });
563 trigger
564 };
565
566 let last_pointer = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
567 st.last_pointer.clone()
568 });
569 let last_pointer = if let Some(last_pointer) = last_pointer {
570 last_pointer
571 } else {
572 let last_pointer = cx.app.models_mut().insert(None);
573 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
574 st.last_pointer = Some(last_pointer.clone());
575 });
576 last_pointer
577 };
578
579 let geometry = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
580 st.geometry.clone()
581 });
582 let geometry = if let Some(geometry) = geometry {
583 geometry
584 } else {
585 let geometry = cx.app.models_mut().insert(None);
586 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
587 st.geometry = Some(geometry.clone());
588 });
589 geometry
590 };
591
592 let close_timer = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
593 st.close_timer.clone()
594 });
595 let close_timer = if let Some(close_timer) = close_timer {
596 close_timer
597 } else {
598 let close_timer = cx.app.models_mut().insert(None);
599 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
600 st.close_timer = Some(close_timer.clone());
601 });
602 close_timer
603 };
604
605 let pointer_dir = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
606 st.pointer_dir.clone()
607 });
608 let pointer_dir = if let Some(pointer_dir) = pointer_dir {
609 pointer_dir
610 } else {
611 let pointer_dir = cx.app.models_mut().insert(None);
612 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
613 st.pointer_dir = Some(pointer_dir.clone());
614 });
615 pointer_dir
616 };
617
618 let pointer_grace_intent =
619 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
620 st.pointer_grace_intent.clone()
621 });
622 let pointer_grace_intent = if let Some(pointer_grace_intent) = pointer_grace_intent {
623 pointer_grace_intent
624 } else {
625 let pointer_grace_intent = cx.app.models_mut().insert(None);
626 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
627 st.pointer_grace_intent = Some(pointer_grace_intent.clone());
628 });
629 pointer_grace_intent
630 };
631
632 let pointer_grace_timer =
633 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
634 st.pointer_grace_timer.clone()
635 });
636 let pointer_grace_timer = if let Some(pointer_grace_timer) = pointer_grace_timer {
637 pointer_grace_timer
638 } else {
639 let pointer_grace_timer = cx.app.models_mut().insert(None);
640 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
641 st.pointer_grace_timer = Some(pointer_grace_timer.clone());
642 });
643 pointer_grace_timer
644 };
645
646 let focus_target = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
647 st.focus_target.clone()
648 });
649 let focus_target = if let Some(focus_target) = focus_target {
650 focus_target
651 } else {
652 let focus_target = cx.app.models_mut().insert(None);
653 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
654 st.focus_target = Some(focus_target.clone());
655 });
656 focus_target
657 };
658
659 let focus_timer = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
660 st.focus_timer.clone()
661 });
662 let focus_timer = if let Some(focus_timer) = focus_timer {
663 focus_timer
664 } else {
665 let focus_timer = cx.app.models_mut().insert(None);
666 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
667 st.focus_timer = Some(focus_timer.clone());
668 });
669 focus_timer
670 };
671
672 let focus_retry_attempts =
673 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
674 st.focus_retry_attempts.clone()
675 });
676 let focus_retry_attempts = if let Some(focus_retry_attempts) = focus_retry_attempts {
677 focus_retry_attempts
678 } else {
679 let focus_retry_attempts = cx.app.models_mut().insert(0);
680 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
681 st.focus_retry_attempts = Some(focus_retry_attempts.clone());
682 });
683 focus_retry_attempts
684 };
685
686 let pending_open_value = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
687 st.pending_open_value.clone()
688 });
689 let pending_open_value = if let Some(pending_open_value) = pending_open_value {
690 pending_open_value
691 } else {
692 let pending_open_value = cx.app.models_mut().insert(None);
693 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
694 st.pending_open_value = Some(pending_open_value.clone());
695 });
696 pending_open_value
697 };
698
699 let pending_open_trigger =
700 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
701 st.pending_open_trigger.clone()
702 });
703 let pending_open_trigger = if let Some(pending_open_trigger) = pending_open_trigger {
704 pending_open_trigger
705 } else {
706 let pending_open_trigger = cx.app.models_mut().insert(None);
707 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
708 st.pending_open_trigger = Some(pending_open_trigger.clone());
709 });
710 pending_open_trigger
711 };
712
713 let open_timer = cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
714 st.open_timer.clone()
715 });
716 let open_timer = if let Some(open_timer) = open_timer {
717 open_timer
718 } else {
719 let open_timer = cx.app.models_mut().insert(None);
720 cx.state_for(timer_handler_element, MenuSubmenuState::default, |st| {
721 st.open_timer = Some(open_timer.clone());
722 });
723 open_timer
724 };
725
726 MenuSubmenuModels {
727 open_value,
728 trigger,
729 last_pointer,
730 geometry,
731 close_timer,
732 pointer_dir,
733 pointer_grace_intent,
734 pointer_grace_timer,
735 focus_target,
736 focus_timer,
737 focus_retry_attempts,
738 pending_open_value,
739 pending_open_trigger,
740 open_timer,
741 }
742}
743
744pub fn ensure_models<H: UiHost>(cx: &mut ElementContext<'_, H>) -> MenuSubmenuModels {
745 ensure_models_for(cx, cx.root_id())
746}
747
748pub fn on_timer_handler(
749 models: MenuSubmenuModels,
750 cfg: MenuSubmenuConfig,
751) -> fret_ui::action::OnTimer {
752 #[allow(clippy::arc_with_non_send_sync)]
753 Arc::new(move |host, acx, token| {
754 let close_armed = host
755 .models_mut()
756 .read(&models.close_timer, |v| *v)
757 .ok()
758 .flatten();
759 let focus_armed = host
760 .models_mut()
761 .read(&models.focus_timer, |v| *v)
762 .ok()
763 .flatten();
764 let open_armed = host
765 .models_mut()
766 .read(&models.open_timer, |v| *v)
767 .ok()
768 .flatten();
769 let pointer_grace_armed = host
770 .models_mut()
771 .read(&models.pointer_grace_timer, |v| *v)
772 .ok()
773 .flatten();
774
775 if close_armed == Some(token) {
776 cancel_timer(host, &models.open_timer);
777 cancel_timer(host, &models.focus_timer);
778 cancel_timer(host, &models.pointer_grace_timer);
779 let _ = host.models_mut().update(&models.open_value, |v| *v = None);
780 let _ = host.models_mut().update(&models.trigger, |v| *v = None);
781 let _ = host
782 .models_mut()
783 .update(&models.last_pointer, |v| *v = None);
784 let _ = host.models_mut().update(&models.pointer_dir, |v| *v = None);
785 let _ = host
786 .models_mut()
787 .update(&models.pointer_grace_intent, |v| *v = None);
788 let _ = host.models_mut().update(&models.geometry, |v| *v = None);
789 let _ = host
790 .models_mut()
791 .update(&models.pending_open_value, |v| *v = None);
792 let _ = host
793 .models_mut()
794 .update(&models.pending_open_trigger, |v| *v = None);
795 cancel_timer_if_matches(host, &models.close_timer, token);
796 host.request_redraw(acx.window);
797 return true;
798 }
799
800 if pointer_grace_armed == Some(token) {
801 cancel_timer_if_matches(host, &models.pointer_grace_timer, token);
802 let _ = host
803 .models_mut()
804 .update(&models.pointer_grace_intent, |v| *v = None);
805 host.request_redraw(acx.window);
806 return true;
807 }
808
809 if open_armed == Some(token) {
810 let pending_value = host
811 .models_mut()
812 .read(&models.pending_open_value, |v| v.clone())
813 .ok()
814 .flatten();
815 let pending_trigger = host
816 .models_mut()
817 .read(&models.pending_open_trigger, |v| *v)
818 .ok()
819 .flatten();
820
821 cancel_timer_if_matches(host, &models.open_timer, token);
822
823 let Some(pending_value) = pending_value else {
824 return false;
825 };
826
827 let open_value = host
828 .models_mut()
829 .read(&models.open_value, |v| v.clone())
830 .ok()
831 .flatten();
832 let pointer = host
833 .models_mut()
834 .read(&models.last_pointer, |v| *v)
835 .ok()
836 .flatten();
837 let pointer_dir = host
838 .models_mut()
839 .read(&models.pointer_dir, |v| *v)
840 .ok()
841 .flatten();
842 let grace_intent = host
843 .models_mut()
844 .read(&models.pointer_grace_intent, |v| *v)
845 .ok()
846 .flatten();
847
848 let switching_away = open_value
849 .as_ref()
850 .is_some_and(|cur| cur.as_ref() != pending_value.as_ref());
851
852 let moving_towards = grace_intent
853 .as_ref()
854 .is_some_and(|intent| pointer_dir == Some(intent.side));
855 let in_grace_area = match (pointer, grace_intent) {
856 (Some(pointer), Some(intent)) => {
857 pointer_grace_intent::is_pointer_in_grace_area(pointer, intent)
858 }
859 _ => false,
860 };
861 if switching_away && moving_towards && in_grace_area {
862 let token = host.next_timer_token();
863 host.push_effect(Effect::SetTimer {
864 window: Some(acx.window),
865 token,
866 after: cfg.open_delay,
867 repeat: None,
868 });
869 let _ = host
870 .models_mut()
871 .update(&models.open_timer, |v| *v = Some(token));
872 host.request_redraw(acx.window);
873 return true;
874 }
875
876 let _ = host
877 .models_mut()
878 .update(&models.pending_open_value, |v| *v = None);
879 let _ = host
880 .models_mut()
881 .update(&models.pending_open_trigger, |v| *v = None);
882 cancel_timer(host, &models.pointer_grace_timer);
883 let _ = host
884 .models_mut()
885 .update(&models.pointer_grace_intent, |v| *v = None);
886
887 let _ = host
888 .models_mut()
889 .update(&models.open_value, |v| *v = Some(pending_value));
890 let _ = host
891 .models_mut()
892 .update(&models.trigger, |v| *v = pending_trigger);
893 let _ = host.models_mut().update(&models.geometry, |v| *v = None);
894 host.request_redraw(acx.window);
895 return true;
896 }
897
898 if focus_armed == Some(token) {
899 const MAX_FOCUS_RETRY_ATTEMPTS: u32 = 4;
900
901 let target = host
902 .models_mut()
903 .read(&models.focus_target, |v| *v)
904 .ok()
905 .flatten();
906 if let Some(target) = target {
907 host.request_focus(target);
908 cancel_timer_if_matches(host, &models.focus_timer, token);
909 let _ = host
910 .models_mut()
911 .update(&models.focus_retry_attempts, |v| *v = 0);
912 host.request_redraw(acx.window);
913 return true;
914 }
915
916 let attempts = host
917 .models_mut()
918 .read(&models.focus_retry_attempts, |v| *v)
919 .ok()
920 .unwrap_or(0);
921 if attempts >= MAX_FOCUS_RETRY_ATTEMPTS {
922 cancel_timer_if_matches(host, &models.focus_timer, token);
923 let _ = host
924 .models_mut()
925 .update(&models.focus_retry_attempts, |v| *v = 0);
926 host.request_redraw(acx.window);
927 return true;
928 }
929
930 let retry_token = host.next_timer_token();
931 let retry_after = if attempts == 0 {
932 Duration::from_millis(0)
933 } else {
934 Duration::from_millis(16)
935 };
936 let _ = host.models_mut().update(&models.focus_timer, |v| {
937 if *v == Some(token) {
938 *v = Some(retry_token);
939 }
940 });
941 let _ = host.models_mut().update(&models.focus_retry_attempts, |v| {
942 *v = attempts.saturating_add(1);
943 });
944 host.push_effect(Effect::SetTimer {
945 window: Some(acx.window),
946 token: retry_token,
947 after: retry_after,
948 repeat: None,
949 });
950 host.request_redraw(acx.window);
951 return true;
952 }
953
954 false
955 })
956}
957
958pub fn install_timer_handler<H: UiHost>(
959 cx: &mut ElementContext<'_, H>,
960 element: GlobalElementId,
961 models: MenuSubmenuModels,
962 cfg: MenuSubmenuConfig,
963) {
964 cx.timer_on_timer_for(element, on_timer_handler(models, cfg));
965}
966
967pub fn handle_dismissible_pointer_move(
968 host: &mut dyn UiActionHost,
969 acx: ActionCx,
970 mv: PointerMoveCx,
971 models: &MenuSubmenuModels,
972 cfg: MenuSubmenuConfig,
973) -> bool {
974 let prev_pointer = host
975 .models_mut()
976 .read(&models.last_pointer, |v| *v)
977 .ok()
978 .flatten();
979 let prev_dir = host
980 .models_mut()
981 .read(&models.pointer_dir, |v| *v)
982 .ok()
983 .flatten();
984
985 let geometry = host
986 .models_mut()
987 .read(&models.geometry, |v| *v)
988 .ok()
989 .flatten();
990 let grace = geometry.map(|g| pointer_grace_intent::PointerGraceIntentGeometry {
991 reference: g.reference,
992 floating: g.floating,
993 });
994
995 let submenu_open = host
999 .models_mut()
1000 .read(&models.open_value, |v| v.is_some())
1001 .ok()
1002 .unwrap_or(false);
1003 if !submenu_open {
1004 let next_dir = match prev_pointer {
1005 None => prev_dir,
1006 Some(prev) => match pointer_grace_intent::pointer_dir(prev, mv.position) {
1007 Some(dir) => Some(dir),
1008 None => prev_dir,
1009 },
1010 };
1011 let _ = host
1012 .models_mut()
1013 .update(&models.pointer_dir, |v| *v = next_dir);
1014 let _ = host
1015 .models_mut()
1016 .update(&models.last_pointer, |v| *v = Some(mv.position));
1017 return false;
1018 }
1019
1020 if grace.is_none() {
1021 let next_dir = match prev_pointer {
1022 None => prev_dir,
1023 Some(prev) => match pointer_grace_intent::pointer_dir(prev, mv.position) {
1024 Some(dir) => Some(dir),
1025 None => prev_dir,
1026 },
1027 };
1028 let _ = host
1029 .models_mut()
1030 .update(&models.pointer_dir, |v| *v = next_dir);
1031 let _ = host
1032 .models_mut()
1033 .update(&models.last_pointer, |v| *v = Some(mv.position));
1034
1035 let pending = host
1036 .models_mut()
1037 .read(&models.close_timer, |v| *v)
1038 .ok()
1039 .flatten();
1040 if pending.is_some() {
1041 return false;
1042 }
1043
1044 let token = host.next_timer_token();
1045 host.push_effect(Effect::SetTimer {
1046 window: Some(acx.window),
1047 token,
1048 after: cfg.close_delay,
1049 repeat: None,
1050 });
1051 let _ = host
1052 .models_mut()
1053 .update(&models.close_timer, |v| *v = Some(token));
1054 host.request_redraw(acx.window);
1055 return true;
1056 }
1057
1058 let changed = pointer_grace_intent::drive_close_timer_on_pointer_move(
1059 host,
1060 acx,
1061 mv,
1062 grace,
1063 pointer_grace_intent::PointerGraceIntentConfig::new(cfg.safe_hover_buffer, cfg.close_delay),
1064 &models.last_pointer,
1065 &models.close_timer,
1066 );
1067
1068 let next_dir = match prev_pointer {
1069 None => prev_dir,
1070 Some(prev) => match pointer_grace_intent::pointer_dir(prev, mv.position) {
1071 Some(dir) => Some(dir),
1072 None => prev_dir,
1073 },
1074 };
1075 let _ = host
1076 .models_mut()
1077 .update(&models.pointer_dir, |v| *v = next_dir);
1078
1079 let mut did_update_grace_intent = false;
1080 if let Some(grace) = grace {
1081 let exit_reference = Rect {
1086 origin: grace.reference.origin,
1087 size: Size::new(
1088 Px((grace.reference.size.width.0 - 1.0).max(0.0)),
1089 Px((grace.reference.size.height.0 - 1.0).max(0.0)),
1090 ),
1091 };
1092 if grace.floating.contains(mv.position) {
1093 cancel_timer(host, &models.pointer_grace_timer);
1094 let _ = host
1095 .models_mut()
1096 .update(&models.pointer_grace_intent, |v| *v = None);
1097 did_update_grace_intent = true;
1098 } else if prev_pointer.is_some_and(|prev| exit_reference.contains(prev))
1099 && !exit_reference.contains(mv.position)
1100 {
1101 let submenu_open = host
1102 .models_mut()
1103 .read(&models.open_value, |v| v.is_some())
1104 .ok()
1105 .unwrap_or(false);
1106 if submenu_open
1107 && let Some(intent) =
1108 pointer_grace_intent::grace_intent_from_exit_point(mv.position, grace, Px(5.0))
1109 {
1110 let _ = host
1111 .models_mut()
1112 .update(&models.pointer_grace_intent, |v| *v = Some(intent));
1113 cancel_timer(host, &models.pointer_grace_timer);
1114 let token = host.next_timer_token();
1115 host.push_effect(Effect::SetTimer {
1116 window: Some(acx.window),
1117 token,
1118 after: cfg.pointer_grace_timeout,
1119 repeat: None,
1120 });
1121 let _ = host
1122 .models_mut()
1123 .update(&models.pointer_grace_timer, |v| *v = Some(token));
1124 did_update_grace_intent = true;
1125 }
1126 }
1127 }
1128
1129 if did_update_grace_intent {
1130 host.request_redraw(acx.window);
1131 }
1132
1133 changed || did_update_grace_intent
1134}
1135
1136pub fn set_geometry_if_changed<H: UiHost>(
1137 cx: &mut ElementContext<'_, H>,
1138 geometry: MenuSubmenuGeometry,
1139 geometry_model: &Model<Option<MenuSubmenuGeometry>>,
1140) {
1141 let _ = cx.app.models_mut().update(geometry_model, |v| {
1142 if v.as_ref() == Some(&geometry) {
1143 return;
1144 }
1145 *v = Some(geometry);
1146 });
1147}
1148
1149pub fn set_trigger_if_none<H: UiHost>(
1150 cx: &mut ElementContext<'_, H>,
1151 trigger_id: GlobalElementId,
1152 trigger_model: &Model<Option<GlobalElementId>>,
1153) {
1154 let _ = cx.app.models_mut().update(trigger_model, |v| {
1155 if v.is_none() {
1156 *v = Some(trigger_id);
1157 }
1158 });
1159}
1160
1161pub fn clear_focus_target<H: UiHost>(
1162 cx: &mut ElementContext<'_, H>,
1163 focus_target: &Model<Option<GlobalElementId>>,
1164) {
1165 let _ = cx.app.models_mut().update(focus_target, |v| *v = None);
1166}
1167
1168pub fn set_focus_target_if_none<H: UiHost>(
1169 cx: &mut ElementContext<'_, H>,
1170 focus_target: &Model<Option<GlobalElementId>>,
1171 target: GlobalElementId,
1172) -> bool {
1173 let mut did_set = false;
1174 let _ = cx.app.models_mut().update(focus_target, |v| {
1175 if v.is_none() {
1176 *v = Some(target);
1177 did_set = true;
1178 }
1179 });
1180 did_set
1181}
1182
1183pub fn sync_while_trigger_hovered<H: UiHost>(
1184 cx: &mut ElementContext<'_, H>,
1185 models: &MenuSubmenuModels,
1186 _cfg: MenuSubmenuConfig,
1187 has_submenu: bool,
1188 value: Arc<str>,
1189 item_id: GlobalElementId,
1190) {
1191 cancel_timer_in_element_context(cx, &models.close_timer);
1192
1193 if has_submenu {
1194 let open_value = cx
1195 .app
1196 .models_mut()
1197 .read(&models.open_value, |v| v.clone())
1198 .ok()
1199 .flatten();
1200 let already_open = open_value
1201 .as_ref()
1202 .is_some_and(|cur| cur.as_ref() == value.as_ref());
1203
1204 if already_open {
1205 set_trigger_if_none(cx, item_id, &models.trigger);
1206 }
1207 } else {
1208 let _ = cx
1209 .app
1210 .models_mut()
1211 .update(&models.open_value, |v| *v = None);
1212 let _ = cx.app.models_mut().update(&models.trigger, |v| *v = None);
1213 let _ = cx.app.models_mut().update(&models.geometry, |v| *v = None);
1214 let _ = cx
1215 .app
1216 .models_mut()
1217 .update(&models.pending_open_value, |v| *v = None);
1218 let _ = cx
1219 .app
1220 .models_mut()
1221 .update(&models.pending_open_trigger, |v| *v = None);
1222 cancel_timer_in_element_context(cx, &models.open_timer);
1223 cancel_timer_in_element_context(cx, &models.focus_timer);
1224 }
1225}
1226
1227pub fn close_if_focus_moved_without_pointer<H: UiHost>(
1228 cx: &mut ElementContext<'_, H>,
1229 models: &MenuSubmenuModels,
1230 focused_value: &Arc<str>,
1231 focused_item_id: GlobalElementId,
1232) {
1233 let no_pointer = cx
1234 .app
1235 .models_mut()
1236 .read(&models.last_pointer, |v| v.is_none())
1237 .ok()
1238 .unwrap_or(true);
1239 if !no_pointer {
1240 return;
1241 }
1242
1243 let open_value = cx
1244 .app
1245 .models_mut()
1246 .read(&models.open_value, |v| v.clone())
1247 .ok()
1248 .flatten();
1249 let open_trigger = cx
1250 .app
1251 .models_mut()
1252 .read(&models.trigger, |v| *v)
1253 .ok()
1254 .flatten();
1255 let is_open_here = open_value
1256 .as_ref()
1257 .is_some_and(|cur| cur.as_ref() == focused_value.as_ref())
1258 && open_trigger == Some(focused_item_id);
1259
1260 if is_open_here {
1261 return;
1262 }
1263
1264 let _ = cx
1265 .app
1266 .models_mut()
1267 .update(&models.open_value, |v| *v = None);
1268 let _ = cx.app.models_mut().update(&models.trigger, |v| *v = None);
1269 let _ = cx.app.models_mut().update(&models.geometry, |v| *v = None);
1270 let _ = cx
1271 .app
1272 .models_mut()
1273 .update(&models.pending_open_value, |v| *v = None);
1274 let _ = cx
1275 .app
1276 .models_mut()
1277 .update(&models.pending_open_trigger, |v| *v = None);
1278 cancel_timer_in_element_context(cx, &models.open_timer);
1279 cancel_timer_in_element_context(cx, &models.close_timer);
1280 cancel_timer_in_element_context(cx, &models.focus_timer);
1281}
1282
1283pub fn handle_sub_trigger_hover_change(
1285 host: &mut dyn UiActionHost,
1286 acx: ActionCx,
1287 models: &MenuSubmenuModels,
1288 cfg: MenuSubmenuConfig,
1289 trigger_id: GlobalElementId,
1290 is_hovered: bool,
1291 value: Arc<str>,
1292) {
1293 if !is_hovered {
1294 cancel_timer(host, &models.open_timer);
1295 let _ = host
1296 .models_mut()
1297 .update(&models.pending_open_value, |v| *v = None);
1298 let _ = host
1299 .models_mut()
1300 .update(&models.pending_open_trigger, |v| *v = None);
1301 return;
1302 }
1303
1304 cancel_timer(host, &models.close_timer);
1305 cancel_timer(host, &models.focus_timer);
1306
1307 let current_open = host
1308 .models_mut()
1309 .read(&models.open_value, |v| v.clone())
1310 .ok()
1311 .flatten();
1312 let already_open = current_open
1313 .as_ref()
1314 .is_some_and(|cur| cur.as_ref() == value.as_ref());
1315 if already_open {
1316 cancel_timer(host, &models.open_timer);
1317 let _ = host
1318 .models_mut()
1319 .update(&models.pending_open_value, |v| *v = None);
1320 let _ = host
1321 .models_mut()
1322 .update(&models.pending_open_trigger, |v| *v = None);
1323 return;
1324 }
1325
1326 if let Some(current_open) = current_open {
1327 let pointer = host
1334 .models_mut()
1335 .read(&models.last_pointer, |v| *v)
1336 .ok()
1337 .flatten();
1338 let pointer_dir = host
1339 .models_mut()
1340 .read(&models.pointer_dir, |v| *v)
1341 .ok()
1342 .flatten();
1343 let grace_intent = host
1344 .models_mut()
1345 .read(&models.pointer_grace_intent, |v| *v)
1346 .ok()
1347 .flatten();
1348
1349 let switching_away = current_open.as_ref() != value.as_ref();
1350 let moving_towards = grace_intent
1351 .as_ref()
1352 .is_some_and(|intent| pointer_dir == Some(intent.side));
1353 let in_grace_area = match (pointer, grace_intent) {
1354 (Some(pointer), Some(intent)) => {
1355 pointer_grace_intent::is_pointer_in_grace_area(pointer, intent)
1356 }
1357 _ => false,
1358 };
1359
1360 if switching_away && moving_towards && in_grace_area {
1361 cancel_timer(host, &models.open_timer);
1362 let _ = host
1363 .models_mut()
1364 .update(&models.pending_open_value, |v| *v = None);
1365 let _ = host
1366 .models_mut()
1367 .update(&models.pending_open_trigger, |v| *v = None);
1368 host.request_redraw(acx.window);
1369 return;
1370 }
1371
1372 }
1375
1376 let _ = host
1377 .models_mut()
1378 .update(&models.pending_open_value, |v| *v = Some(value));
1379 let _ = host
1380 .models_mut()
1381 .update(&models.pending_open_trigger, |v| *v = Some(trigger_id));
1382
1383 if cfg.open_delay == Duration::from_millis(0) {
1384 let pending_value = host
1385 .models_mut()
1386 .read(&models.pending_open_value, |v| v.clone())
1387 .ok()
1388 .flatten();
1389 let pending_trigger = host
1390 .models_mut()
1391 .read(&models.pending_open_trigger, |v| *v)
1392 .ok()
1393 .flatten();
1394 let _ = host
1395 .models_mut()
1396 .update(&models.pending_open_value, |v| *v = None);
1397 let _ = host
1398 .models_mut()
1399 .update(&models.pending_open_trigger, |v| *v = None);
1400 let _ = host.models_mut().update(&models.open_timer, |v| *v = None);
1401
1402 let _ = host
1403 .models_mut()
1404 .update(&models.open_value, |v| *v = pending_value);
1405 let _ = host
1406 .models_mut()
1407 .update(&models.trigger, |v| *v = pending_trigger);
1408 host.request_redraw(acx.window);
1409 return;
1410 }
1411
1412 cancel_timer(host, &models.open_timer);
1413 let token = host.next_timer_token();
1414 host.push_effect(Effect::SetTimer {
1415 window: Some(acx.window),
1416 token,
1417 after: cfg.open_delay,
1418 repeat: None,
1419 });
1420 let _ = host
1421 .models_mut()
1422 .update(&models.open_timer, |v| *v = Some(token));
1423 host.request_redraw(acx.window);
1424}
1425
1426pub fn open_on_activate(
1427 host: &mut dyn UiActionHost,
1428 acx: ActionCx,
1429 models: &MenuSubmenuModels,
1430 value: Arc<str>,
1431) {
1432 cancel_timer(host, &models.close_timer);
1433 cancel_timer(host, &models.open_timer);
1434 cancel_timer(host, &models.pointer_grace_timer);
1435 let _ = host
1436 .models_mut()
1437 .update(&models.pointer_grace_intent, |v| *v = None);
1438 let _ = host
1439 .models_mut()
1440 .update(&models.pending_open_value, |v| *v = None);
1441 let _ = host
1442 .models_mut()
1443 .update(&models.pending_open_trigger, |v| *v = None);
1444 let _ = host
1445 .models_mut()
1446 .update(&models.focus_target, |v| *v = None);
1447 let _ = host
1448 .models_mut()
1449 .update(&models.focus_retry_attempts, |v| *v = 0);
1450 let _ = host
1451 .models_mut()
1452 .update(&models.open_value, |v| *v = Some(value));
1453 let _ = host
1454 .models_mut()
1455 .update(&models.trigger, |v| *v = Some(acx.target));
1456 host.request_redraw(acx.window);
1457}
1458
1459pub fn open_on_arrow_right(
1460 host: &mut dyn UiActionHost,
1461 acx: ActionCx,
1462 models: &MenuSubmenuModels,
1463 trigger_id: GlobalElementId,
1464 value: Arc<str>,
1465 focus_delay: Duration,
1466) {
1467 cancel_timer(host, &models.focus_timer);
1468 cancel_timer(host, &models.close_timer);
1469 cancel_timer(host, &models.open_timer);
1470 cancel_timer(host, &models.pointer_grace_timer);
1471 let _ = host
1472 .models_mut()
1473 .update(&models.pointer_grace_intent, |v| *v = None);
1474 let _ = host
1475 .models_mut()
1476 .update(&models.pending_open_value, |v| *v = None);
1477 let _ = host
1478 .models_mut()
1479 .update(&models.pending_open_trigger, |v| *v = None);
1480 let _ = host
1481 .models_mut()
1482 .update(&models.focus_target, |v| *v = None);
1483 let _ = host
1484 .models_mut()
1485 .update(&models.focus_retry_attempts, |v| *v = 0);
1486
1487 let _ = host
1488 .models_mut()
1489 .update(&models.open_value, |v| *v = Some(value));
1490 let _ = host
1491 .models_mut()
1492 .update(&models.trigger, |v| *v = Some(trigger_id));
1493
1494 let token = host.next_timer_token();
1495 host.push_effect(Effect::SetTimer {
1496 window: Some(acx.window),
1497 token,
1498 after: focus_delay,
1499 repeat: None,
1500 });
1501 let _ = host
1502 .models_mut()
1503 .update(&models.focus_timer, |v| *v = Some(token));
1504 host.request_redraw(acx.window);
1505}
1506
1507pub fn close_on_arrow_left(host: &mut dyn UiActionHost, acx: ActionCx, models: &MenuSubmenuModels) {
1508 let _ = host.models_mut().update(&models.open_value, |v| *v = None);
1509 let _ = host.models_mut().update(&models.trigger, |v| *v = None);
1510 let _ = host.models_mut().update(&models.geometry, |v| *v = None);
1511 let _ = host
1512 .models_mut()
1513 .update(&models.pointer_grace_intent, |v| *v = None);
1514 let _ = host
1515 .models_mut()
1516 .update(&models.pending_open_value, |v| *v = None);
1517 let _ = host
1518 .models_mut()
1519 .update(&models.pending_open_trigger, |v| *v = None);
1520 cancel_timer(host, &models.open_timer);
1521 cancel_timer(host, &models.close_timer);
1522 cancel_timer(host, &models.pointer_grace_timer);
1523 cancel_timer(host, &models.focus_timer);
1524 host.request_redraw(acx.window);
1525}
1526
1527pub fn close_and_restore_trigger(
1528 host: &mut dyn UiFocusActionHost,
1529 acx: ActionCx,
1530 models: &MenuSubmenuModels,
1531) {
1532 let trigger = host
1533 .models_mut()
1534 .read(&models.trigger, |v| *v)
1535 .ok()
1536 .flatten();
1537 close_on_arrow_left(host, acx, models);
1538 if let Some(trigger) = trigger {
1539 host.request_focus(trigger);
1540 }
1541}
1542
1543pub fn submenu_item_close_key_handler(
1544 models: MenuSubmenuModels,
1545 dir: LayoutDirection,
1546) -> fret_ui::action::OnKeyDown {
1547 #[allow(clippy::arc_with_non_send_sync)]
1548 Arc::new(move |host, acx, down| {
1549 if down.repeat {
1550 return false;
1551 }
1552 if down.key == fret_core::KeyCode::Escape {
1553 close_and_restore_trigger(host, acx, &models);
1554 return true;
1555 }
1556 let is_close_key = matches!(
1557 (down.key, dir),
1558 (fret_core::KeyCode::ArrowLeft, LayoutDirection::Ltr)
1559 | (fret_core::KeyCode::ArrowRight, LayoutDirection::Rtl)
1560 );
1561 if !is_close_key {
1562 return false;
1563 }
1564 close_and_restore_trigger(host, acx, &models);
1565 true
1566 })
1567}
1568
1569pub fn focus_first_available_on_open<H: UiHost>(
1570 cx: &mut ElementContext<'_, H>,
1571 models: &MenuSubmenuModels,
1572 item_id: GlobalElementId,
1573 disabled: bool,
1574) {
1575 if disabled {
1576 return;
1577 }
1578 let _ = set_focus_target_if_none(cx, &models.focus_target, item_id);
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583 use super::*;
1584
1585 use std::cell::Cell;
1586 use std::sync::Arc;
1587
1588 use fret_app::App;
1589 use fret_core::{AppWindowId, Point, Px, Rect, Size};
1590 use fret_runtime::Effect;
1591 use fret_ui::GlobalElementId;
1592 use fret_ui::action::{ActionCx, UiActionHost, UiFocusActionHost};
1593
1594 #[test]
1595 fn default_pointer_grace_timeout_matches_radix() {
1596 assert_eq!(
1597 MenuSubmenuConfig::default().pointer_grace_timeout,
1598 DEFAULT_POINTER_GRACE_TIMEOUT
1599 );
1600 }
1601
1602 #[test]
1603 fn new_uses_default_pointer_grace_timeout() {
1604 let cfg = MenuSubmenuConfig::new(
1605 Px(1.0),
1606 Duration::from_millis(1),
1607 Duration::from_millis(2),
1608 Duration::from_millis(3),
1609 );
1610 assert_eq!(cfg.pointer_grace_timeout, DEFAULT_POINTER_GRACE_TIMEOUT);
1611 }
1612
1613 #[test]
1614 fn default_submenu_bounds_respects_direction() {
1615 let outer = Rect::new(
1616 Point::new(Px(0.0), Px(0.0)),
1617 Size::new(Px(400.0), Px(300.0)),
1618 );
1619 let trigger = Rect::new(
1620 Point::new(Px(200.0), Px(120.0)),
1621 Size::new(Px(20.0), Px(28.0)),
1622 );
1623 let desired = Size::new(Px(140.0), Px(180.0));
1624
1625 let ltr = default_submenu_bounds(outer, trigger, desired, LayoutDirection::Ltr);
1626 let rtl = default_submenu_bounds(outer, trigger, desired, LayoutDirection::Rtl);
1627
1628 let trigger_left = trigger.origin.x.0;
1629 let trigger_right = trigger.origin.x.0 + trigger.size.width.0;
1630
1631 assert!(
1632 ltr.origin.x.0 >= trigger_right,
1633 "LTR submenu should be placed to the right of the trigger (got {ltr:?})"
1634 );
1635 assert!(
1636 rtl.origin.x.0 + rtl.size.width.0 <= trigger_left,
1637 "RTL submenu should be placed to the left of the trigger (got {rtl:?})"
1638 );
1639 }
1640
1641 struct Host<'a> {
1642 app: &'a mut App,
1643 last_focus_requested: Cell<Option<GlobalElementId>>,
1644 }
1645
1646 impl UiActionHost for Host<'_> {
1647 fn models_mut(&mut self) -> &mut fret_runtime::ModelStore {
1648 self.app.models_mut()
1649 }
1650
1651 fn push_effect(&mut self, effect: Effect) {
1652 self.app.push_effect(effect);
1653 }
1654
1655 fn request_redraw(&mut self, window: AppWindowId) {
1656 self.app.request_redraw(window);
1657 }
1658
1659 fn next_timer_token(&mut self) -> TimerToken {
1660 self.app.next_timer_token()
1661 }
1662
1663 fn next_clipboard_token(&mut self) -> fret_runtime::ClipboardToken {
1664 self.app.next_clipboard_token()
1665 }
1666
1667 fn next_share_sheet_token(&mut self) -> fret_runtime::ShareSheetToken {
1668 self.app.next_share_sheet_token()
1669 }
1670 }
1671
1672 impl UiFocusActionHost for Host<'_> {
1673 fn request_focus(&mut self, target: GlobalElementId) {
1674 self.last_focus_requested.set(Some(target));
1675 }
1676 }
1677
1678 fn new_models(app: &mut App) -> MenuSubmenuModels {
1679 MenuSubmenuModels {
1680 open_value: app.models_mut().insert(None),
1681 trigger: app.models_mut().insert(None),
1682 last_pointer: app.models_mut().insert(None),
1683 geometry: app.models_mut().insert(None),
1684 close_timer: app.models_mut().insert(None),
1685 pointer_dir: app.models_mut().insert(None),
1686 pointer_grace_intent: app.models_mut().insert(None),
1687 pointer_grace_timer: app.models_mut().insert(None),
1688 focus_target: app.models_mut().insert(None),
1689 focus_timer: app.models_mut().insert(None),
1690 focus_retry_attempts: app.models_mut().insert(0),
1691 pending_open_value: app.models_mut().insert(None),
1692 pending_open_trigger: app.models_mut().insert(None),
1693 open_timer: app.models_mut().insert(None),
1694 }
1695 }
1696
1697 fn right_side_grace_intent() -> pointer_grace_intent::GraceIntent {
1698 let reference = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
1699 let floating = Rect::new(Point::new(Px(20.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
1700 pointer_grace_intent::grace_intent_from_exit_point(
1701 Point::new(Px(12.0), Px(5.0)),
1702 pointer_grace_intent::PointerGraceIntentGeometry {
1703 reference,
1704 floating,
1705 },
1706 Px(5.0),
1707 )
1708 .expect("expected grace intent")
1709 }
1710
1711 #[test]
1712 fn submenu_trigger_hover_does_not_switch_while_pointer_in_grace_polygon() {
1713 let window = AppWindowId::default();
1714 let mut app = App::new();
1715 let mut host = Host {
1716 app: &mut app,
1717 last_focus_requested: Cell::new(None),
1718 };
1719
1720 let models = new_models(host.app);
1721 let cfg = MenuSubmenuConfig::default();
1722
1723 let _ = host
1724 .models_mut()
1725 .update(&models.open_value, |v| *v = Some(Arc::from("a")));
1726 let _ = host.models_mut().update(&models.last_pointer, |v| {
1727 *v = Some(Point::new(Px(12.0), Px(5.0)))
1728 });
1729 let _ = host.models_mut().update(&models.pointer_dir, |v| {
1730 *v = Some(pointer_grace_intent::GraceSide::Right)
1731 });
1732 let _ = host.models_mut().update(&models.pointer_grace_intent, |v| {
1733 *v = Some(right_side_grace_intent())
1734 });
1735
1736 handle_sub_trigger_hover_change(
1737 &mut host,
1738 ActionCx {
1739 window,
1740 target: GlobalElementId(1),
1741 },
1742 &models,
1743 cfg,
1744 GlobalElementId(2),
1745 true,
1746 Arc::from("b"),
1747 );
1748
1749 let open_value = host
1750 .models_mut()
1751 .read(&models.open_value, |v| v.clone())
1752 .ok()
1753 .flatten();
1754 let pending_open = host
1755 .models_mut()
1756 .read(&models.pending_open_value, |v| v.clone())
1757 .ok()
1758 .flatten();
1759 let open_timer = host
1760 .models_mut()
1761 .read(&models.open_timer, |v| *v)
1762 .ok()
1763 .flatten();
1764
1765 assert_eq!(open_value.as_deref(), Some("a"));
1766 assert!(pending_open.is_none());
1767 assert!(open_timer.is_none());
1768 }
1769
1770 #[test]
1771 fn submenu_open_timer_defers_switch_while_pointer_in_grace_polygon() {
1772 let window = AppWindowId::default();
1773 let mut app = App::new();
1774 let mut host = Host {
1775 app: &mut app,
1776 last_focus_requested: Cell::new(None),
1777 };
1778
1779 let models = new_models(host.app);
1780 let cfg = MenuSubmenuConfig::default();
1781
1782 let _ = host
1783 .models_mut()
1784 .update(&models.open_value, |v| *v = Some(Arc::from("a")));
1785 let _ = host
1786 .models_mut()
1787 .update(&models.pending_open_value, |v| *v = Some(Arc::from("b")));
1788 let _ = host.models_mut().update(&models.last_pointer, |v| {
1789 *v = Some(Point::new(Px(12.0), Px(5.0)))
1790 });
1791 let _ = host.models_mut().update(&models.pointer_dir, |v| {
1792 *v = Some(pointer_grace_intent::GraceSide::Right)
1793 });
1794 let _ = host.models_mut().update(&models.pointer_grace_intent, |v| {
1795 *v = Some(right_side_grace_intent())
1796 });
1797
1798 let token = host.next_timer_token();
1799 let _ = host
1800 .models_mut()
1801 .update(&models.open_timer, |v| *v = Some(token));
1802
1803 let on_timer = on_timer_handler(models.clone(), cfg);
1804 assert!(on_timer(
1805 &mut host,
1806 ActionCx {
1807 window,
1808 target: GlobalElementId(1),
1809 },
1810 token
1811 ));
1812
1813 let open_value = host
1814 .models_mut()
1815 .read(&models.open_value, |v| v.clone())
1816 .ok()
1817 .flatten();
1818 let open_timer = host
1819 .models_mut()
1820 .read(&models.open_timer, |v| *v)
1821 .ok()
1822 .flatten();
1823
1824 assert_eq!(open_value.as_deref(), Some("a"));
1825 assert!(open_timer.is_some_and(|t| t != token));
1826 }
1827
1828 #[test]
1829 fn focus_timer_retries_until_submenu_focus_target_is_ready() {
1830 let window = AppWindowId::default();
1831 let mut app = App::new();
1832 let mut host = Host {
1833 app: &mut app,
1834 last_focus_requested: Cell::new(None),
1835 };
1836
1837 let models = new_models(host.app);
1838 let cfg = MenuSubmenuConfig::default();
1839
1840 let token = host.next_timer_token();
1841 let _ = host
1842 .models_mut()
1843 .update(&models.focus_timer, |v| *v = Some(token));
1844
1845 let on_timer = on_timer_handler(models.clone(), cfg);
1846 assert!(on_timer(
1847 &mut host,
1848 ActionCx {
1849 window,
1850 target: GlobalElementId(1),
1851 },
1852 token,
1853 ));
1854
1855 let retry_token = host
1856 .models_mut()
1857 .read(&models.focus_timer, |v| *v)
1858 .ok()
1859 .flatten()
1860 .expect("retry timer should be armed");
1861 assert_ne!(retry_token, token);
1862 assert_eq!(
1863 host.models_mut()
1864 .read(&models.focus_retry_attempts, |v| *v)
1865 .ok(),
1866 Some(1)
1867 );
1868 assert_eq!(host.last_focus_requested.get(), None);
1869
1870 let target = GlobalElementId(99);
1871 let _ = host
1872 .models_mut()
1873 .update(&models.focus_target, |v| *v = Some(target));
1874 assert!(on_timer(
1875 &mut host,
1876 ActionCx {
1877 window,
1878 target: GlobalElementId(1),
1879 },
1880 retry_token,
1881 ));
1882
1883 assert_eq!(host.last_focus_requested.get(), Some(target));
1884 assert!(
1885 host.models_mut()
1886 .read(&models.focus_timer, |v| *v)
1887 .ok()
1888 .flatten()
1889 .is_none(),
1890 "focus timer should clear after successful focus"
1891 );
1892 assert_eq!(
1893 host.models_mut()
1894 .read(&models.focus_retry_attempts, |v| *v)
1895 .ok(),
1896 Some(0)
1897 );
1898 }
1899
1900 #[test]
1901 fn pointer_grace_timer_clears_grace_intent() {
1902 let window = AppWindowId::default();
1903 let mut app = App::new();
1904 let mut host = Host {
1905 app: &mut app,
1906 last_focus_requested: Cell::new(None),
1907 };
1908
1909 let models = new_models(host.app);
1910 let cfg = MenuSubmenuConfig::default();
1911
1912 let token = host.next_timer_token();
1913 let _ = host
1914 .models_mut()
1915 .update(&models.pointer_grace_timer, |v| *v = Some(token));
1916 let _ = host.models_mut().update(&models.pointer_grace_intent, |v| {
1917 *v = Some(right_side_grace_intent())
1918 });
1919
1920 let on_timer = on_timer_handler(models.clone(), cfg);
1921 assert!(on_timer(
1922 &mut host,
1923 ActionCx {
1924 window,
1925 target: GlobalElementId(1),
1926 },
1927 token
1928 ));
1929
1930 let intent = host
1931 .models_mut()
1932 .read(&models.pointer_grace_intent, |v| *v)
1933 .ok()
1934 .flatten();
1935 let armed = host
1936 .models_mut()
1937 .read(&models.pointer_grace_timer, |v| *v)
1938 .ok()
1939 .flatten();
1940
1941 assert!(intent.is_none());
1942 assert!(armed.is_none());
1943 }
1944}