egui/
hit_test.rs

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