Skip to main content

retroglyph_ui/interact/
hit.rs

1//! [`HitTester`]: resolve a pointer position to the topmost widget id
2//! occupying it.
3
4use alloc::vec::Vec;
5
6use retroglyph_core::grid::{Pos, Rect};
7
8/// A per-frame registry of `(Rect, Id)` pairs, queried by pointer position
9/// to find the topmost widget under a point.
10///
11/// Standalone and headless: no [`Backend`](retroglyph_core::backend::Backend)
12/// dependency, so it's usable (and unit-testable) without a
13/// [`Terminal`](retroglyph_core::terminal::Terminal) or any drawing at all, e.g. for
14/// hand-rolled hit-testing outside of [`Interaction`](crate::Interaction).
15///
16/// Registrations are draw-ordered: a later [`push`](Self::push) means drawn
17/// (and therefore visually on top) later, so [`topmost_at`](Self::topmost_at)
18/// scans back-to-front and returns the *last* match. This mirrors the
19/// painter's algorithm every widget in this crate already draws with.
20///
21/// A [`push_barrier`](Self::push_barrier)ed rect additionally stops that scan: a point inside a
22/// barrier's rect never resolves to anything registered before the barrier, regardless of overlap
23/// (an overlay region claims everything under it, not just what it happens to draw over), while a
24/// point outside the barrier's rect is unaffected by it and keeps scanning past it normally.
25#[derive(Debug, Clone)]
26pub struct HitTester<Id> {
27    hits: Vec<Entry<Id>>,
28}
29
30#[derive(Debug, Clone)]
31enum Entry<Id> {
32    Hit(Rect, Id),
33    Barrier(Rect),
34}
35
36impl<Id> HitTester<Id> {
37    /// An empty registry.
38    #[must_use]
39    pub const fn new() -> Self {
40        Self { hits: Vec::new() }
41    }
42
43    /// Register `id` as occupying `rect`, on top of everything registered
44    /// so far this pass.
45    pub fn push(&mut self, rect: Rect, id: Id) {
46        self.hits.push(Entry::Hit(rect, id));
47    }
48
49    /// Register `rect` as a barrier, on top of everything registered so far this pass: see the
50    /// [`HitTester`] docs for what that does to [`topmost_at`](Self::topmost_at).
51    pub fn push_barrier(&mut self, rect: Rect) {
52        self.hits.push(Entry::Barrier(rect));
53    }
54
55    /// Discard all registrations, e.g. at the start of a new frame's draw
56    /// pass.
57    pub fn clear(&mut self) {
58        self.hits.clear();
59    }
60
61    /// Number of rects currently registered, hits and barriers combined.
62    #[must_use]
63    pub const fn len(&self) -> usize {
64        self.hits.len()
65    }
66
67    /// `true` if nothing has been registered.
68    #[must_use]
69    pub const fn is_empty(&self) -> bool {
70        self.hits.is_empty()
71    }
72}
73
74impl<Id: Copy> HitTester<Id> {
75    /// The id of the topmost (most recently [`push`](Self::push)ed)
76    /// registration whose rect contains `pos`, if any.
77    ///
78    /// Stops at the first (most recently registered) [`push_barrier`](Self::push_barrier)ed rect
79    /// that also contains `pos`: nothing registered earlier than that barrier can win at a
80    /// position the barrier covers.
81    #[must_use]
82    pub fn topmost_at(&self, pos: Pos) -> Option<Id> {
83        for entry in self.hits.iter().rev() {
84            match entry {
85                Entry::Hit(rect, id) if rect.contains_pos(pos) => return Some(*id),
86                Entry::Barrier(rect) if rect.contains_pos(pos) => return None,
87                Entry::Hit(_, _) | Entry::Barrier(_) => {}
88            }
89        }
90        None
91    }
92}
93
94// Not `#[derive(Default)]`: that would add an unnecessary `Id: Default`
95// bound to the generated impl, even though an empty `Vec<(Rect, Id)>` never
96// needs one.
97impl<Id> Default for HitTester<Id> {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn topmost_at_prefers_the_most_recently_pushed_overlap() {
109        let mut hits = HitTester::new();
110        hits.push(Rect::new(0, 0, 10, 10), "back");
111        hits.push(Rect::new(5, 5, 10, 10), "front");
112
113        assert_eq!(hits.topmost_at(Pos::new(6, 6)), Some("front")); // overlap
114        assert_eq!(hits.topmost_at(Pos::new(1, 1)), Some("back")); // back only
115        assert_eq!(hits.topmost_at(Pos::new(20, 20)), None); // neither
116    }
117
118    #[test]
119    fn clear_empties_the_registry() {
120        let mut hits = HitTester::new();
121        hits.push(Rect::new(0, 0, 5, 5), 1);
122        assert!(!hits.is_empty());
123        hits.clear();
124        assert!(hits.is_empty());
125        assert_eq!(hits.len(), 0);
126        assert_eq!(hits.topmost_at(Pos::new(0, 0)), None);
127    }
128
129    #[test]
130    fn default_is_empty() {
131        let hits: HitTester<()> = HitTester::default();
132        assert!(hits.is_empty());
133    }
134
135    #[test]
136    fn a_barrier_hides_hits_registered_before_it_inside_its_own_rect() {
137        let mut hits = HitTester::new();
138        hits.push(Rect::new(0, 0, 10, 10), "behind");
139        hits.push_barrier(Rect::new(2, 2, 4, 4));
140
141        assert_eq!(hits.topmost_at(Pos::new(3, 3)), None); // inside the barrier
142        assert_eq!(hits.topmost_at(Pos::new(0, 0)), Some("behind")); // outside the barrier
143    }
144
145    #[test]
146    fn a_hit_registered_after_a_barrier_still_wins_inside_it() {
147        let mut hits = HitTester::new();
148        hits.push(Rect::new(0, 0, 10, 10), "behind");
149        hits.push_barrier(Rect::new(2, 2, 4, 4));
150        hits.push(Rect::new(3, 3, 1, 1), "in front");
151
152        assert_eq!(hits.topmost_at(Pos::new(3, 3)), Some("in front"));
153    }
154
155    #[test]
156    fn a_barrier_does_not_affect_positions_outside_its_own_rect() {
157        let mut hits = HitTester::new();
158        hits.push(Rect::new(0, 0, 10, 10), "back");
159        hits.push_barrier(Rect::new(2, 2, 2, 2));
160        hits.push(Rect::new(5, 5, 3, 3), "front");
161
162        // Outside the barrier's own rect, resolution proceeds normally past it.
163        assert_eq!(hits.topmost_at(Pos::new(6, 6)), Some("front"));
164        assert_eq!(hits.topmost_at(Pos::new(0, 0)), Some("back"));
165    }
166
167    #[test]
168    fn an_older_barrier_does_not_shadow_a_newer_ones_uncovered_region() {
169        let mut hits = HitTester::new();
170        hits.push(Rect::new(0, 0, 10, 10), "back");
171        hits.push_barrier(Rect::new(0, 0, 10, 10));
172        hits.push(Rect::new(2, 2, 2, 2), "modal widget");
173
174        // The most recent barrier is the modal's own full-area one, registered before its
175        // widget: inside the widget's rect, the widget itself wins.
176        assert_eq!(hits.topmost_at(Pos::new(2, 2)), Some("modal widget"));
177        // Elsewhere inside the modal's barrier but outside its widget: nothing wins, including
178        // "back", which sits behind the barrier.
179        assert_eq!(hits.topmost_at(Pos::new(8, 8)), None);
180    }
181}