1use 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
31pub const CONTEXT_MENU_TOUCH_LONG_PRESS_DELAY: Duration = Duration::from_millis(500);
33
34pub 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
55pub 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
77pub 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
92pub 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
105pub 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
291pub 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}