fret_ui_kit/primitives/menu/
pointer_grace_intent.rs1use 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 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
167pub 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 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)); assert!(!is_pointer_moving_to_submenu(prev, next, geometry, Px(0.0)));
279 }
280}