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