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}