Skip to main content

fret_ui_kit/primitives/menu/
pointer_grace_intent.rs

1//! Pointer grace intent (Radix Menu submenu safe-hover outcomes).
2//!
3//! Radix Menu uses a “pointer grace intent” to avoid closing submenus while the pointer moves
4//! diagonally from a trigger into the submenu content. In the web implementation this is tracked
5//! via pointer direction + a polygon area. In Fret we keep the behavior outcome:
6//!
7//! - treat “moving towards submenu” + “inside corridor” as safe
8//! - cancel a pending close timer when safe
9//! - arm a close-delay timer when unsafe
10//!
11//! This module intentionally does not decide *what* to close when the timer fires; that remains
12//! component policy.
13
14use std::time::Duration;
15
16use fret_core::{Point, Px, Rect};
17use fret_runtime::{Model, TimerToken};
18use fret_ui::action::{ActionCx, PointerMoveCx, UiActionHost};
19
20use crate::headless::safe_hover::safe_hover_contains;
21
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct PointerGraceIntentGeometry {
24    pub reference: Rect,
25    pub floating: Rect,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct PointerGraceIntentConfig {
30    pub buffer: Px,
31    pub close_delay: Duration,
32}
33
34impl PointerGraceIntentConfig {
35    pub fn new(buffer: Px, close_delay: Duration) -> Self {
36        Self {
37            buffer,
38            close_delay,
39        }
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum GraceSide {
45    Left,
46    Right,
47}
48
49pub type Polygon = [Point; 5];
50
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct GraceIntent {
53    pub area: Polygon,
54    pub side: GraceSide,
55}
56
57pub fn grace_side(geometry: PointerGraceIntentGeometry) -> Option<GraceSide> {
58    let reference_left = geometry.reference.origin.x.0;
59    let reference_right = reference_left + geometry.reference.size.width.0;
60    let reference_center = (reference_left + reference_right) / 2.0;
61
62    let floating_left = geometry.floating.origin.x.0;
63    let floating_right = floating_left + geometry.floating.size.width.0;
64
65    if floating_left >= reference_right {
66        return Some(GraceSide::Right);
67    }
68    if floating_right <= reference_left {
69        return Some(GraceSide::Left);
70    }
71
72    // Allow a small horizontal overlap as long as the floating panel is predominantly on one side
73    // of the reference. This matches typical menu submenu layouts that slightly overlap to avoid
74    // visible seams between panels.
75    if floating_left >= reference_center {
76        return Some(GraceSide::Right);
77    }
78    if floating_right <= reference_center {
79        return Some(GraceSide::Left);
80    }
81    None
82}
83
84pub fn pointer_dir(prev: Point, next: Point) -> Option<GraceSide> {
85    if next.x.0 > prev.x.0 {
86        Some(GraceSide::Right)
87    } else if next.x.0 < prev.x.0 {
88        Some(GraceSide::Left)
89    } else {
90        None
91    }
92}
93
94fn is_point_in_polygon(point: Point, polygon: &Polygon) -> bool {
95    let x = point.x.0;
96    let y = point.y.0;
97    let mut inside = false;
98    let mut j = polygon.len() - 1;
99    for i in 0..polygon.len() {
100        let xi = polygon[i].x.0;
101        let yi = polygon[i].y.0;
102        let xj = polygon[j].x.0;
103        let yj = polygon[j].y.0;
104
105        let intersect = (yi > y) != (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
106        if intersect {
107            inside = !inside;
108        }
109        j = i;
110    }
111
112    inside
113}
114
115pub fn is_pointer_in_grace_area(point: Point, intent: GraceIntent) -> bool {
116    is_point_in_polygon(point, &intent.area)
117}
118
119pub fn grace_intent_from_exit_point(
120    exit: Point,
121    geometry: PointerGraceIntentGeometry,
122    bleed: Px,
123) -> Option<GraceIntent> {
124    let side = grace_side(geometry)?;
125
126    let floating = geometry.floating;
127    let floating_left = floating.origin.x.0;
128    let floating_right = floating_left + floating.size.width.0;
129    let floating_top = floating.origin.y.0;
130    let floating_bottom = floating_top + floating.size.height.0;
131
132    let (near_edge, far_edge, exit_x) = match side {
133        GraceSide::Right => (floating_left, floating_right, exit.x.0 - bleed.0),
134        GraceSide::Left => (floating_right, floating_left, exit.x.0 + bleed.0),
135    };
136
137    Some(GraceIntent {
138        area: [
139            Point::new(Px(exit_x), exit.y),
140            Point::new(Px(near_edge), Px(floating_top)),
141            Point::new(Px(far_edge), Px(floating_top)),
142            Point::new(Px(far_edge), Px(floating_bottom)),
143            Point::new(Px(near_edge), Px(floating_bottom)),
144        ],
145        side,
146    })
147}
148
149fn is_pointer_moving_to_submenu(
150    prev: Option<Point>,
151    next: Point,
152    geometry: PointerGraceIntentGeometry,
153    buffer: Px,
154) -> bool {
155    let Some(side) = grace_side(geometry) else {
156        return false;
157    };
158
159    let moving_towards = match prev {
160        None => true,
161        Some(prev) => pointer_dir(prev, next).is_none_or(|dir| dir == side),
162    };
163
164    moving_towards && safe_hover_contains(next, geometry.reference, geometry.floating, buffer)
165}
166
167/// Drive the “submenu close-delay” timer from pointer-move events.
168///
169/// Returns `true` when it changes the timer state.
170pub fn drive_close_timer_on_pointer_move(
171    host: &mut dyn UiActionHost,
172    acx: ActionCx,
173    mv: PointerMoveCx,
174    geometry: Option<PointerGraceIntentGeometry>,
175    config: PointerGraceIntentConfig,
176    last_pointer: &Model<Option<Point>>,
177    close_timer: &Model<Option<TimerToken>>,
178) -> bool {
179    let Some(geometry) = geometry else {
180        let _ = host
181            .models_mut()
182            .update(last_pointer, |v| *v = Some(mv.position));
183        return false;
184    };
185
186    let prev = host.models_mut().read(last_pointer, |v| *v).ok().flatten();
187    let _ = host
188        .models_mut()
189        .update(last_pointer, |v| *v = Some(mv.position));
190
191    let safe = is_pointer_moving_to_submenu(prev, mv.position, geometry, config.buffer);
192
193    let pending = host.models_mut().read(close_timer, |v| *v).ok().flatten();
194    if safe {
195        let Some(token) = pending else {
196            return false;
197        };
198        host.push_effect(fret_runtime::Effect::CancelTimer { token });
199        let _ = host.models_mut().update(close_timer, |v| *v = None);
200        host.request_redraw(acx.window);
201        return true;
202    }
203
204    if pending.is_some() {
205        return false;
206    }
207
208    let token = host.next_timer_token();
209    host.push_effect(fret_runtime::Effect::SetTimer {
210        window: Some(acx.window),
211        token,
212        after: config.close_delay,
213        repeat: None,
214    });
215    let _ = host.models_mut().update(close_timer, |v| *v = Some(token));
216    host.request_redraw(acx.window);
217    true
218}
219
220pub fn last_pointer_is_safe(
221    pointer: Point,
222    geometry: PointerGraceIntentGeometry,
223    buffer: Px,
224) -> bool {
225    safe_hover_contains(pointer, geometry.reference, geometry.floating, buffer)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    use fret_core::Size;
233
234    #[test]
235    fn grace_side_classifies_overlapping_submenu_as_right() {
236        let reference = Rect::new(Point::new(Px(10.0), Px(0.0)), Size::new(Px(20.0), Px(10.0)));
237        // Overlaps by 2px but is mostly to the right.
238        let floating = Rect::new(Point::new(Px(28.0), Px(0.0)), Size::new(Px(20.0), Px(10.0)));
239        let geometry = PointerGraceIntentGeometry {
240            reference,
241            floating,
242        };
243        assert_eq!(grace_side(geometry), Some(GraceSide::Right));
244    }
245
246    #[test]
247    fn last_pointer_is_safe_matches_geometry_corridor() {
248        let reference = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
249        let floating = Rect::new(Point::new(Px(20.0), Px(2.0)), Size::new(Px(10.0), Px(10.0)));
250        let geometry = PointerGraceIntentGeometry {
251            reference,
252            floating,
253        };
254
255        assert!(last_pointer_is_safe(
256            Point::new(Px(12.0), Px(5.0)),
257            geometry,
258            Px(0.0)
259        ));
260        assert!(!last_pointer_is_safe(
261            Point::new(Px(12.0), Px(30.0)),
262            geometry,
263            Px(0.0)
264        ));
265    }
266
267    #[test]
268    fn moving_away_is_not_considered_safe() {
269        let reference = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0)));
270        let floating = Rect::new(Point::new(Px(20.0), Px(2.0)), Size::new(Px(10.0), Px(10.0)));
271        let geometry = PointerGraceIntentGeometry {
272            reference,
273            floating,
274        };
275
276        let prev = Some(Point::new(Px(13.0), Px(5.0)));
277        let next = Point::new(Px(12.0), Px(5.0)); // moving left, away from right-side submenu
278        assert!(!is_pointer_moving_to_submenu(prev, next, geometry, Px(0.0)));
279    }
280}