Skip to main content

cranpose_render_common/
hit_graph.rs

1use std::rc::Rc;
2
3use cranpose_core::NodeId;
4use cranpose_foundation::PointerEvent;
5use cranpose_ui::Point;
6use cranpose_ui_graphics::{Rect, RoundedCornerShape};
7use smallvec::SmallVec;
8
9use crate::{
10    graph::{LayerNode, ProjectiveTransform, RenderNode, quad_bounds},
11    graph_scene::{ClickAction, HitClip, HitGeometry, Scene},
12    primitive_emit::resolve_clip,
13};
14
15pub trait HitGraphSink {
16    fn push_hit(
17        &mut self,
18        node_id: NodeId,
19        capture_path: &[NodeId],
20        geometry: HitGeometry<'_>,
21        shape: Option<RoundedCornerShape>,
22        click_actions: &[Rc<dyn Fn(Point)>],
23        pointer_inputs: &[Rc<dyn Fn(PointerEvent)>],
24    );
25}
26
27impl HitGraphSink for Scene {
28    fn push_hit(
29        &mut self,
30        node_id: NodeId,
31        capture_path: &[NodeId],
32        geometry: HitGeometry<'_>,
33        shape: Option<RoundedCornerShape>,
34        click_actions: &[Rc<dyn Fn(Point)>],
35        pointer_inputs: &[Rc<dyn Fn(PointerEvent)>],
36    ) {
37        Scene::push_hit(
38            self,
39            node_id,
40            capture_path,
41            geometry,
42            shape,
43            click_actions.iter().cloned().map(ClickAction::WithPoint),
44            pointer_inputs,
45        );
46    }
47}
48
49pub fn collect_hits_from_graph<S: HitGraphSink>(
50    layer: &LayerNode,
51    parent_transform: ProjectiveTransform,
52    sink: &mut S,
53    parent_hit_clip: Option<Rect>,
54) {
55    if !layer.has_hit_targets {
56        return;
57    }
58    let mut hit_clips = Vec::new();
59    let mut pointer_input_ancestors = Vec::new();
60    let mut capture_path = SmallVec::<[NodeId; 8]>::new();
61    collect_hits_from_graph_inner(
62        layer,
63        parent_transform,
64        sink,
65        parent_hit_clip,
66        &mut hit_clips,
67        &mut pointer_input_ancestors,
68        &mut capture_path,
69    );
70}
71
72fn collect_hits_from_graph_inner<S: HitGraphSink>(
73    layer: &LayerNode,
74    parent_transform: ProjectiveTransform,
75    sink: &mut S,
76    parent_hit_clip: Option<Rect>,
77    hit_clips: &mut Vec<HitClip>,
78    pointer_input_ancestors: &mut Vec<NodeId>,
79    capture_path: &mut SmallVec<[NodeId; 8]>,
80) {
81    if !layer.has_hit_targets {
82        return;
83    }
84    let transform = layer.transform_to_parent.then(parent_transform);
85    let transformed_quad = transform.map_rect(layer.local_bounds);
86    let transformed_rect = quad_bounds(transformed_quad);
87
88    if transformed_rect.width <= 0.0 || transformed_rect.height <= 0.0 {
89        return;
90    }
91
92    let Some(world_to_local) = transform.inverse() else {
93        return;
94    };
95
96    let mut hit_clip_bounds = parent_hit_clip;
97    let mut pushed_clip = false;
98    if let Some(local_clip) = layer.clip_rect() {
99        let clip_quad = transform.map_rect(local_clip);
100        let clip_bounds = quad_bounds(clip_quad);
101        let resolved_clip_bounds = resolve_clip(parent_hit_clip, Some(clip_bounds));
102        if resolved_clip_bounds.is_some_and(|clip| clip.is_empty()) {
103            return;
104        }
105        hit_clip_bounds = resolved_clip_bounds;
106        hit_clips.push(HitClip {
107            quad: clip_quad,
108            bounds: clip_bounds,
109        });
110        pushed_clip = true;
111    }
112
113    if let (Some(node_id), Some(hit)) = (layer.node_id, &layer.hit_test) {
114        capture_path.clear();
115        capture_path.push(node_id);
116        capture_path.extend(pointer_input_ancestors.iter().rev().copied());
117        sink.push_hit(
118            node_id,
119            capture_path,
120            HitGeometry {
121                rect: transformed_rect,
122                quad: transformed_quad,
123                local_bounds: layer.local_bounds,
124                world_to_local,
125                hit_clip_bounds,
126                hit_clips,
127            },
128            hit.shape,
129            &hit.click_actions,
130            &hit.pointer_inputs,
131        );
132    }
133
134    let pointer_input_ancestor = layer
135        .hit_test
136        .as_ref()
137        .filter(|hit| !hit.pointer_inputs.is_empty())
138        .and(layer.node_id);
139    if let Some(node_id) = pointer_input_ancestor {
140        pointer_input_ancestors.push(node_id);
141    }
142
143    for child in &layer.children {
144        if let RenderNode::Layer(child_layer) = child {
145            collect_hits_from_graph_inner(
146                child_layer,
147                transform,
148                sink,
149                hit_clip_bounds,
150                hit_clips,
151                pointer_input_ancestors,
152                capture_path,
153            );
154        }
155    }
156
157    if pointer_input_ancestor.is_some() {
158        let _ = pointer_input_ancestors.pop();
159    }
160
161    if pushed_clip {
162        let _ = hit_clips.pop();
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::graph::HitTestNode;
170
171    type RecordedHit = (
172        NodeId,
173        Vec<NodeId>,
174        Rect,
175        [[f32; 2]; 4],
176        Option<Rect>,
177        usize,
178    );
179
180    #[derive(Default)]
181    struct TestSink {
182        hits: Vec<RecordedHit>,
183    }
184
185    impl HitGraphSink for TestSink {
186        fn push_hit(
187            &mut self,
188            node_id: NodeId,
189            capture_path: &[NodeId],
190            geometry: HitGeometry<'_>,
191            _shape: Option<RoundedCornerShape>,
192            _click_actions: &[Rc<dyn Fn(Point)>],
193            _pointer_inputs: &[Rc<dyn Fn(PointerEvent)>],
194        ) {
195            self.hits.push((
196                node_id,
197                capture_path.to_vec(),
198                geometry.rect,
199                geometry.quad,
200                geometry.hit_clip_bounds,
201                geometry.hit_clips.len(),
202            ));
203        }
204    }
205
206    fn test_layer(node_id: NodeId, transform_to_parent: ProjectiveTransform) -> LayerNode {
207        LayerNode {
208            node_id: Some(node_id),
209            local_bounds: Rect {
210                x: 0.0,
211                y: 0.0,
212                width: 30.0,
213                height: 18.0,
214            },
215            transform_to_parent,
216            clip_to_bounds: true,
217            hit_test: Some(HitTestNode {
218                shape: None,
219                click_actions: vec![Rc::new(|_point| {})],
220                pointer_inputs: vec![],
221                clip: None,
222            }),
223            has_hit_targets: true,
224            ..Default::default()
225        }
226    }
227
228    #[test]
229    fn collect_hits_uses_graph_transform_to_parent() {
230        let layer = test_layer(7, ProjectiveTransform::translation(12.0, 9.0));
231        let mut sink = TestSink::default();
232
233        collect_hits_from_graph(&layer, ProjectiveTransform::identity(), &mut sink, None);
234
235        assert_eq!(sink.hits.len(), 1);
236        let (node_id, capture_path, rect, quad, clip, clip_count) = &sink.hits[0];
237        assert_eq!(*node_id, 7);
238        assert_eq!(capture_path, &vec![7]);
239        assert_eq!(
240            *rect,
241            Rect {
242                x: 12.0,
243                y: 9.0,
244                width: 30.0,
245                height: 18.0,
246            }
247        );
248        assert_eq!(
249            *quad,
250            [[12.0, 9.0], [42.0, 9.0], [12.0, 27.0], [42.0, 27.0]]
251        );
252        assert_eq!(*clip, Some(*rect));
253        assert_eq!(*clip_count, 1);
254    }
255
256    #[test]
257    fn a_child_clipped_away_by_its_parent_takes_no_hits() {
258        let mut list = test_layer(1, ProjectiveTransform::translation(0.0, 80.0));
259        let scrolled_out = test_layer(2, ProjectiveTransform::translation(4.0, -40.0));
260        list.children
261            .push(RenderNode::Layer(Box::new(scrolled_out)));
262        let mut sink = TestSink::default();
263
264        collect_hits_from_graph(&list, ProjectiveTransform::identity(), &mut sink, None);
265
266        let hit_ids: Vec<NodeId> = sink.hits.iter().map(|hit| hit.0).collect();
267        assert_eq!(
268            hit_ids,
269            vec![1],
270            "a child the list has scrolled past its edge lies outside the list's clip and \
271             takes no hits, however far inside the window it sits"
272        );
273    }
274
275    #[test]
276    fn collect_hits_composes_nested_graph_transforms() {
277        let child = test_layer(9, ProjectiveTransform::translation(4.0, 3.0));
278        let mut parent = test_layer(7, ProjectiveTransform::translation(10.0, 6.0));
279        parent.hit_test.as_mut().expect("hit test").pointer_inputs = vec![Rc::new(|_event| {})];
280        parent.children.push(RenderNode::Layer(Box::new(child)));
281        let mut sink = TestSink::default();
282
283        collect_hits_from_graph(&parent, ProjectiveTransform::identity(), &mut sink, None);
284
285        assert_eq!(sink.hits.len(), 2);
286        let (_, child_capture_path, child_rect, child_quad, child_clip, child_clip_count) =
287            &sink.hits[1];
288        assert_eq!(child_capture_path, &vec![9, 7]);
289        assert_eq!(
290            *child_rect,
291            Rect {
292                x: 14.0,
293                y: 9.0,
294                width: 30.0,
295                height: 18.0,
296            }
297        );
298        assert_eq!(
299            *child_quad,
300            [[14.0, 9.0], [44.0, 9.0], [14.0, 27.0], [44.0, 27.0]]
301        );
302        assert_eq!(
303            *child_clip,
304            Some(Rect {
305                x: 14.0,
306                y: 9.0,
307                width: 26.0,
308                height: 15.0,
309            })
310        );
311        assert_eq!(*child_clip_count, 2);
312    }
313
314    #[test]
315    fn capture_paths_do_not_leak_between_siblings_or_traversals() {
316        let identity = ProjectiveTransform::identity();
317        let mut root = test_layer(12, identity);
318        for node_id in (1..12).rev() {
319            let mut parent = test_layer(node_id, identity);
320            parent.hit_test.as_mut().unwrap().pointer_inputs = vec![Rc::new(|_| {})];
321            parent.children.push(RenderNode::Layer(Box::new(root)));
322            root = parent;
323        }
324        root.children
325            .push(RenderNode::Layer(Box::new(test_layer(13, identity))));
326        let mut sink = TestSink::default();
327        collect_hits_from_graph(&root, identity, &mut sink, None);
328        collect_hits_from_graph(&test_layer(14, identity), identity, &mut sink, None);
329        let mut expected: Vec<Vec<_>> = (1..=12)
330            .map(|node_id| (1..=node_id).rev().collect())
331            .collect();
332        expected.extend([vec![13, 1], vec![14]]);
333        let paths: Vec<_> = sink.hits.into_iter().map(|hit| hit.1).collect();
334        assert_eq!(paths, expected);
335    }
336
337    #[test]
338    fn collect_hits_retains_transformed_clip_chain() {
339        let mut parent = test_layer(1, ProjectiveTransform::translation(20.0, 10.0));
340        let mut child = test_layer(
341            2,
342            ProjectiveTransform::from_rect_to_quad(
343                Rect {
344                    x: 0.0,
345                    y: 0.0,
346                    width: 30.0,
347                    height: 18.0,
348                },
349                [[0.0, 0.0], [30.0, 0.0], [4.0, 18.0], [34.0, 18.0]],
350            ),
351        );
352        child.clip_to_bounds = true;
353        parent.children.push(RenderNode::Layer(Box::new(child)));
354
355        let mut sink = TestSink::default();
356        collect_hits_from_graph(&parent, ProjectiveTransform::identity(), &mut sink, None);
357
358        let (_, _, _, _, _, child_clip_count) = sink.hits[1];
359        assert_eq!(child_clip_count, 2);
360    }
361}