Skip to main content

kozan_core/layout/
hit_test.rs

1//! Hit testing — point → DOM element lookup.
2//!
3//! Chrome: `HitTestResult` + `HitTestLocation` in
4//! `blink/renderer/core/layout/hit_test_result.h`.
5//!
6//! Walks the fragment tree to find the deepest DOM node at a given point.
7//! Respects overflow clipping and scroll offsets.
8
9use std::sync::Arc;
10
11use kozan_primitives::geometry::{Offset, Point};
12
13use crate::scroll::ScrollOffsets;
14
15use super::fragment::{Fragment, FragmentKind, OverflowClip};
16
17/// Chrome: `HitTestResult`.
18#[derive(Debug, Clone)]
19pub struct HitTestResult {
20    pub node_index: Option<u32>,
21    pub local_point: Point,
22}
23
24impl HitTestResult {
25    pub const NONE: Self = Self {
26        node_index: None,
27        local_point: Point::ZERO,
28    };
29}
30
31/// Walks the fragment tree to find the deepest DOM node at a point.
32///
33/// Chrome: `HitTesting` in `blink/renderer/core/layout/`.
34/// Holds scroll offsets to adjust point coordinates inside scrolled containers.
35pub struct HitTester<'a> {
36    scroll_offsets: &'a ScrollOffsets,
37}
38
39impl<'a> HitTester<'a> {
40    pub fn new(scroll_offsets: &'a ScrollOffsets) -> Self {
41        Self { scroll_offsets }
42    }
43
44    /// Find the deepest DOM node at `point` (CSS px, root-relative).
45    pub fn test(&self, fragment: &Fragment, point: Point) -> HitTestResult {
46        self.test_fragment(fragment, point, Point::ZERO)
47    }
48
49    fn test_fragment(&self, fragment: &Fragment, point: Point, origin: Point) -> HitTestResult {
50        let local = point - origin;
51        let in_bounds = local.dx >= 0.0
52            && local.dy >= 0.0
53            && local.dx < fragment.size.width
54            && local.dy < fragment.size.height;
55
56        if let FragmentKind::Box(ref box_data) = fragment.kind {
57            let clips = box_data.overflow_x != OverflowClip::Visible
58                || box_data.overflow_y != OverflowClip::Visible;
59            if clips && !in_bounds {
60                return HitTestResult::NONE;
61            }
62
63            // Only user-scrollable nodes have scroll offsets.
64            // Must match paint_clipped_children — same gate.
65            let is_user_scrollable = box_data.overflow_x.is_user_scrollable()
66                || box_data.overflow_y.is_user_scrollable();
67            let scroll_offset = if is_user_scrollable {
68                fragment
69                    .dom_node
70                    .map(|id| self.scroll_offsets.offset(id))
71                    .unwrap_or(Offset::ZERO)
72            } else {
73                Offset::ZERO
74            };
75
76            // Walk children in REVERSE order (last painted = front = checked first).
77            for child in box_data.children.iter().rev() {
78                let child_origin =
79                    origin + Offset::new(child.offset.x, child.offset.y) - scroll_offset;
80                let result = self.test_fragment(&child.fragment, point, child_origin);
81                if result.node_index.is_some() {
82                    return result;
83                }
84            }
85        }
86
87        if let FragmentKind::Line(ref line_data) = fragment.kind {
88            for child in line_data.children.iter().rev() {
89                let child_origin = origin + Offset::new(child.offset.x, child.offset.y);
90                let result = self.test_fragment(&child.fragment, point, child_origin);
91                if result.node_index.is_some() {
92                    return result;
93                }
94            }
95        }
96
97        if in_bounds {
98            if let Some(node_index) = fragment.dom_node {
99                return HitTestResult {
100                    node_index: Some(node_index),
101                    local_point: Point::new(local.dx, local.dy),
102                };
103            }
104        }
105
106        HitTestResult::NONE
107    }
108}
109
110/// Cached hit testing — avoids re-walking when cursor barely moved.
111///
112/// Chrome: `HitTestCache` in `blink/renderer/core/layout/`.
113/// Invalidates when fragment pointer changes or cursor moves > 0.5px.
114pub struct HitTestCache {
115    last_fragment_ptr: usize,
116    last_point: Point,
117    last_result: HitTestResult,
118}
119
120const TOLERANCE: f32 = 0.5;
121
122impl Default for HitTestCache {
123    fn default() -> Self {
124        Self {
125            last_fragment_ptr: 0,
126            last_point: Point::ZERO,
127            last_result: HitTestResult::NONE,
128        }
129    }
130}
131
132impl HitTestCache {
133    #[must_use]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Returns cached result if fragment and point haven't changed.
139    pub fn test(
140        &mut self,
141        tester: &HitTester,
142        fragment: &Arc<Fragment>,
143        point: Point,
144    ) -> &HitTestResult {
145        let ptr = Arc::as_ptr(fragment) as usize;
146        let same_fragment = self.last_fragment_ptr == ptr;
147        let close_enough = (point.x - self.last_point.x).abs() < TOLERANCE
148            && (point.y - self.last_point.y).abs() < TOLERANCE;
149
150        if same_fragment && close_enough {
151            return &self.last_result;
152        }
153
154        self.last_result = tester.test(fragment, point);
155        self.last_fragment_ptr = ptr;
156        self.last_point = point;
157        &self.last_result
158    }
159
160    /// Force invalidation (e.g., after scroll offset changes).
161    pub fn invalidate(&mut self) {
162        self.last_fragment_ptr = 0;
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::layout::fragment::*;
170    use kozan_primitives::geometry::Size;
171
172    fn make_box(size: Size, dom_node: Option<u32>, children: Vec<ChildFragment>) -> Arc<Fragment> {
173        Fragment::new_box_styled_with_node(size, dom_node, children)
174    }
175
176    impl Fragment {
177        fn new_box_styled_with_node(
178            size: Size,
179            dom_node: Option<u32>,
180            children: Vec<ChildFragment>,
181        ) -> Arc<Self> {
182            Arc::new(Self {
183                size,
184                kind: FragmentKind::Box(BoxFragmentData {
185                    children,
186                    ..Default::default()
187                }),
188                style: None,
189                dom_node,
190            })
191        }
192    }
193
194    fn no_scroll() -> ScrollOffsets {
195        ScrollOffsets::new()
196    }
197
198    fn tester(offsets: &ScrollOffsets) -> HitTester<'_> {
199        HitTester::new(offsets)
200    }
201
202    #[test]
203    fn hit_empty_fragment() {
204        let root = make_box(Size::new(800.0, 600.0), Some(0), vec![]);
205        let result = tester(&no_scroll()).test(&root, Point::new(400.0, 300.0));
206        assert_eq!(result.node_index, Some(0));
207    }
208
209    #[test]
210    fn miss_outside_root() {
211        let root = make_box(Size::new(800.0, 600.0), Some(0), vec![]);
212        let result = tester(&no_scroll()).test(&root, Point::new(900.0, 300.0));
213        assert_eq!(result.node_index, None);
214    }
215
216    #[test]
217    fn miss_negative_coords() {
218        let root = make_box(Size::new(800.0, 600.0), Some(0), vec![]);
219        let result = tester(&no_scroll()).test(&root, Point::new(-10.0, 300.0));
220        assert_eq!(result.node_index, None);
221    }
222
223    #[test]
224    fn nested_child_hit() {
225        let child = make_box(Size::new(100.0, 50.0), Some(1), vec![]);
226        let root = make_box(
227            Size::new(800.0, 600.0),
228            Some(0),
229            vec![ChildFragment {
230                offset: Point::new(50.0, 50.0),
231                fragment: child,
232            }],
233        );
234        let result = tester(&no_scroll()).test(&root, Point::new(75.0, 60.0));
235        assert_eq!(result.node_index, Some(1));
236        assert!((result.local_point.x - 25.0).abs() < 0.001);
237    }
238
239    #[test]
240    fn last_child_wins_overlap() {
241        let a = make_box(Size::new(100.0, 100.0), Some(1), vec![]);
242        let b = make_box(Size::new(100.0, 100.0), Some(2), vec![]);
243        let root = make_box(
244            Size::new(800.0, 600.0),
245            Some(0),
246            vec![
247                ChildFragment {
248                    offset: Point::new(10.0, 10.0),
249                    fragment: a,
250                },
251                ChildFragment {
252                    offset: Point::new(10.0, 10.0),
253                    fragment: b,
254                },
255            ],
256        );
257        let result = tester(&no_scroll()).test(&root, Point::new(50.0, 50.0));
258        assert_eq!(result.node_index, Some(2));
259    }
260
261    #[test]
262    fn deeply_nested() {
263        let gc = make_box(Size::new(20.0, 20.0), Some(3), vec![]);
264        let child = make_box(
265            Size::new(100.0, 100.0),
266            Some(2),
267            vec![ChildFragment {
268                offset: Point::new(10.0, 10.0),
269                fragment: gc,
270            }],
271        );
272        let root = make_box(
273            Size::new(800.0, 600.0),
274            Some(1),
275            vec![ChildFragment {
276                offset: Point::new(50.0, 50.0),
277                fragment: child,
278            }],
279        );
280        let result = tester(&no_scroll()).test(&root, Point::new(65.0, 65.0));
281        assert_eq!(result.node_index, Some(3));
282    }
283
284    #[test]
285    fn anonymous_box_passthrough() {
286        let anon = Arc::new(Fragment {
287            size: Size::new(100.0, 100.0),
288            kind: FragmentKind::Box(BoxFragmentData::default()),
289            style: None,
290            dom_node: None,
291        });
292        let root = make_box(
293            Size::new(800.0, 600.0),
294            Some(0),
295            vec![ChildFragment {
296                offset: Point::ZERO,
297                fragment: anon,
298            }],
299        );
300        let result = tester(&no_scroll()).test(&root, Point::new(50.0, 50.0));
301        assert_eq!(result.node_index, Some(0));
302    }
303
304    #[test]
305    fn overflow_hidden_clips() {
306        let child = Arc::new(Fragment {
307            size: Size::new(200.0, 200.0),
308            kind: FragmentKind::Box(BoxFragmentData {
309                overflow_x: OverflowClip::Hidden,
310                overflow_y: OverflowClip::Hidden,
311                ..Default::default()
312            }),
313            style: None,
314            dom_node: Some(1),
315        });
316        let root = make_box(
317            Size::new(800.0, 600.0),
318            Some(0),
319            vec![ChildFragment {
320                offset: Point::ZERO,
321                fragment: child,
322            }],
323        );
324        let result = tester(&no_scroll()).test(&root, Point::new(50.0, 50.0));
325        assert_eq!(result.node_index, Some(1));
326    }
327
328    #[test]
329    fn boundary_inclusive_exclusive() {
330        let root = make_box(Size::new(100.0, 100.0), Some(0), vec![]);
331        let offsets = no_scroll();
332        let t = tester(&offsets);
333
334        assert_eq!(t.test(&root, Point::new(0.0, 0.0)).node_index, Some(0));
335        assert_eq!(t.test(&root, Point::new(100.0, 100.0)).node_index, None);
336        assert_eq!(t.test(&root, Point::new(99.9, 99.9)).node_index, Some(0));
337    }
338
339    #[test]
340    fn scroll_offset_adjusts_child_hit() {
341        let child = make_box(Size::new(200.0, 800.0), Some(2), vec![]);
342        let root = Arc::new(Fragment {
343            size: Size::new(200.0, 200.0),
344            kind: FragmentKind::Box(BoxFragmentData {
345                children: vec![ChildFragment {
346                    offset: Point::ZERO,
347                    fragment: child,
348                }],
349                overflow_y: OverflowClip::Scroll,
350                ..Default::default()
351            }),
352            style: None,
353            dom_node: Some(1),
354        });
355
356        let mut offsets = ScrollOffsets::new();
357        offsets.set_offset(1, Offset::new(0.0, 300.0));
358
359        // Point at y=50 in the viewport maps to y=350 in content space.
360        // The child is 800px tall, so 350 is inside.
361        let result = HitTester::new(&offsets).test(&root, Point::new(100.0, 50.0));
362        assert_eq!(result.node_index, Some(2));
363    }
364}