Skip to main content

cranpose_render_common/
graph_scene.rs

1use std::cell::{Cell, RefCell};
2use std::cmp::Reverse;
3use std::collections::{HashMap, HashSet};
4use std::rc::Rc;
5
6use cranpose_core::{MemoryApplier, NodeId};
7use cranpose_foundation::{PointerEvent, PointerEventKind};
8use cranpose_ui::{LayoutNode, ModifierNodeSlices, SubcomposeLayoutNode};
9use cranpose_ui_graphics::{Point, Rect, RoundedCornerShape};
10
11use crate::graph::{ProjectiveTransform, RenderGraph};
12use crate::{HitTestTarget, RenderScene};
13
14pub struct RenderDiagnostics {
15    reported_warnings: RefCell<HashSet<&'static str>>,
16    live_modifier_slice_lookup_miss_count: Cell<usize>,
17}
18
19impl RenderDiagnostics {
20    pub fn new() -> Self {
21        Self {
22            reported_warnings: RefCell::new(HashSet::new()),
23            live_modifier_slice_lookup_miss_count: Cell::new(0),
24        }
25    }
26
27    pub fn claim_warning_once(&self, key: &'static str) -> bool {
28        self.reported_warnings.borrow_mut().insert(key)
29    }
30
31    pub fn record_live_modifier_slice_lookup_miss(&self) {
32        self.live_modifier_slice_lookup_miss_count.set(
33            self.live_modifier_slice_lookup_miss_count
34                .get()
35                .saturating_add(1),
36        );
37    }
38
39    pub fn live_modifier_slice_lookup_miss_count(&self) -> usize {
40        self.live_modifier_slice_lookup_miss_count.get()
41    }
42}
43
44impl Default for RenderDiagnostics {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50#[derive(Clone)]
51pub enum ClickAction {
52    Simple(Rc<RefCell<dyn FnMut()>>),
53    WithPoint(Rc<dyn Fn(Point)>),
54}
55
56impl ClickAction {
57    fn invoke(&self, local_position: Point) {
58        match self {
59            ClickAction::Simple(handler) => (handler.borrow_mut())(),
60            ClickAction::WithPoint(handler) => handler(local_position),
61        }
62    }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct HitClip {
67    pub quad: [[f32; 2]; 4],
68    pub bounds: Rect,
69}
70
71#[derive(Clone)]
72pub struct HitGeometry {
73    pub rect: Rect,
74    pub quad: [[f32; 2]; 4],
75    pub local_bounds: Rect,
76    pub world_to_local: ProjectiveTransform,
77    pub hit_clip_bounds: Option<Rect>,
78    pub hit_clips: Vec<HitClip>,
79}
80
81#[derive(Clone)]
82pub struct HitRegion {
83    pub node_id: NodeId,
84    pub capture_path: Vec<NodeId>,
85    pub rect: Rect,
86    pub quad: [[f32; 2]; 4],
87    pub local_bounds: Rect,
88    pub world_to_local: ProjectiveTransform,
89    pub shape: Option<RoundedCornerShape>,
90    pub click_actions: Vec<ClickAction>,
91    pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
92    pub z_index: usize,
93    pub hit_clip_bounds: Option<Rect>,
94    pub hit_clips: Vec<HitClip>,
95    diagnostics: Rc<RenderDiagnostics>,
96}
97
98struct HitRegionInit {
99    node_id: NodeId,
100    capture_path: Vec<NodeId>,
101    geometry: HitGeometry,
102    shape: Option<RoundedCornerShape>,
103    click_actions: Vec<ClickAction>,
104    pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
105    z_index: usize,
106    diagnostics: Rc<RenderDiagnostics>,
107}
108
109impl HitRegion {
110    fn with_diagnostics(init: HitRegionInit) -> Self {
111        let HitRegionInit {
112            node_id,
113            capture_path,
114            geometry,
115            shape,
116            click_actions,
117            pointer_inputs,
118            z_index,
119            diagnostics,
120        } = init;
121        let HitGeometry {
122            rect,
123            quad,
124            local_bounds,
125            world_to_local,
126            hit_clip_bounds,
127            hit_clips,
128        } = geometry;
129        Self {
130            node_id,
131            capture_path,
132            rect,
133            quad,
134            local_bounds,
135            world_to_local,
136            shape,
137            click_actions,
138            pointer_inputs,
139            z_index,
140            hit_clip_bounds,
141            hit_clips,
142            diagnostics,
143        }
144    }
145
146    fn contains(&self, x: f32, y: f32) -> bool {
147        if !self.rect.contains(x, y) {
148            return false;
149        }
150
151        if let Some(clip_bounds) = self.hit_clip_bounds {
152            if !clip_bounds.contains(x, y) {
153                return false;
154            }
155        }
156
157        let point = Point { x, y };
158        if !point_in_quad(point, self.quad) {
159            return false;
160        }
161
162        for clip in &self.hit_clips {
163            if !point_in_quad(point, clip.quad) {
164                return false;
165            }
166        }
167
168        let local_point = self.world_to_local.map_point(point);
169        if let Some(shape) = self.shape {
170            point_in_rounded_rect(local_point, self.local_bounds, shape)
171        } else {
172            self.local_bounds.contains(local_point.x, local_point.y)
173        }
174    }
175
176    fn localize_event(&self, event: &PointerEvent) -> (PointerEvent, Point) {
177        let local = self.world_to_local.map_point(event.global_position);
178        let local_position = Point {
179            x: local.x - self.local_bounds.x,
180            y: local.y - self.local_bounds.y,
181        };
182        (
183            event.copy_with_local_position(local_position),
184            local_position,
185        )
186    }
187
188    fn dispatch_pointer_inputs(
189        pointer_inputs: &[Rc<dyn Fn(PointerEvent)>],
190        local_event: &PointerEvent,
191    ) {
192        for handler in pointer_inputs {
193            if local_event.is_consumed() && !is_terminal_pointer_event(local_event.kind) {
194                break;
195            }
196            handler(local_event.clone());
197        }
198    }
199
200    fn dispatch_click_actions(&self, local_position: Point) {
201        for action in &self.click_actions {
202            action.invoke(local_position);
203        }
204    }
205
206    fn dispatch_modifier_slices(&self, modifier_slices: &ModifierNodeSlices, event: PointerEvent) {
207        if should_skip_consumed_event(&event) {
208            return;
209        }
210
211        let (local_event, local_position) = self.localize_event(&event);
212        Self::dispatch_pointer_inputs(modifier_slices.pointer_inputs(), &local_event);
213
214        if event.kind == PointerEventKind::Down && !local_event.is_consumed() {
215            for handler in modifier_slices.click_handlers() {
216                handler(local_position);
217            }
218        }
219    }
220
221    fn dispatch_cached_handlers(&self, event: PointerEvent) {
222        if should_skip_consumed_event(&event) {
223            return;
224        }
225
226        let (local_event, local_position) = self.localize_event(&event);
227        Self::dispatch_pointer_inputs(&self.pointer_inputs, &local_event);
228
229        if event.kind == PointerEventKind::Down && !local_event.is_consumed() {
230            self.dispatch_click_actions(local_position);
231        }
232    }
233
234    fn live_modifier_slices(&self, applier: &mut MemoryApplier) -> Option<Rc<ModifierNodeSlices>> {
235        if let Ok(modifier_slices) =
236            applier.with_node::<LayoutNode, _>(self.node_id, |node| node.modifier_slices_snapshot())
237        {
238            return Some(modifier_slices);
239        }
240
241        applier
242            .with_node::<SubcomposeLayoutNode, _>(self.node_id, |node| {
243                node.modifier_slices_snapshot()
244            })
245            .ok()
246    }
247}
248
249fn is_terminal_pointer_event(kind: PointerEventKind) -> bool {
250    matches!(kind, PointerEventKind::Up | PointerEventKind::Cancel)
251}
252
253fn should_skip_consumed_event(event: &PointerEvent) -> bool {
254    event.is_consumed() && !is_terminal_pointer_event(event.kind)
255}
256
257impl HitTestTarget for HitRegion {
258    fn node_id(&self) -> NodeId {
259        self.node_id
260    }
261
262    fn capture_path(&self) -> Vec<NodeId> {
263        self.capture_path.clone()
264    }
265
266    fn dispatch(&self, event: PointerEvent) {
267        self.dispatch_cached_handlers(event);
268    }
269
270    fn dispatch_with_applier(&self, applier: &mut MemoryApplier, event: PointerEvent) {
271        if let Some(modifier_slices) = self.live_modifier_slices(applier) {
272            self.dispatch_modifier_slices(modifier_slices.as_ref(), event);
273            return;
274        }
275
276        self.diagnostics.record_live_modifier_slice_lookup_miss();
277        self.dispatch_cached_handlers(event);
278    }
279}
280
281pub struct Scene {
282    pub graph: Option<RenderGraph>,
283    pub hits: Vec<HitRegion>,
284    pub next_hit_z: usize,
285    pub node_index: HashMap<NodeId, usize>,
286    diagnostics: Rc<RenderDiagnostics>,
287}
288
289impl Scene {
290    pub fn new() -> Self {
291        Self {
292            graph: None,
293            hits: Vec::new(),
294            next_hit_z: 0,
295            node_index: HashMap::new(),
296            diagnostics: Rc::new(RenderDiagnostics::new()),
297        }
298    }
299
300    pub fn diagnostics(&self) -> &RenderDiagnostics {
301        self.diagnostics.as_ref()
302    }
303
304    pub fn push_hit(
305        &mut self,
306        node_id: NodeId,
307        capture_path: Vec<NodeId>,
308        geometry: HitGeometry,
309        shape: Option<RoundedCornerShape>,
310        click_actions: Vec<ClickAction>,
311        pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
312    ) {
313        if click_actions.is_empty() && pointer_inputs.is_empty() {
314            return;
315        }
316
317        let z_index = self.next_hit_z;
318        self.next_hit_z += 1;
319        let hit_index = self.hits.len();
320        self.hits.push(HitRegion::with_diagnostics(HitRegionInit {
321            node_id,
322            capture_path,
323            geometry,
324            shape,
325            click_actions,
326            pointer_inputs,
327            z_index,
328            diagnostics: Rc::clone(&self.diagnostics),
329        }));
330        self.node_index.insert(node_id, hit_index);
331    }
332
333    pub fn replace_graph(&mut self, graph: RenderGraph) {
334        self.graph = Some(graph);
335    }
336}
337
338impl Default for Scene {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344impl RenderScene for Scene {
345    type HitTarget = HitRegion;
346
347    fn clear(&mut self) {
348        self.graph = None;
349        self.hits.clear();
350        self.node_index.clear();
351        self.next_hit_z = 0;
352    }
353
354    fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
355        let mut hit_indices: Vec<usize> = self
356            .hits
357            .iter()
358            .enumerate()
359            .filter_map(|(index, hit)| hit.contains(x, y).then_some(index))
360            .collect();
361
362        hit_indices.sort_by_key(|&index| Reverse(self.hits[index].z_index));
363        hit_indices
364            .into_iter()
365            .map(|index| self.hits[index].clone())
366            .collect()
367    }
368
369    fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
370        self.node_index
371            .get(&node_id)
372            .and_then(|&index| self.hits.get(index))
373            .cloned()
374    }
375
376    fn retained_visual_observation_nodes(&self) -> Option<HashSet<NodeId>> {
377        Some(
378            self.graph
379                .as_ref()
380                .map_or_else(HashSet::new, RenderGraph::retained_visual_observation_nodes),
381        )
382    }
383}
384
385fn point_in_rounded_rect(point: Point, rect: Rect, shape: RoundedCornerShape) -> bool {
386    if !rect.contains(point.x, point.y) {
387        return false;
388    }
389
390    let local_x = point.x - rect.x;
391    let local_y = point.y - rect.y;
392    let radii = shape.resolve(rect.width, rect.height);
393    let tl = radii.top_left;
394    let tr = radii.top_right;
395    let bl = radii.bottom_left;
396    let br = radii.bottom_right;
397
398    if local_x < tl && local_y < tl {
399        let dx = tl - local_x;
400        let dy = tl - local_y;
401        return dx * dx + dy * dy <= tl * tl;
402    }
403
404    if local_x > rect.width - tr && local_y < tr {
405        let dx = local_x - (rect.width - tr);
406        let dy = tr - local_y;
407        return dx * dx + dy * dy <= tr * tr;
408    }
409
410    if local_x < bl && local_y > rect.height - bl {
411        let dx = bl - local_x;
412        let dy = local_y - (rect.height - bl);
413        return dx * dx + dy * dy <= bl * bl;
414    }
415
416    if local_x > rect.width - br && local_y > rect.height - br {
417        let dx = local_x - (rect.width - br);
418        let dy = local_y - (rect.height - br);
419        return dx * dx + dy * dy <= br * br;
420    }
421
422    true
423}
424
425fn point_in_quad(point: Point, quad: [[f32; 2]; 4]) -> bool {
426    point_in_triangle(point, quad[0], quad[1], quad[3])
427        || point_in_triangle(point, quad[0], quad[3], quad[2])
428}
429
430fn point_in_triangle(point: Point, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
431    let d1 = triangle_sign(point, a, b);
432    let d2 = triangle_sign(point, b, c);
433    let d3 = triangle_sign(point, c, a);
434    let has_negative = d1 < -f32::EPSILON || d2 < -f32::EPSILON || d3 < -f32::EPSILON;
435    let has_positive = d1 > f32::EPSILON || d2 > f32::EPSILON || d3 > f32::EPSILON;
436    !(has_negative && has_positive)
437}
438
439fn triangle_sign(point: Point, a: [f32; 2], b: [f32; 2]) -> f32 {
440    (point.x - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point.y - b[1])
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::cell::Cell;
447
448    fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
449        [
450            [rect.x, rect.y],
451            [rect.x + rect.width, rect.y],
452            [rect.x, rect.y + rect.height],
453            [rect.x + rect.width, rect.y + rect.height],
454        ]
455    }
456
457    fn translated_world_to_local(rect: Rect) -> ProjectiveTransform {
458        ProjectiveTransform::translation(-rect.x, -rect.y)
459    }
460
461    fn local_bounds_for_rect(rect: Rect) -> Rect {
462        Rect {
463            x: 0.0,
464            y: 0.0,
465            width: rect.width,
466            height: rect.height,
467        }
468    }
469
470    fn hit_geometry_for_rect(rect: Rect) -> HitGeometry {
471        HitGeometry {
472            rect,
473            quad: rect_to_quad(rect),
474            local_bounds: local_bounds_for_rect(rect),
475            world_to_local: translated_world_to_local(rect),
476            hit_clip_bounds: None,
477            hit_clips: Vec::new(),
478        }
479    }
480
481    fn test_diagnostics() -> Rc<RenderDiagnostics> {
482        Rc::new(RenderDiagnostics::new())
483    }
484
485    fn make_handler(counter: Rc<Cell<u32>>, consume: bool) -> Rc<dyn Fn(PointerEvent)> {
486        Rc::new(move |event: PointerEvent| {
487            counter.set(counter.get() + 1);
488            if consume {
489                event.consume();
490            }
491        })
492    }
493
494    #[test]
495    fn hit_test_respects_hit_clip() {
496        let mut scene = Scene::new();
497        let rect = Rect {
498            x: 0.0,
499            y: 0.0,
500            width: 100.0,
501            height: 100.0,
502        };
503        let clip = Rect {
504            x: 0.0,
505            y: 0.0,
506            width: 40.0,
507            height: 40.0,
508        };
509        scene.push_hit(
510            1,
511            vec![1],
512            HitGeometry {
513                hit_clip_bounds: Some(clip),
514                hit_clips: vec![HitClip {
515                    quad: rect_to_quad(clip),
516                    bounds: clip,
517                }],
518                ..hit_geometry_for_rect(rect)
519            },
520            None,
521            Vec::new(),
522            vec![Rc::new(|_event: PointerEvent| {})],
523        );
524
525        assert!(scene.hit_test(60.0, 20.0).is_empty());
526        assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
527    }
528
529    #[test]
530    fn hit_test_sorts_by_z_without_duplicating_hit_storage() {
531        let mut scene = Scene::new();
532        let rect = Rect {
533            x: 0.0,
534            y: 0.0,
535            width: 50.0,
536            height: 50.0,
537        };
538
539        scene.push_hit(
540            1,
541            vec![1],
542            hit_geometry_for_rect(rect),
543            None,
544            Vec::new(),
545            vec![Rc::new(|_event: PointerEvent| {})],
546        );
547        scene.push_hit(
548            2,
549            vec![2],
550            hit_geometry_for_rect(rect),
551            None,
552            Vec::new(),
553            vec![Rc::new(|_event: PointerEvent| {})],
554        );
555
556        assert_eq!(scene.node_index.get(&1), Some(&0));
557        assert_eq!(scene.node_index.get(&2), Some(&1));
558
559        let hits = scene.hit_test(10.0, 10.0);
560        assert_eq!(
561            hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
562            vec![2, 1]
563        );
564        assert_eq!(scene.find_target(1).map(|hit| hit.node_id), Some(1));
565        assert_eq!(scene.find_target(2).map(|hit| hit.node_id), Some(2));
566    }
567
568    #[test]
569    fn hit_test_rejects_points_in_rounded_corner_cutout() {
570        let mut scene = Scene::new();
571        let rect = Rect {
572            x: 0.0,
573            y: 0.0,
574            width: 40.0,
575            height: 40.0,
576        };
577        scene.push_hit(
578            1,
579            vec![1],
580            hit_geometry_for_rect(rect),
581            Some(RoundedCornerShape::uniform(20.0)),
582            Vec::new(),
583            vec![Rc::new(|_event: PointerEvent| {})],
584        );
585
586        assert!(scene.hit_test(1.0, 1.0).is_empty());
587        assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
588    }
589
590    #[test]
591    fn render_diagnostics_claim_each_warning_key_once() {
592        let diagnostics = RenderDiagnostics::new();
593
594        assert!(diagnostics.claim_warning_once("pixels.effect-fallback"));
595        assert!(!diagnostics.claim_warning_once("pixels.effect-fallback"));
596        assert!(diagnostics.claim_warning_once("pixels.blend-fallback"));
597    }
598
599    #[test]
600    fn dispatch_stops_after_event_consumed() {
601        let count_first = Rc::new(Cell::new(0));
602        let count_second = Rc::new(Cell::new(0));
603
604        let hit = HitRegion::with_diagnostics(HitRegionInit {
605            node_id: 1,
606            capture_path: vec![1],
607            geometry: hit_geometry_for_rect(Rect {
608                x: 0.0,
609                y: 0.0,
610                width: 50.0,
611                height: 50.0,
612            }),
613            shape: None,
614            click_actions: Vec::new(),
615            pointer_inputs: vec![
616                make_handler(count_first.clone(), true),
617                make_handler(count_second.clone(), false),
618            ],
619            z_index: 0,
620            diagnostics: test_diagnostics(),
621        });
622
623        let event = PointerEvent::new(
624            PointerEventKind::Down,
625            Point { x: 10.0, y: 10.0 },
626            Point { x: 10.0, y: 10.0 },
627        );
628        hit.dispatch(event);
629
630        assert_eq!(count_first.get(), 1);
631        assert_eq!(count_second.get(), 0);
632    }
633
634    #[test]
635    fn dispatch_delivers_terminal_events_after_consumption_for_cleanup() {
636        let count_first = Rc::new(Cell::new(0));
637        let count_second = Rc::new(Cell::new(0));
638
639        let hit = HitRegion::with_diagnostics(HitRegionInit {
640            node_id: 1,
641            capture_path: vec![1],
642            geometry: hit_geometry_for_rect(Rect {
643                x: 0.0,
644                y: 0.0,
645                width: 50.0,
646                height: 50.0,
647            }),
648            shape: None,
649            click_actions: Vec::new(),
650            pointer_inputs: vec![
651                make_handler(count_first.clone(), true),
652                make_handler(count_second.clone(), false),
653            ],
654            z_index: 0,
655            diagnostics: test_diagnostics(),
656        });
657
658        for kind in [PointerEventKind::Up, PointerEventKind::Cancel] {
659            let event =
660                PointerEvent::new(kind, Point { x: 10.0, y: 10.0 }, Point { x: 10.0, y: 10.0 });
661            hit.dispatch(event);
662        }
663
664        assert_eq!(count_first.get(), 2);
665        assert_eq!(count_second.get(), 2);
666    }
667
668    #[test]
669    fn dispatch_delivers_terminal_events_to_later_captured_targets_after_consumption() {
670        let child_count = Rc::new(Cell::new(0));
671        let parent_count = Rc::new(Cell::new(0));
672
673        let child_hit = HitRegion::with_diagnostics(HitRegionInit {
674            node_id: 2,
675            capture_path: vec![2, 1],
676            geometry: hit_geometry_for_rect(Rect {
677                x: 8.0,
678                y: 8.0,
679                width: 20.0,
680                height: 20.0,
681            }),
682            shape: None,
683            click_actions: Vec::new(),
684            pointer_inputs: vec![make_handler(child_count.clone(), true)],
685            z_index: 1,
686            diagnostics: test_diagnostics(),
687        });
688        let parent_hit = HitRegion::with_diagnostics(HitRegionInit {
689            node_id: 1,
690            capture_path: vec![1],
691            geometry: hit_geometry_for_rect(Rect {
692                x: 0.0,
693                y: 0.0,
694                width: 50.0,
695                height: 50.0,
696            }),
697            shape: None,
698            click_actions: Vec::new(),
699            pointer_inputs: vec![make_handler(parent_count.clone(), false)],
700            z_index: 0,
701            diagnostics: test_diagnostics(),
702        });
703
704        let event = PointerEvent::new(
705            PointerEventKind::Up,
706            Point { x: 12.0, y: 12.0 },
707            Point { x: 12.0, y: 12.0 },
708        );
709        child_hit.dispatch(event.clone());
710        parent_hit.dispatch(event);
711
712        assert_eq!(child_count.get(), 1);
713        assert_eq!(parent_count.get(), 1);
714    }
715
716    #[test]
717    fn dispatch_triggers_click_action_on_down() {
718        let click_count = Rc::new(Cell::new(0));
719        let click_count_for_handler = Rc::clone(&click_count);
720        let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
721            click_count_for_handler.set(click_count_for_handler.get() + 1);
722        })));
723
724        let hit = HitRegion::with_diagnostics(HitRegionInit {
725            node_id: 1,
726            capture_path: vec![1],
727            geometry: hit_geometry_for_rect(Rect {
728                x: 0.0,
729                y: 0.0,
730                width: 50.0,
731                height: 50.0,
732            }),
733            shape: None,
734            click_actions: vec![click_action],
735            pointer_inputs: Vec::new(),
736            z_index: 0,
737            diagnostics: test_diagnostics(),
738        });
739
740        hit.dispatch(PointerEvent::new(
741            PointerEventKind::Down,
742            Point { x: 10.0, y: 10.0 },
743            Point { x: 10.0, y: 10.0 },
744        ));
745        hit.dispatch(PointerEvent::new(
746            PointerEventKind::Move,
747            Point { x: 10.0, y: 10.0 },
748            Point { x: 12.0, y: 12.0 },
749        ));
750
751        assert_eq!(click_count.get(), 1);
752    }
753
754    #[test]
755    fn dispatch_passes_local_position_to_click_action() {
756        let local_positions = Rc::new(RefCell::new(Vec::new()));
757        let local_positions_for_handler = Rc::clone(&local_positions);
758        let click_action = ClickAction::WithPoint(Rc::new(move |point| {
759            local_positions_for_handler.borrow_mut().push(point);
760        }));
761
762        let hit = HitRegion::with_diagnostics(HitRegionInit {
763            node_id: 1,
764            capture_path: vec![1],
765            geometry: hit_geometry_for_rect(Rect {
766                x: 10.0,
767                y: 12.0,
768                width: 50.0,
769                height: 50.0,
770            }),
771            shape: None,
772            click_actions: vec![click_action],
773            pointer_inputs: Vec::new(),
774            z_index: 0,
775            diagnostics: test_diagnostics(),
776        });
777
778        hit.dispatch(PointerEvent::new(
779            PointerEventKind::Down,
780            Point { x: 15.0, y: 17.0 },
781            Point { x: 15.0, y: 17.0 },
782        ));
783
784        assert_eq!(*local_positions.borrow(), vec![Point { x: 5.0, y: 5.0 }]);
785    }
786
787    #[test]
788    fn dispatch_does_not_trigger_click_action_when_consumed() {
789        let click_count = Rc::new(Cell::new(0));
790        let click_count_for_handler = Rc::clone(&click_count);
791        let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
792            click_count_for_handler.set(click_count_for_handler.get() + 1);
793        })));
794
795        let hit = HitRegion::with_diagnostics(HitRegionInit {
796            node_id: 1,
797            capture_path: vec![1],
798            geometry: hit_geometry_for_rect(Rect {
799                x: 0.0,
800                y: 0.0,
801                width: 50.0,
802                height: 50.0,
803            }),
804            shape: None,
805            click_actions: vec![click_action],
806            pointer_inputs: vec![Rc::new(|event: PointerEvent| event.consume())],
807            z_index: 0,
808            diagnostics: test_diagnostics(),
809        });
810
811        hit.dispatch(PointerEvent::new(
812            PointerEventKind::Down,
813            Point { x: 10.0, y: 10.0 },
814            Point { x: 10.0, y: 10.0 },
815        ));
816
817        assert_eq!(click_count.get(), 0);
818    }
819
820    #[test]
821    fn hit_test_uses_exact_quad_for_transformed_region() {
822        let mut scene = Scene::new();
823        let rect = Rect {
824            x: 0.0,
825            y: 0.0,
826            width: 40.0,
827            height: 20.0,
828        };
829        let quad = [[10.0, 10.0], [50.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
830        let world_to_local = ProjectiveTransform::from_rect_to_quad(rect, quad)
831            .inverse()
832            .expect("transformed hit region should be invertible");
833        scene.push_hit(
834            1,
835            vec![1],
836            HitGeometry {
837                rect: Rect {
838                    x: 10.0,
839                    y: 10.0,
840                    width: 50.0,
841                    height: 20.0,
842                },
843                quad,
844                local_bounds: rect,
845                world_to_local,
846                hit_clip_bounds: None,
847                hit_clips: Vec::new(),
848            },
849            None,
850            Vec::new(),
851            vec![Rc::new(|_event: PointerEvent| {})],
852        );
853
854        assert!(
855            scene.hit_test(15.0, 28.0).is_empty(),
856            "point inside the quad bounds but outside the transformed quad must not hit"
857        );
858        assert_eq!(scene.hit_test(30.0, 20.0).len(), 1);
859    }
860
861    #[test]
862    fn dispatch_uses_inverse_transform_for_local_position() {
863        let local_positions = Rc::new(RefCell::new(Vec::new()));
864        let local_positions_for_handler = Rc::clone(&local_positions);
865        let click_action = ClickAction::WithPoint(Rc::new(move |point| {
866            local_positions_for_handler.borrow_mut().push(point);
867        }));
868        let local_bounds = Rect {
869            x: 0.0,
870            y: 0.0,
871            width: 20.0,
872            height: 10.0,
873        };
874        let quad = [[20.0, 10.0], [60.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
875        let world_to_local = ProjectiveTransform::from_rect_to_quad(local_bounds, quad)
876            .inverse()
877            .expect("translated quad should be invertible");
878        let hit = HitRegion::with_diagnostics(HitRegionInit {
879            node_id: 1,
880            capture_path: vec![1],
881            geometry: HitGeometry {
882                rect: Rect {
883                    x: 20.0,
884                    y: 10.0,
885                    width: 40.0,
886                    height: 20.0,
887                },
888                quad,
889                local_bounds,
890                world_to_local,
891                hit_clip_bounds: None,
892                hit_clips: Vec::new(),
893            },
894            shape: None,
895            click_actions: vec![click_action],
896            pointer_inputs: Vec::new(),
897            z_index: 0,
898            diagnostics: test_diagnostics(),
899        });
900
901        hit.dispatch(PointerEvent::new(
902            PointerEventKind::Down,
903            Point { x: 25.0, y: 17.0 },
904            Point { x: 25.0, y: 17.0 },
905        ));
906
907        assert_eq!(*local_positions.borrow(), vec![Point { x: 2.5, y: 3.5 }]);
908    }
909
910    #[test]
911    fn dispatch_with_applier_counts_live_modifier_slice_lookup_misses() {
912        let handler_calls = Rc::new(Cell::new(0));
913        let handler_calls_for_handler = Rc::clone(&handler_calls);
914        let diagnostics = test_diagnostics();
915        let hit = HitRegion::with_diagnostics(HitRegionInit {
916            node_id: 42,
917            capture_path: vec![42],
918            geometry: hit_geometry_for_rect(Rect {
919                x: 0.0,
920                y: 0.0,
921                width: 50.0,
922                height: 50.0,
923            }),
924            shape: None,
925            click_actions: Vec::new(),
926            pointer_inputs: vec![Rc::new(move |_event: PointerEvent| {
927                handler_calls_for_handler.set(handler_calls_for_handler.get() + 1);
928            })],
929            z_index: 0,
930            diagnostics: Rc::clone(&diagnostics),
931        });
932        let misses_before = diagnostics.live_modifier_slice_lookup_miss_count();
933        let mut applier = MemoryApplier::new();
934
935        hit.dispatch_with_applier(
936            &mut applier,
937            PointerEvent::new(
938                PointerEventKind::Down,
939                Point { x: 10.0, y: 10.0 },
940                Point { x: 10.0, y: 10.0 },
941            ),
942        );
943
944        assert_eq!(handler_calls.get(), 1);
945        assert_eq!(
946            diagnostics.live_modifier_slice_lookup_miss_count(),
947            misses_before + 1
948        );
949    }
950}