Skip to main content

cranpose_render_common/
graph_scene.rs

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