Skip to main content

fret_ui_kit/primitives/menu/
sub_trigger.rs

1//! MenuSubTrigger helpers (Radix-aligned outcomes).
2//!
3//! Radix `MenuSubTrigger` is responsible for opening nested menus via:
4//! - pointer hover intent (with grace corridor)
5//! - click/activation
6//! - ArrowRight / ArrowLeft keyboard affordances
7//!
8//! In Fret, wrappers call these helpers from within a pressable item closure.
9
10use std::sync::Arc;
11
12use fret_core::{KeyCode, Rect, Size};
13use fret_ui::element::PressableState;
14use fret_ui::elements::GlobalElementId;
15use fret_ui::{ElementContext, UiHost};
16
17use crate::declarative::model_watch::ModelWatchExt as _;
18use crate::on_activate;
19use crate::primitives::direction::{self as direction_prim, LayoutDirection};
20use crate::primitives::menu::sub;
21
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct MenuSubTriggerGeometryHint {
24    pub outer: Rect,
25    pub desired: Size,
26}
27
28/// Wire submenu-trigger behavior onto a pressable item.
29///
30/// Returns `Some(expanded)` when the item has a submenu, otherwise `None`.
31#[allow(clippy::too_many_arguments)]
32pub fn wire<H: UiHost>(
33    cx: &mut ElementContext<'_, H>,
34    st: PressableState,
35    item_id: GlobalElementId,
36    disabled: bool,
37    has_submenu: bool,
38    value: Arc<str>,
39    models: &sub::MenuSubmenuModels,
40    cfg: sub::MenuSubmenuConfig,
41    geometry_hint: Option<MenuSubTriggerGeometryHint>,
42) -> Option<bool> {
43    if disabled {
44        return has_submenu.then_some(false);
45    }
46
47    if has_submenu {
48        // Submenu open/focus/close timers are emitted from submenu-trigger interactions (hover,
49        // arrow keys). Install a timer handler on the trigger element so timer routing remains
50        // stable even when the overlay root is not the timer event target.
51        sub::install_timer_handler(cx, item_id, models.clone(), cfg);
52
53        let models_for_hover = models.clone();
54        let value_for_hover = value.clone();
55        let cfg_for_hover = cfg;
56        let trigger_id_for_hover = item_id;
57        cx.pressable_add_on_hover_change(Arc::new(move |host, acx, is_hovered| {
58            sub::handle_sub_trigger_hover_change(
59                host,
60                acx,
61                &models_for_hover,
62                cfg_for_hover,
63                trigger_id_for_hover,
64                is_hovered,
65                value_for_hover.clone(),
66            );
67        }));
68    }
69
70    if st.hovered {
71        sub::sync_while_trigger_hovered(cx, models, cfg, has_submenu, value.clone(), item_id);
72    }
73
74    if st.focused {
75        sub::close_if_focus_moved_without_pointer(cx, models, &value, item_id);
76    }
77
78    if has_submenu {
79        let models_for_activate = models.clone();
80        let value_for_activate = value.clone();
81        cx.pressable_add_on_activate(on_activate(move |host, acx, _reason| {
82            sub::open_on_activate(host, acx, &models_for_activate, value_for_activate.clone());
83        }));
84    }
85
86    let key_has_submenu = has_submenu;
87    let models_for_key = models.clone();
88    let value_for_key = value.clone();
89    let cfg_for_key = cfg;
90    let trigger_id_for_key = item_id;
91    let dir = direction_prim::use_direction_in_scope(cx, None);
92    cx.key_on_key_down_for(
93        item_id,
94        Arc::new(move |host, acx, down| {
95            if down.repeat {
96                return false;
97            }
98            let is_open_key = matches!(
99                (down.key, dir),
100                (KeyCode::ArrowRight, LayoutDirection::Ltr)
101                    | (KeyCode::ArrowLeft, LayoutDirection::Rtl)
102            );
103            if is_open_key {
104                if !key_has_submenu {
105                    return false;
106                }
107                sub::open_on_arrow_right(
108                    host,
109                    acx,
110                    &models_for_key,
111                    trigger_id_for_key,
112                    value_for_key.clone(),
113                    cfg_for_key.focus_delay,
114                );
115                return true;
116            }
117
118            let is_close_key = matches!(
119                (down.key, dir),
120                (KeyCode::ArrowLeft, LayoutDirection::Ltr)
121                    | (KeyCode::ArrowRight, LayoutDirection::Rtl)
122            );
123            if is_close_key {
124                let is_open = host
125                    .models_mut()
126                    .read(&models_for_key.open_value, |v| v.is_some())
127                    .ok()
128                    .unwrap_or(false);
129                if !is_open {
130                    return false;
131                }
132
133                sub::close_on_arrow_left(host, acx, &models_for_key);
134                return true;
135            }
136
137            false
138        }),
139    );
140
141    let expanded = cx
142        .watch_model(&models.open_value)
143        .cloned()
144        .unwrap_or(None)
145        .as_ref()
146        .is_some_and(|cur: &Arc<str>| cur.as_ref() == value.as_ref());
147
148    if has_submenu && expanded {
149        sub::set_trigger_if_none(cx, item_id, &models.trigger);
150
151        let open_trigger = cx
152            .app
153            .models_mut()
154            .read(&models.trigger, |v| *v)
155            .ok()
156            .flatten();
157        if open_trigger.is_none_or(|t| t == item_id)
158            && let Some(hint) = geometry_hint
159        {
160            sub::set_geometry_from_element_anchor_if_present(
161                cx,
162                item_id,
163                models,
164                hint.outer,
165                hint.desired,
166            );
167        }
168    }
169
170    has_submenu.then_some(expanded)
171}