Skip to main content

fret_ui_kit/primitives/
context_menu.rs

1//! Radix `ContextMenu` facades.
2//!
3//! Upstream: <https://github.com/radix-ui/primitives/tree/main/packages/react/context-menu>
4//!
5//! In Radix, `ContextMenu` is built on top of `Menu` with a different trigger/open policy.
6//! In Fret we share the same underlying behavior via `crate::primitives::menu` and expose
7//! Radix-named entry points here for reuse outside the shadcn layer.
8
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11use std::time::Duration;
12
13use fret_core::{MouseButton, Point, PointerId, PointerType, Px, Rect};
14use fret_runtime::{Effect, Model, ModelId, TimerToken};
15use fret_ui::UiHost;
16use fret_ui::action::{
17    OnPointerCancel, OnPointerDown, OnPointerMove, OnPointerUp, PointerCancelCx, PointerDownCx,
18    PointerMoveCx, PointerUpCx, UiActionHost, UiPointerActionHost,
19};
20
21use crate::primitives::popper;
22
23pub use crate::primitives::menu::*;
24
25pub use crate::primitives::menu::root::dismissible_menu_request as context_menu_dismissible_request;
26pub use crate::primitives::menu::root::dismissible_menu_request_with_dismiss_handler as context_menu_dismissible_request_with_dismiss_handler;
27pub use crate::primitives::menu::root::menu_overlay_root_name as context_menu_root_name;
28pub use crate::primitives::menu::root::with_root_name_sync_root_open_and_ensure_submenu as context_menu_sync_root_open_and_ensure_submenu;
29pub use crate::primitives::menu::trigger::wire_open_on_shift_f10 as wire_context_menu_open_on_shift_f10;
30
31/// Touch long-press delay aligned with Base UI `ContextMenu.Trigger`.
32pub const CONTEXT_MENU_TOUCH_LONG_PRESS_DELAY: Duration = Duration::from_millis(500);
33
34/// Touch move threshold (in logical px) before canceling a pending long-press open.
35pub const CONTEXT_MENU_TOUCH_LONG_PRESS_MOVE_THRESHOLD_PX: f32 = 10.0;
36
37#[derive(Debug, Default, Clone, Copy, PartialEq)]
38pub struct ContextMenuTouchLongPressState {
39    pub pointer_id: Option<PointerId>,
40    pub origin: Option<Point>,
41    pub timer: Option<TimerToken>,
42}
43
44pub type ContextMenuTouchLongPress = Arc<Mutex<ContextMenuTouchLongPressState>>;
45
46pub fn context_menu_touch_long_press() -> ContextMenuTouchLongPress {
47    Arc::new(Mutex::new(ContextMenuTouchLongPressState::default()))
48}
49
50#[derive(Default)]
51struct ContextMenuTouchLongPressStore {
52    by_open_model: HashMap<ModelId, ContextMenuTouchLongPress>,
53}
54
55/// Returns a shared touch long-press state keyed by the context menu's open model id.
56///
57/// The pending long-press interaction should survive render-root churn as long as the menu
58/// instance itself is still represented by the same `open` model.
59pub fn context_menu_touch_long_press_for_open_model<H: UiHost>(
60    app: &mut H,
61    open: &Model<bool>,
62) -> ContextMenuTouchLongPress {
63    let open_model_id = open.id();
64    app.with_global_mut_untracked(ContextMenuTouchLongPressStore::default, |st, _app| {
65        st.by_open_model
66            .entry(open_model_id)
67            .or_insert_with(context_menu_touch_long_press)
68            .clone()
69    })
70}
71
72#[derive(Default)]
73struct ContextMenuAnchorStore {
74    by_open_model: Option<Model<HashMap<ModelId, Point>>>,
75}
76
77/// Returns a shared anchor store keyed by the context menu's open model id.
78///
79/// This is intended for context menus that need to anchor by cursor position even when the trigger
80/// is not a `PointerRegion` (e.g. viewport tools opened via `Effect::ViewportInput`).
81pub fn context_menu_anchor_store_model<H: UiHost>(app: &mut H) -> Model<HashMap<ModelId, Point>> {
82    app.with_global_mut_untracked(ContextMenuAnchorStore::default, |st, app| {
83        if let Some(model) = st.by_open_model.clone() {
84            return model;
85        }
86        let model = app.models_mut().insert(HashMap::<ModelId, Point>::new());
87        st.by_open_model = Some(model.clone());
88        model
89    })
90}
91
92/// Updates the anchor point for the given open model.
93pub fn set_context_menu_anchor_for_open_model<H: UiHost>(
94    app: &mut H,
95    open: &Model<bool>,
96    position: Point,
97) {
98    let open_model_id = open.id();
99    let anchor_store_model = context_menu_anchor_store_model(app);
100    let _ = app.models_mut().update(&anchor_store_model, |map| {
101        map.insert(open_model_id, position);
102    });
103}
104
105/// A Radix-aligned pointer-down policy for opening a context menu.
106///
107/// Mirrors the common desktop behavior:
108/// - Right click opens.
109/// - (macOS) Ctrl + left click opens.
110///
111/// Usage (typical):
112/// - wrap your trigger in a `PointerRegion`,
113/// - call `cx.pointer_region_on_pointer_down(context_menu_pointer_down_policy(open.clone()))`,
114/// - read `PointerRegionState::last_down` to anchor the popup at the click position.
115///
116/// Note: `PointerRegionState::last_down` is per-element state; if you need the anchor to persist
117/// across re-renders (or want to decouple it from element identity), copy `down.position` into an
118/// app-owned model (e.g. `Model<Option<Point>>`, or a map keyed by your `open` model id).
119pub fn context_menu_pointer_down_policy(open: Model<bool>) -> OnPointerDown {
120    Arc::new(
121        move |host: &mut dyn UiPointerActionHost,
122              cx: fret_ui::action::ActionCx,
123              down: PointerDownCx| {
124            let is_right_click = down.button == MouseButton::Right;
125            let is_macos_ctrl_click = cfg!(target_os = "macos")
126                && down.button == MouseButton::Left
127                && down.modifiers.ctrl;
128
129            if !is_right_click && !is_macos_ctrl_click {
130                return false;
131            }
132
133            let _ = host.models_mut().update(&open, |v| *v = true);
134            host.request_redraw(cx.window);
135            true
136        },
137    )
138}
139
140fn touch_long_press_is_touch_left_down(down: PointerDownCx) -> bool {
141    down.pointer_type == PointerType::Touch && down.button == MouseButton::Left
142}
143
144fn touch_long_press_exceeds_move_threshold(origin: Point, position: Point) -> bool {
145    let dx = origin.x.0 - position.x.0;
146    let dy = origin.y.0 - position.y.0;
147    (dx * dx + dy * dy)
148        > CONTEXT_MENU_TOUCH_LONG_PRESS_MOVE_THRESHOLD_PX
149            * CONTEXT_MENU_TOUCH_LONG_PRESS_MOVE_THRESHOLD_PX
150}
151
152fn clear_touch_long_press_inner(
153    host: &mut dyn UiActionHost,
154    state: &mut ContextMenuTouchLongPressState,
155) {
156    if let Some(token) = state.timer.take() {
157        host.push_effect(Effect::CancelTimer { token });
158    }
159    state.pointer_id = None;
160    state.origin = None;
161}
162
163pub fn context_menu_touch_long_press_clear(
164    long_press: &ContextMenuTouchLongPress,
165    host: &mut dyn UiActionHost,
166) {
167    let mut state = long_press.lock().unwrap_or_else(|e| e.into_inner());
168    clear_touch_long_press_inner(host, &mut state);
169}
170
171pub fn context_menu_touch_long_press_on_pointer_down(
172    long_press: &ContextMenuTouchLongPress,
173    host: &mut dyn UiPointerActionHost,
174    cx: fret_ui::action::ActionCx,
175    down: PointerDownCx,
176) -> bool {
177    if !touch_long_press_is_touch_left_down(down) {
178        return false;
179    }
180
181    let token = host.next_timer_token();
182    {
183        let mut state = long_press.lock().unwrap_or_else(|e| e.into_inner());
184        clear_touch_long_press_inner(host, &mut state);
185        state.pointer_id = Some(down.pointer_id);
186        state.origin = Some(down.position_window.unwrap_or(down.position));
187        state.timer = Some(token);
188    }
189
190    host.push_effect(Effect::SetTimer {
191        window: Some(cx.window),
192        token,
193        after: CONTEXT_MENU_TOUCH_LONG_PRESS_DELAY,
194        repeat: None,
195    });
196    host.capture_pointer();
197    true
198}
199
200pub fn context_menu_touch_long_press_on_pointer_move(
201    long_press: &ContextMenuTouchLongPress,
202    host: &mut dyn UiPointerActionHost,
203    mv: PointerMoveCx,
204) -> bool {
205    if mv.pointer_type != PointerType::Touch {
206        return false;
207    }
208
209    let mut state = long_press.lock().unwrap_or_else(|e| e.into_inner());
210    if state.pointer_id != Some(mv.pointer_id) {
211        return false;
212    }
213    let position = mv.position_window.unwrap_or(mv.position);
214    if let Some(origin) = state.origin
215        && touch_long_press_exceeds_move_threshold(origin, position)
216    {
217        clear_touch_long_press_inner(host, &mut state);
218    }
219    false
220}
221
222pub fn context_menu_touch_long_press_on_pointer_up(
223    long_press: &ContextMenuTouchLongPress,
224    host: &mut dyn UiPointerActionHost,
225    up: PointerUpCx,
226) -> bool {
227    let mut state = long_press.lock().unwrap_or_else(|e| e.into_inner());
228    if state.pointer_id != Some(up.pointer_id) {
229        return false;
230    }
231    clear_touch_long_press_inner(host, &mut state);
232    false
233}
234
235pub fn context_menu_touch_long_press_on_pointer_cancel(
236    long_press: &ContextMenuTouchLongPress,
237    host: &mut dyn UiPointerActionHost,
238    cancel: PointerCancelCx,
239) -> bool {
240    let mut state = long_press.lock().unwrap_or_else(|e| e.into_inner());
241    if state.pointer_id != Some(cancel.pointer_id) {
242        return false;
243    }
244    clear_touch_long_press_inner(host, &mut state);
245    false
246}
247
248pub fn context_menu_touch_long_press_take_anchor_on_timer(
249    long_press: &ContextMenuTouchLongPress,
250    token: TimerToken,
251) -> Option<Point> {
252    let mut state = long_press.lock().unwrap_or_else(|e| e.into_inner());
253    if state.timer != Some(token) {
254        return None;
255    }
256
257    state.timer = None;
258    state.pointer_id = None;
259    state.origin.take()
260}
261
262pub fn context_menu_touch_long_press_pointer_handlers(
263    long_press: ContextMenuTouchLongPress,
264) -> (OnPointerMove, OnPointerUp, OnPointerCancel) {
265    let on_move: OnPointerMove = Arc::new({
266        let long_press = long_press.clone();
267        move |host, _cx, mv| context_menu_touch_long_press_on_pointer_move(&long_press, host, mv)
268    });
269    let on_up: OnPointerUp = Arc::new({
270        let long_press = long_press.clone();
271        move |host, _cx, up| context_menu_touch_long_press_on_pointer_up(&long_press, host, up)
272    });
273    let on_cancel: OnPointerCancel = Arc::new(move |host, _cx, cancel| {
274        context_menu_touch_long_press_on_pointer_cancel(&long_press, host, cancel)
275    });
276    (on_move, on_up, on_cancel)
277}
278
279#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct ContextMenuPopperVars {
281    pub available_width: Px,
282    pub available_height: Px,
283    pub trigger_width: Px,
284    pub trigger_height: Px,
285}
286
287pub fn context_menu_popper_desired_width(outer: Rect, anchor: Rect, min_width: Px) -> Px {
288    popper::popper_desired_width(outer, anchor, min_width)
289}
290
291/// Compute Radix-like "context menu popper vars" (`--radix-context-menu-*`) for recipes.
292///
293/// Upstream Radix re-namespaces these from `@radix-ui/react-popper`:
294/// - `--radix-context-menu-content-available-width`
295/// - `--radix-context-menu-content-available-height`
296/// - `--radix-context-menu-trigger-width`
297/// - `--radix-context-menu-trigger-height`
298///
299/// In Fret, we compute the same concepts as a structured return value so recipes can constrain
300/// their content without relying on CSS variables.
301pub fn context_menu_popper_vars(
302    outer: Rect,
303    anchor: Rect,
304    min_width: Px,
305    placement: popper::PopperContentPlacement,
306) -> ContextMenuPopperVars {
307    let metrics =
308        popper::popper_available_metrics_for_placement(outer, anchor, min_width, placement);
309    ContextMenuPopperVars {
310        available_width: metrics.available_width,
311        available_height: metrics.available_height,
312        trigger_width: metrics.anchor_width,
313        trigger_height: metrics.anchor_height,
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    use fret_app::App;
322    use fret_core::{
323        AppWindowId, Modifiers, MouseButtons, Point, PointerCancelReason, PointerId, PointerType,
324        Size,
325    };
326    use fret_runtime::{Effect, ModelStore};
327    use fret_ui::action::{
328        ActionCx, UiActionHost, UiDragActionHost, UiFocusActionHost, UiPointerActionHost,
329    };
330
331    #[derive(Default)]
332    struct PointerHost {
333        app: App,
334    }
335
336    impl UiActionHost for PointerHost {
337        fn models_mut(&mut self) -> &mut ModelStore {
338            self.app.models_mut()
339        }
340
341        fn push_effect(&mut self, effect: Effect) {
342            self.app.push_effect(effect);
343        }
344
345        fn request_redraw(&mut self, window: AppWindowId) {
346            self.app.request_redraw(window);
347        }
348
349        fn next_timer_token(&mut self) -> TimerToken {
350            self.app.next_timer_token()
351        }
352
353        fn next_clipboard_token(&mut self) -> fret_runtime::ClipboardToken {
354            self.app.next_clipboard_token()
355        }
356
357        fn next_share_sheet_token(&mut self) -> fret_runtime::ShareSheetToken {
358            self.app.next_share_sheet_token()
359        }
360    }
361
362    impl UiFocusActionHost for PointerHost {
363        fn request_focus(&mut self, _target: fret_ui::elements::GlobalElementId) {}
364    }
365
366    impl UiDragActionHost for PointerHost {
367        fn begin_drag_with_kind(
368            &mut self,
369            _pointer_id: PointerId,
370            _kind: fret_runtime::DragKindId,
371            _source_window: AppWindowId,
372            _start: Point,
373        ) {
374        }
375
376        fn begin_cross_window_drag_with_kind(
377            &mut self,
378            _pointer_id: PointerId,
379            _kind: fret_runtime::DragKindId,
380            _source_window: AppWindowId,
381            _start: Point,
382        ) {
383        }
384
385        fn drag(&self, _pointer_id: PointerId) -> Option<&fret_runtime::DragSession> {
386            None
387        }
388
389        fn drag_mut(&mut self, _pointer_id: PointerId) -> Option<&mut fret_runtime::DragSession> {
390            None
391        }
392
393        fn cancel_drag(&mut self, _pointer_id: PointerId) {}
394    }
395
396    impl UiPointerActionHost for PointerHost {
397        fn bounds(&self) -> Rect {
398            Rect::new(
399                Point::new(Px(0.0), Px(0.0)),
400                Size::new(Px(800.0), Px(600.0)),
401            )
402        }
403
404        fn capture_pointer(&mut self) {}
405
406        fn release_pointer_capture(&mut self) {}
407
408        fn set_cursor_icon(&mut self, _icon: fret_core::CursorIcon) {}
409
410        fn prevent_default(&mut self, _action: fret_runtime::DefaultAction) {}
411    }
412
413    #[test]
414    fn context_menu_popper_vars_available_height_tracks_flipped_side_space() {
415        let outer = Rect::new(
416            Point::new(Px(0.0), Px(0.0)),
417            Size::new(Px(100.0), Px(100.0)),
418        );
419        let anchor = Rect::new(Point::new(Px(10.0), Px(70.0)), Size::new(Px(1.0), Px(1.0)));
420
421        let placement = popper::PopperContentPlacement::new(
422            popper::LayoutDirection::Ltr,
423            popper::Side::Bottom,
424            popper::Align::Start,
425            Px(0.0),
426        );
427        let vars = context_menu_popper_vars(outer, anchor, Px(0.0), placement);
428        assert!(vars.available_height.0 > 60.0 && vars.available_height.0 < 90.0);
429    }
430
431    #[test]
432    fn touch_long_press_arms_timer_and_returns_anchor_on_fire() {
433        let window = AppWindowId::default();
434        let action_cx = ActionCx {
435            window,
436            target: fret_ui::elements::GlobalElementId(1),
437        };
438        let mut host = PointerHost::default();
439        let long_press = context_menu_touch_long_press();
440
441        let pointer_id = PointerId(7);
442        let origin = Point::new(Px(120.0), Px(88.0));
443        let tick_id = host.app.tick_id();
444        let handled = context_menu_touch_long_press_on_pointer_down(
445            &long_press,
446            &mut host,
447            action_cx,
448            PointerDownCx {
449                pointer_id,
450                position: origin,
451                position_local: origin,
452                position_window: Some(origin),
453                tick_id,
454                pixels_per_point: 1.0,
455                button: MouseButton::Left,
456                modifiers: Modifiers::default(),
457                click_count: 1,
458                pointer_type: PointerType::Touch,
459                hit_is_text_input: false,
460                hit_is_pressable: false,
461                hit_pressable_target: None,
462                hit_pressable_target_in_descendant_subtree: false,
463            },
464        );
465        assert!(handled);
466
467        let effects = host.app.flush_effects();
468        let token = effects.iter().find_map(|effect| match effect {
469            Effect::SetTimer { token, after, .. }
470                if *after == CONTEXT_MENU_TOUCH_LONG_PRESS_DELAY =>
471            {
472                Some(*token)
473            }
474            _ => None,
475        });
476        let Some(token) = token else {
477            panic!("expected long-press timer effect; effects={effects:?}");
478        };
479
480        let anchor = context_menu_touch_long_press_take_anchor_on_timer(&long_press, token);
481        assert_eq!(anchor, Some(origin));
482    }
483
484    #[test]
485    fn touch_long_press_clears_when_pointer_moves_far() {
486        let window = AppWindowId::default();
487        let action_cx = ActionCx {
488            window,
489            target: fret_ui::elements::GlobalElementId(1),
490        };
491        let mut host = PointerHost::default();
492        let long_press = context_menu_touch_long_press();
493
494        let pointer_id = PointerId(9);
495        let origin = Point::new(Px(10.0), Px(10.0));
496        let tick_id = host.app.tick_id();
497        let _ = context_menu_touch_long_press_on_pointer_down(
498            &long_press,
499            &mut host,
500            action_cx,
501            PointerDownCx {
502                pointer_id,
503                position: origin,
504                position_local: origin,
505                position_window: Some(origin),
506                tick_id,
507                pixels_per_point: 1.0,
508                button: MouseButton::Left,
509                modifiers: Modifiers::default(),
510                click_count: 1,
511                pointer_type: PointerType::Touch,
512                hit_is_text_input: false,
513                hit_is_pressable: false,
514                hit_pressable_target: None,
515                hit_pressable_target_in_descendant_subtree: false,
516            },
517        );
518
519        let effects = host.app.flush_effects();
520        let token = effects.iter().find_map(|effect| match effect {
521            Effect::SetTimer { token, after, .. }
522                if *after == CONTEXT_MENU_TOUCH_LONG_PRESS_DELAY =>
523            {
524                Some(*token)
525            }
526            _ => None,
527        });
528        let Some(token) = token else {
529            panic!("expected long-press timer effect; effects={effects:?}");
530        };
531
532        let _ = context_menu_touch_long_press_on_pointer_move(
533            &long_press,
534            &mut host,
535            PointerMoveCx {
536                pointer_id,
537                position: Point::new(Px(40.0), Px(40.0)),
538                position_local: Point::new(Px(40.0), Px(40.0)),
539                position_window: Some(Point::new(Px(40.0), Px(40.0))),
540                tick_id,
541                pixels_per_point: 1.0,
542                velocity_window: None,
543                buttons: MouseButtons::default(),
544                modifiers: Modifiers::default(),
545                pointer_type: PointerType::Touch,
546            },
547        );
548
549        let anchor = context_menu_touch_long_press_take_anchor_on_timer(&long_press, token);
550        assert!(anchor.is_none(), "moved too far; long-press should cancel");
551
552        let cancel_effects = host.app.flush_effects();
553        assert!(
554            cancel_effects
555                .iter()
556                .any(|effect| matches!(effect, Effect::CancelTimer { token: t } if *t == token)),
557            "expected timer cancellation effect after touch move; effects={cancel_effects:?}"
558        );
559    }
560
561    #[test]
562    fn touch_long_press_clears_on_pointer_cancel() {
563        let window = AppWindowId::default();
564        let action_cx = ActionCx {
565            window,
566            target: fret_ui::elements::GlobalElementId(1),
567        };
568        let mut host = PointerHost::default();
569        let long_press = context_menu_touch_long_press();
570
571        let pointer_id = PointerId(5);
572        let tick_id = host.app.tick_id();
573        let _ = context_menu_touch_long_press_on_pointer_down(
574            &long_press,
575            &mut host,
576            action_cx,
577            PointerDownCx {
578                pointer_id,
579                position: Point::new(Px(50.0), Px(60.0)),
580                position_local: Point::new(Px(50.0), Px(60.0)),
581                position_window: Some(Point::new(Px(50.0), Px(60.0))),
582                tick_id,
583                pixels_per_point: 1.0,
584                button: MouseButton::Left,
585                modifiers: Modifiers::default(),
586                click_count: 1,
587                pointer_type: PointerType::Touch,
588                hit_is_text_input: false,
589                hit_is_pressable: false,
590                hit_pressable_target: None,
591                hit_pressable_target_in_descendant_subtree: false,
592            },
593        );
594
595        let _ = host.app.flush_effects();
596
597        let _ = context_menu_touch_long_press_on_pointer_cancel(
598            &long_press,
599            &mut host,
600            PointerCancelCx {
601                pointer_id,
602                position: None,
603                position_local: None,
604                position_window: None,
605                tick_id,
606                pixels_per_point: 1.0,
607                buttons: MouseButtons::default(),
608                modifiers: Modifiers::default(),
609                pointer_type: PointerType::Touch,
610                reason: PointerCancelReason::LeftWindow,
611            },
612        );
613
614        let state = long_press.lock().unwrap_or_else(|e| e.into_inner());
615        assert!(state.pointer_id.is_none());
616        assert!(state.origin.is_none());
617        assert!(state.timer.is_none());
618    }
619}