Skip to main content

fret_ui_kit/primitives/menu/
sub.rs

1//! Menu Sub (submenu) policy helpers (Radix-aligned outcomes).
2//!
3//! In Radix, `DropdownMenu`, `ContextMenu`, and `Menubar` are wrappers around the lower-level
4//! `Menu` primitive (`@radix-ui/react-menu`):
5//! <https://github.com/radix-ui/primitives/tree/main/packages/react/menu>
6//!
7//! A key behavior baked into Radix Menu is submenu ergonomics:
8//! - pointer grace intent while moving towards submenu content
9//! - delayed close timers
10//! - keyboard focus transfer into the submenu (and restore to the trigger on close)
11//!
12//! This module provides those outcomes as reusable policy helpers for Fret wrappers.
13
14use 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
35/// Radix Menu clears `pointerGraceIntentRef` after 300ms.
36pub 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    /// Delay before opening a submenu on pointer hover.
42    ///
43    /// Radix Menu uses a small delay (~100ms) to avoid accidental opens while sweeping across items.
44    pub open_delay: Duration,
45    pub close_delay: Duration,
46    pub focus_delay: Duration,
47    /// How long a submenu "pointer grace" corridor stays armed after the pointer exits the trigger.
48    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
85/// Return the default submenu floating bounds (Radix Menu-like: side=Right, align=Start, offset=2px).
86pub fn default_submenu_bounds(
87    outer: Rect,
88    trigger_anchor: Rect,
89    desired: Size,
90    dir: LayoutDirection,
91) -> Rect {
92    // Radix `MenuSubContent` hard-codes `align="start"` and relies on Popper's collision logic to
93    // flip the alignment when the submenu would overflow the viewport on the cross axis.
94    //
95    // We approximate that behavior here by flipping to `Align::End` when the desired height does
96    // not fit below the trigger (side=Right in LTR, side=Left in RTL).
97    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    // Submenus should not be shifted along the main (side) axis: Radix keeps the submenu aligned to
116    // its trigger for pointer-grace ergonomics, flipping to the opposite side when it overflows.
117    // However, it still shifts/clamps along the cross axis to remain vertically usable.
118    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
142/// Estimate a scrollable menu panel viewport height for `row_count` rows.
143///
144/// This is primarily used by submenu wrappers to approximate the `desired` size passed into the
145/// placement solver: Radix Menu uses the content's measured height but clamps it by the available
146/// space (and any theme cap) so flip decisions remain stable while the internal list scrolls.
147pub 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
158/// Return an estimated desired size for a scrollable menu/submenu list.
159pub 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
178/// Synchronize submenu geometry from the currently-registered trigger element anchor.
179///
180/// When a submenu is already open, its trigger can continue to move due to layout/scroll. Updating
181/// geometry ahead of rendering keeps pointer-grace intent and safe-corridor heuristics stable.
182pub 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
219/// Like [`with_open_submenu`], but eagerly syncs submenu geometry from the current trigger anchor
220/// before resolving the geometry model.
221pub 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
275/// Update submenu geometry from a specific trigger element's current anchor bounds.
276///
277/// This is typically called from within a `MenuSubTrigger` pressable closure when the submenu is
278/// already open, so pointer-grace intent has up-to-date geometry even before the submenu panel is
279/// rendered/measured.
280pub 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    // If a submenu is open but we have no geometry yet, we still want to begin closing it when the
996    // pointer wanders away. Without geometry we can't compute a safe-hover corridor, so fall back
997    // to arming the close-delay timer once.
998    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        // Hit-testing and layout rounding can produce 1px overlaps between adjacent menu items,
1082        // especially when borders are present. Radix's DOM-driven hover logic effectively treats
1083        // the "exit" boundary as half-open; bias our exit detection by trimming the bottom/right
1084        // edge by 1px so moving onto the next item reliably arms the pointer-grace corridor.
1085        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
1283/// Handle submenu trigger hover changes, applying a small open delay to avoid accidental opens.
1284pub 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        // Radix prevents switching submenus while the pointer is moving toward the already-open
1328        // submenu panel (pointer grace intent). We mirror that by ignoring hover-enter on other
1329        // submenu triggers while the pointer remains inside the grace polygon.
1330        //
1331        // This keeps us closer to Radix's `onItemEnter(event).preventDefault()` semantics and avoids
1332        // repeatedly arming "switch submenu" open-delay timers while the pointer is in transit.
1333        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        // While another submenu is open, avoid closing it immediately when hovering a different
1373        // submenu trigger. We only switch once the hover open-delay timer fires.
1374    }
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}