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 && 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 WidgetHits {
335 click: None,
336 drag: Some(hit_drag),
337 ..Default::default()
338 }
339 }
340 }
341
342 (Some(hit_click), None) => {
343 // We have a perfect hit on a click-widget, but not on a drag-widget.
344 //
345 // Note that we don't look for a close drag widget in this case,
346 // because I can't think of a case where that would be helpful.
347 // This is in contrast with the opposite case,
348 // where when hovering directly over a drag-widget (like a big ScrollArea),
349 // we look for close click-widgets (e.g. buttons).
350 // This is because big background drag-widgets (ScrollArea, Window) are common,
351 // but big clickable things aren't.
352 // Even if they were, I think it would be confusing for a user if clicking
353 // a drag-only widget would click something _behind_ it.
354
355 WidgetHits {
356 click: Some(hit_click),
357 drag: None,
358 ..Default::default()
359 }
360 }
361
362 (Some(hit_click), Some(hit_drag)) => {
363 // We have a perfect hit on both click and drag. Which is the topmost?
364 let click_idx = close.iter().position(|w| *w == hit_click).unwrap();
365 let drag_idx = close.iter().position(|w| *w == hit_drag).unwrap();
366
367 let click_is_on_top_of_drag = drag_idx < click_idx;
368 if click_is_on_top_of_drag {
369 if hit_click.sense.senses_drag() {
370 // The top thing senses both clicks and drags.
371 WidgetHits {
372 click: Some(hit_click),
373 drag: Some(hit_click),
374 ..Default::default()
375 }
376 } else {
377 // They are interested in different things,
378 // and click is on top. Report both hits,
379 // e.g. the top Button and the ScrollArea behind it.
380 WidgetHits {
381 click: Some(hit_click),
382 drag: Some(hit_drag),
383 ..Default::default()
384 }
385 }
386 } else {
387 if hit_drag.sense.senses_click() {
388 // The top thing senses both clicks and drags.
389 WidgetHits {
390 click: Some(hit_drag),
391 drag: Some(hit_drag),
392 ..Default::default()
393 }
394 } else {
395 // The top things senses only drags,
396 // so we ignore the click-widget, because it would be confusing
397 // if clicking a drag-widget would actually click something else below it.
398 WidgetHits {
399 click: None,
400 drag: Some(hit_drag),
401 ..Default::default()
402 }
403 }
404 }
405 }
406 }
407}
408
409fn find_closest(widgets: impl Iterator<Item = WidgetRect>, pos: Pos2) -> Option<WidgetRect> {
410 find_closest_within(widgets, pos, f32::INFINITY)
411}
412
413fn find_closest_within(
414 widgets: impl Iterator<Item = WidgetRect>,
415 pos: Pos2,
416 max_dist: f32,
417) -> Option<WidgetRect> {
418 let mut closest: Option<WidgetRect> = None;
419 let mut closest_dist_sq = max_dist * max_dist;
420 for widget in widgets {
421 if widget.interact_rect.is_negative() {
422 continue;
423 }
424
425 let dist_sq = widget.interact_rect.distance_sq_to_pos(pos);
426
427 if let Some(closest) = closest
428 && dist_sq == closest_dist_sq
429 {
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 // In case of a tie, take the last one = the one on top.
438 if dist_sq <= closest_dist_sq {
439 closest_dist_sq = dist_sq;
440 closest = Some(widget);
441 }
442 }
443
444 closest
445}
446
447/// Should we prioritize hits on `back` over those on `front`?
448///
449/// `back` should be behind the `front` widget.
450///
451/// Returns true if `back` is a small hit-target and `front` is not.
452fn should_prioritize_hits_on_back(back: Rect, front: Rect) -> bool {
453 if front.contains_rect(back) {
454 return false; // back widget is fully occluded; no way to hit it
455 }
456
457 // Reduce each rect to its width or height, whichever is smaller:
458 let back = back.width().min(back.height());
459 let front = front.width().min(front.height());
460
461 // These are hard-coded heuristics that could surely be improved.
462 let back_is_much_thinner = back <= 0.5 * front;
463 let back_is_thin = back <= 16.0;
464
465 back_is_much_thinner && back_is_thin
466}
467
468#[cfg(test)]
469mod tests {
470 #![expect(clippy::print_stdout)]
471
472 use emath::{Rect, pos2, vec2};
473
474 use crate::{Id, Sense};
475
476 use super::*;
477
478 fn wr(id: Id, sense: Sense, rect: Rect) -> WidgetRect {
479 WidgetRect {
480 id,
481 layer_id: LayerId::background(),
482 rect,
483 interact_rect: rect,
484 sense,
485 enabled: true,
486 }
487 }
488
489 #[test]
490 fn buttons_on_window() {
491 let widgets = vec![
492 wr(
493 Id::new("bg-area"),
494 Sense::drag(),
495 Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)),
496 ),
497 wr(
498 Id::new("click"),
499 Sense::click(),
500 Rect::from_min_size(pos2(10.0, 10.0), vec2(10.0, 10.0)),
501 ),
502 wr(
503 Id::new("click-and-drag"),
504 Sense::click_and_drag(),
505 Rect::from_min_size(pos2(100.0, 10.0), vec2(10.0, 10.0)),
506 ),
507 ];
508
509 // Perfect hit:
510 let hits = hit_test_on_close(&widgets, pos2(15.0, 15.0));
511 assert_eq!(hits.click.unwrap().id, Id::new("click"));
512 assert_eq!(hits.drag.unwrap().id, Id::new("bg-area"));
513
514 // Close hit:
515 let hits = hit_test_on_close(&widgets, pos2(5.0, 5.0));
516 assert_eq!(hits.click.unwrap().id, Id::new("click"));
517 assert_eq!(hits.drag.unwrap().id, Id::new("bg-area"));
518
519 // Perfect hit:
520 let hits = hit_test_on_close(&widgets, pos2(105.0, 15.0));
521 assert_eq!(hits.click.unwrap().id, Id::new("click-and-drag"));
522 assert_eq!(hits.drag.unwrap().id, Id::new("click-and-drag"));
523
524 // Close hit - should still ignore the drag-background so as not to confuse the user:
525 let hits = hit_test_on_close(&widgets, pos2(105.0, 5.0));
526 assert_eq!(hits.click.unwrap().id, Id::new("click-and-drag"));
527 assert_eq!(hits.drag.unwrap().id, Id::new("click-and-drag"));
528 }
529
530 #[test]
531 fn thin_resize_handle_next_to_label() {
532 let widgets = vec![
533 wr(
534 Id::new("bg-area"),
535 Sense::drag(),
536 Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)),
537 ),
538 wr(
539 Id::new("bg-left-label"),
540 Sense::click_and_drag(),
541 Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 100.0)),
542 ),
543 wr(
544 Id::new("thin-drag-handle"),
545 Sense::drag(),
546 Rect::from_min_size(pos2(30.0, 0.0), vec2(70.0, 100.0)),
547 ),
548 wr(
549 Id::new("fg-right-label"),
550 Sense::click_and_drag(),
551 Rect::from_min_size(pos2(60.0, 0.0), vec2(50.0, 100.0)),
552 ),
553 ];
554
555 for (i, w) in widgets.iter().enumerate() {
556 println!("Widget {i}: {:?}", w.id);
557 }
558
559 // In the middle of the bg-left-label:
560 let hits = hit_test_on_close(&widgets, pos2(25.0, 50.0));
561 assert_eq!(hits.click.unwrap().id, Id::new("bg-left-label"));
562 assert_eq!(hits.drag.unwrap().id, Id::new("bg-left-label"));
563
564 // On both the left click-and-drag and thin handle, but the thin handle is on top and should win:
565 let hits = hit_test_on_close(&widgets, pos2(35.0, 50.0));
566 assert_eq!(hits.click, None);
567 assert_eq!(hits.drag.unwrap().id, Id::new("thin-drag-handle"));
568
569 // Only on the thin-drag-handle:
570 let hits = hit_test_on_close(&widgets, pos2(50.0, 50.0));
571 assert_eq!(hits.click, None);
572 assert_eq!(hits.drag.unwrap().id, Id::new("thin-drag-handle"));
573
574 // On both the thin handle and right label. The label is on top and should win
575 let hits = hit_test_on_close(&widgets, pos2(65.0, 50.0));
576 assert_eq!(hits.click.unwrap().id, Id::new("fg-right-label"));
577 assert_eq!(hits.drag.unwrap().id, Id::new("fg-right-label"));
578 }
579}