egui/hit_test.rs
1use ahash::HashMap;
2
3use emath::TSTransform;
4
5use crate::{ahash, emath, id::IdSet, LayerId, Pos2, Rect, Sense, WidgetRect, WidgetRects};
6
7/// Result of a hit-test against [`WidgetRects`].
8///
9/// Answers the question "what is under the mouse pointer?".
10///
11/// Note that this doesn't care if the mouse button is pressed or not,
12/// or if we're currently already dragging something.
13#[derive(Clone, Debug, Default)]
14pub struct WidgetHits {
15 /// All widgets close to the pointer, back-to-front.
16 ///
17 /// This is a superset of all other widgets in this struct.
18 pub close: Vec<WidgetRect>,
19
20 /// All widgets that contains the pointer, back-to-front.
21 ///
22 /// i.e. both a Window and the Button in it can contain the pointer.
23 ///
24 /// Some of these may be widgets in a layer below the top-most layer.
25 ///
26 /// This will be used for hovering.
27 pub contains_pointer: Vec<WidgetRect>,
28
29 /// If the user would start a clicking now, this is what would be clicked.
30 ///
31 /// This is the top one under the pointer, or closest one of the top-most.
32 pub click: Option<WidgetRect>,
33
34 /// If the user would start a dragging now, this is what would be dragged.
35 ///
36 /// This is the top one under the pointer, or closest one of the top-most.
37 pub drag: Option<WidgetRect>,
38}
39
40/// Find the top or closest widgets to the given position,
41/// none which is closer than `search_radius`.
42pub fn hit_test(
43 widgets: &WidgetRects,
44 layer_order: &[LayerId],
45 layer_to_global: &HashMap<LayerId, TSTransform>,
46 pos: Pos2,
47 search_radius: f32,
48) -> WidgetHits {
49 profiling::function_scope!();
50
51 let search_radius_sq = search_radius * search_radius;
52
53 // Transform the position into the local coordinate space of each layer:
54 let pos_in_layers: HashMap<LayerId, Pos2> = layer_to_global
55 .iter()
56 .map(|(layer_id, to_global)| (*layer_id, to_global.inverse() * pos))
57 .collect();
58
59 let mut closest_dist_sq = f32::INFINITY;
60 let mut closest_hit = None;
61
62 // First pass: find the few widgets close to the given position, sorted back-to-front.
63 let mut close: Vec<WidgetRect> = layer_order
64 .iter()
65 .filter(|layer| layer.order.allow_interaction())
66 .flat_map(|&layer_id| widgets.get_layer(layer_id))
67 .filter(|&w| {
68 if w.interact_rect.is_negative() {
69 return false;
70 }
71
72 let pos_in_layer = pos_in_layers.get(&w.layer_id).copied().unwrap_or(pos);
73 // TODO(emilk): we should probably do the distance testing in global space instead
74 let dist_sq = w.interact_rect.distance_sq_to_pos(pos_in_layer);
75
76 // In tie, pick last = topmost.
77 if dist_sq <= closest_dist_sq {
78 closest_dist_sq = dist_sq;
79 closest_hit = Some(w);
80 }
81
82 dist_sq <= search_radius_sq
83 })
84 .copied()
85 .collect();
86
87 // Transform to global coordinates:
88 for hit in &mut close {
89 if let Some(to_global) = layer_to_global.get(&hit.layer_id).copied() {
90 *hit = hit.transform(to_global);
91 }
92 }
93
94 // When using layer transforms it is common to stack layers close to each other.
95 // For instance, you may have a resize-separator on a panel, with two
96 // transform-layers on either side.
97 // The resize-separator is technically in a layer _behind_ the transform-layers,
98 // but the user doesn't perceive it as such.
99 // So how do we handle this case?
100 //
101 // If we just allow interactions with ALL close widgets,
102 // then we might accidentally allow clicks through windows and other bad stuff.
103 //
104 // Let's try this:
105 // * Set up a hit-area (based on search_radius)
106 // * Iterate over all hits top-to-bottom
107 // * Stop if any hit covers the whole hit-area, otherwise keep going
108 // * Collect the layers ids in a set
109 // * Remove all widgets not in the above layer set
110 //
111 // This will most often result in only one layer,
112 // but if the pointer is at the edge of a layer, we might include widgets in
113 // a layer behind it.
114
115 let mut included_layers: ahash::HashSet<LayerId> = Default::default();
116 for hit in close.iter().rev() {
117 included_layers.insert(hit.layer_id);
118 let hit_covers_search_area = contains_circle(hit.interact_rect, pos, search_radius);
119 if hit_covers_search_area {
120 break; // nothing behind this layer could ever be interacted with
121 }
122 }
123
124 close.retain(|hit| included_layers.contains(&hit.layer_id));
125
126 // If a widget is disabled, treat it as if it isn't sensing anything.
127 // This simplifies the code in `hit_test_on_close` so it doesn't have to check
128 // the `enabled` flag everywhere:
129 for w in &mut close {
130 if !w.enabled {
131 w.sense -= Sense::CLICK;
132 w.sense -= Sense::DRAG;
133 }
134 }
135
136 // Find widgets which are hidden behind another widget and discard them.
137 // This is the case when a widget fully contains another widget and is on a different layer.
138 // It prevents "hovering through" widgets when there is a clickable widget behind.
139
140 let mut hidden = IdSet::default();
141 for (i, current) in close.iter().enumerate().rev() {
142 for next in &close[i + 1..] {
143 if next.interact_rect.contains_rect(current.interact_rect)
144 && current.layer_id != next.layer_id
145 {
146 hidden.insert(current.id);
147 }
148 }
149 }
150
151 close.retain(|c| !hidden.contains(&c.id));
152
153 let mut hits = hit_test_on_close(&close, pos);
154
155 hits.contains_pointer = close
156 .iter()
157 .filter(|widget| widget.interact_rect.contains(pos))
158 .copied()
159 .collect();
160
161 hits.close = close;
162
163 {
164 // Undo the to_global-transform we applied earlier,
165 // go back to local layer-coordinates:
166
167 let restore_widget_rect = |w: &mut WidgetRect| {
168 *w = widgets.get(w.id).copied().unwrap_or(*w);
169 };
170
171 for wr in &mut hits.close {
172 restore_widget_rect(wr);
173 }
174 for wr in &mut hits.contains_pointer {
175 restore_widget_rect(wr);
176 }
177 if let Some(wr) = &mut hits.drag {
178 debug_assert!(wr.sense.senses_drag());
179 restore_widget_rect(wr);
180 }
181 if let Some(wr) = &mut hits.click {
182 debug_assert!(wr.sense.senses_click());
183 restore_widget_rect(wr);
184 }
185 }
186
187 hits
188}
189
190/// Returns true if the rectangle contains the whole circle.
191fn contains_circle(interact_rect: emath::Rect, pos: Pos2, radius: f32) -> bool {
192 interact_rect.shrink(radius).contains(pos)
193}
194
195fn hit_test_on_close(close: &[WidgetRect], pos: Pos2) -> WidgetHits {
196 #![allow(clippy::collapsible_else_if)]
197
198 // First find the best direct hits:
199 let hit_click = find_closest_within(
200 close.iter().copied().filter(|w| w.sense.senses_click()),
201 pos,
202 0.0,
203 );
204 let hit_drag = find_closest_within(
205 close.iter().copied().filter(|w| w.sense.senses_drag()),
206 pos,
207 0.0,
208 );
209
210 match (hit_click, hit_drag) {
211 (None, None) => {
212 // No direct hit on anything. Find the closest interactive widget.
213
214 let closest = find_closest(
215 close
216 .iter()
217 .copied()
218 .filter(|w| w.sense.senses_click() || w.sense.senses_drag()),
219 pos,
220 );
221
222 if let Some(closest) = closest {
223 WidgetHits {
224 click: closest.sense.senses_click().then_some(closest),
225 drag: closest.sense.senses_drag().then_some(closest),
226 ..Default::default()
227 }
228 } else {
229 // Found nothing
230 WidgetHits {
231 click: None,
232 drag: None,
233 ..Default::default()
234 }
235 }
236 }
237
238 (None, Some(hit_drag)) => {
239 // We have a perfect hit on a drag, but not on click.
240
241 // We have a direct hit on something that implements drag.
242 // This could be a big background thing, like a `ScrollArea` background,
243 // or a moveable window.
244 // It could also be something small, like a slider, or panel resize handle.
245
246 let closest_click = find_closest(
247 close.iter().copied().filter(|w| w.sense.senses_click()),
248 pos,
249 );
250 if let Some(closest_click) = closest_click {
251 if closest_click.sense.senses_drag() {
252 // We have something close that sense both clicks and drag.
253 // Should we use it over the direct drag-hit?
254 if hit_drag
255 .interact_rect
256 .contains_rect(closest_click.interact_rect)
257 {
258 // This is a smaller thing on a big background - help the user hit it,
259 // and ignore the big drag background.
260 WidgetHits {
261 click: Some(closest_click),
262 drag: Some(closest_click),
263 ..Default::default()
264 }
265 } else {
266 // The drag-widget is separate from the click-widget,
267 // so return only the drag-widget
268 WidgetHits {
269 click: None,
270 drag: Some(hit_drag),
271 ..Default::default()
272 }
273 }
274 } else {
275 // This is a close pure-click widget.
276 // However, we should be careful to only return two different widgets
277 // when it is absolutely not going to confuse the user.
278 if hit_drag
279 .interact_rect
280 .contains_rect(closest_click.interact_rect)
281 {
282 // The drag widget is a big background thing (scroll area),
283 // so returning a separate click widget should not be confusing
284 WidgetHits {
285 click: Some(closest_click),
286 drag: Some(hit_drag),
287 ..Default::default()
288 }
289 } else {
290 // The two widgets are just two normal small widgets close to each other.
291 // Highlighting both would be very confusing.
292 WidgetHits {
293 click: None,
294 drag: Some(hit_drag),
295 ..Default::default()
296 }
297 }
298 }
299 } else {
300 // No close clicks.
301 // Maybe there is a close drag widget, that is a smaller
302 // widget floating on top of a big background?
303 // If so, it would be nice to help the user click that.
304 let closest_drag = find_closest(
305 close
306 .iter()
307 .copied()
308 .filter(|w| w.sense.senses_drag() && w.id != hit_drag.id),
309 pos,
310 );
311
312 if let Some(closest_drag) = closest_drag {
313 if hit_drag
314 .interact_rect
315 .contains_rect(closest_drag.interact_rect)
316 {
317 // `hit_drag` is a big background thing and `closest_drag` is something small on top of it.
318 // Be helpful and return the small things:
319 return WidgetHits {
320 click: None,
321 drag: Some(closest_drag),
322 ..Default::default()
323 };
324 }
325 }
326
327 WidgetHits {
328 click: None,
329 drag: Some(hit_drag),
330 ..Default::default()
331 }
332 }
333 }
334
335 (Some(hit_click), None) => {
336 // We have a perfect hit on a click-widget, but not on a drag-widget.
337 //
338 // Note that we don't look for a close drag widget in this case,
339 // because I can't think of a case where that would be helpful.
340 // This is in contrast with the opposite case,
341 // where when hovering directly over a drag-widget (like a big ScrollArea),
342 // we look for close click-widgets (e.g. buttons).
343 // This is because big background drag-widgets (ScrollArea, Window) are common,
344 // but big clickable things aren't.
345 // Even if they were, I think it would be confusing for a user if clicking
346 // a drag-only widget would click something _behind_ it.
347
348 WidgetHits {
349 click: Some(hit_click),
350 drag: None,
351 ..Default::default()
352 }
353 }
354
355 (Some(hit_click), Some(hit_drag)) => {
356 // We have a perfect hit on both click and drag. Which is the topmost?
357 let click_idx = close.iter().position(|w| *w == hit_click).unwrap();
358 let drag_idx = close.iter().position(|w| *w == hit_drag).unwrap();
359
360 let click_is_on_top_of_drag = drag_idx < click_idx;
361 if click_is_on_top_of_drag {
362 if hit_click.sense.senses_drag() {
363 // The top thing senses both clicks and drags.
364 WidgetHits {
365 click: Some(hit_click),
366 drag: Some(hit_click),
367 ..Default::default()
368 }
369 } else {
370 // They are interested in different things,
371 // and click is on top. Report both hits,
372 // e.g. the top Button and the ScrollArea behind it.
373 WidgetHits {
374 click: Some(hit_click),
375 drag: Some(hit_drag),
376 ..Default::default()
377 }
378 }
379 } else {
380 if hit_drag.sense.senses_click() {
381 // The top thing senses both clicks and drags.
382 WidgetHits {
383 click: Some(hit_drag),
384 drag: Some(hit_drag),
385 ..Default::default()
386 }
387 } else {
388 // The top things senses only drags,
389 // so we ignore the click-widget, because it would be confusing
390 // if clicking a drag-widget would actually click something else below it.
391 WidgetHits {
392 click: None,
393 drag: Some(hit_drag),
394 ..Default::default()
395 }
396 }
397 }
398 }
399 }
400}
401
402fn find_closest(widgets: impl Iterator<Item = WidgetRect>, pos: Pos2) -> Option<WidgetRect> {
403 find_closest_within(widgets, pos, f32::INFINITY)
404}
405
406fn find_closest_within(
407 widgets: impl Iterator<Item = WidgetRect>,
408 pos: Pos2,
409 max_dist: f32,
410) -> Option<WidgetRect> {
411 let mut closest: Option<WidgetRect> = None;
412 let mut closest_dist_sq = max_dist * max_dist;
413 for widget in widgets {
414 if widget.interact_rect.is_negative() {
415 continue;
416 }
417
418 let dist_sq = widget.interact_rect.distance_sq_to_pos(pos);
419
420 if let Some(closest) = closest {
421 if dist_sq == closest_dist_sq {
422 // It's a tie! Pick the thin candidate over the thick one.
423 // This makes it easier to hit a thin resize-handle, for instance:
424 if should_prioritize_hits_on_back(closest.interact_rect, widget.interact_rect) {
425 continue;
426 }
427 }
428 }
429
430 // In case of a tie, take the last one on top.
431 if dist_sq <= closest_dist_sq {
432 closest_dist_sq = dist_sq;
433 closest = Some(widget);
434 }
435 }
436
437 closest
438}
439
440/// Should we prioritize hits on `back` over those on `front`?
441///
442/// `back` should be behind the `front` widget.
443///
444/// Returns true if `back` is a small hit-target and `front` is not.
445fn should_prioritize_hits_on_back(back: Rect, front: Rect) -> bool {
446 if front.contains_rect(back) {
447 return false; // back widget is fully occluded; no way to hit it
448 }
449
450 // Reduce each rect to its width or height, whichever is smaller:
451 let back = back.width().min(back.height());
452 let front = front.width().min(front.height());
453
454 // These are hard-coded heuristics that could surely be improved.
455 let back_is_much_thinner = back <= 0.5 * front;
456 let back_is_thin = back <= 16.0;
457
458 back_is_much_thinner && back_is_thin
459}
460
461#[cfg(test)]
462mod tests {
463 use emath::{pos2, vec2, Rect};
464
465 use crate::{Id, Sense};
466
467 use super::*;
468
469 fn wr(id: Id, sense: Sense, rect: Rect) -> WidgetRect {
470 WidgetRect {
471 id,
472 layer_id: LayerId::background(),
473 rect,
474 interact_rect: rect,
475 sense,
476 enabled: true,
477 }
478 }
479
480 #[test]
481 fn buttons_on_window() {
482 let widgets = vec![
483 wr(
484 Id::new("bg-area"),
485 Sense::drag(),
486 Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)),
487 ),
488 wr(
489 Id::new("click"),
490 Sense::click(),
491 Rect::from_min_size(pos2(10.0, 10.0), vec2(10.0, 10.0)),
492 ),
493 wr(
494 Id::new("click-and-drag"),
495 Sense::click_and_drag(),
496 Rect::from_min_size(pos2(100.0, 10.0), vec2(10.0, 10.0)),
497 ),
498 ];
499
500 // Perfect hit:
501 let hits = hit_test_on_close(&widgets, pos2(15.0, 15.0));
502 assert_eq!(hits.click.unwrap().id, Id::new("click"));
503 assert_eq!(hits.drag.unwrap().id, Id::new("bg-area"));
504
505 // Close hit:
506 let hits = hit_test_on_close(&widgets, pos2(5.0, 5.0));
507 assert_eq!(hits.click.unwrap().id, Id::new("click"));
508 assert_eq!(hits.drag.unwrap().id, Id::new("bg-area"));
509
510 // Perfect hit:
511 let hits = hit_test_on_close(&widgets, pos2(105.0, 15.0));
512 assert_eq!(hits.click.unwrap().id, Id::new("click-and-drag"));
513 assert_eq!(hits.drag.unwrap().id, Id::new("click-and-drag"));
514
515 // Close hit - should still ignore the drag-background so as not to confuse the user:
516 let hits = hit_test_on_close(&widgets, pos2(105.0, 5.0));
517 assert_eq!(hits.click.unwrap().id, Id::new("click-and-drag"));
518 assert_eq!(hits.drag.unwrap().id, Id::new("click-and-drag"));
519 }
520
521 #[test]
522 fn thin_resize_handle_next_to_label() {
523 let widgets = vec![
524 wr(
525 Id::new("bg-area"),
526 Sense::drag(),
527 Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)),
528 ),
529 wr(
530 Id::new("bg-left-label"),
531 Sense::click_and_drag(),
532 Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 100.0)),
533 ),
534 wr(
535 Id::new("thin-drag-handle"),
536 Sense::drag(),
537 Rect::from_min_size(pos2(30.0, 0.0), vec2(70.0, 100.0)),
538 ),
539 wr(
540 Id::new("fg-right-label"),
541 Sense::click_and_drag(),
542 Rect::from_min_size(pos2(60.0, 0.0), vec2(50.0, 100.0)),
543 ),
544 ];
545
546 for (i, w) in widgets.iter().enumerate() {
547 println!("Widget {i}: {:?}", w.id);
548 }
549
550 // In the middle of the bg-left-label:
551 let hits = hit_test_on_close(&widgets, pos2(25.0, 50.0));
552 assert_eq!(hits.click.unwrap().id, Id::new("bg-left-label"));
553 assert_eq!(hits.drag.unwrap().id, Id::new("bg-left-label"));
554
555 // On both the left click-and-drag and thin handle, but the thin handle is on top and should win:
556 let hits = hit_test_on_close(&widgets, pos2(35.0, 50.0));
557 assert_eq!(hits.click, None);
558 assert_eq!(hits.drag.unwrap().id, Id::new("thin-drag-handle"));
559
560 // Only on the thin-drag-handle:
561 let hits = hit_test_on_close(&widgets, pos2(50.0, 50.0));
562 assert_eq!(hits.click, None);
563 assert_eq!(hits.drag.unwrap().id, Id::new("thin-drag-handle"));
564
565 // On both the thin handle and right label. The label is on top and should win
566 let hits = hit_test_on_close(&widgets, pos2(65.0, 50.0));
567 assert_eq!(hits.click.unwrap().id, Id::new("fg-right-label"));
568 assert_eq!(hits.drag.unwrap().id, Id::new("fg-right-label"));
569 }
570}