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() {
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 event.is_consumed() {
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 event.is_consumed() {
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
249impl HitTestTarget for HitRegion {
250    fn node_id(&self) -> NodeId {
251        self.node_id
252    }
253
254    fn capture_path(&self) -> Vec<NodeId> {
255        self.capture_path.clone()
256    }
257
258    fn dispatch(&self, event: PointerEvent) {
259        self.dispatch_cached_handlers(event);
260    }
261
262    fn dispatch_with_applier(&self, applier: &mut MemoryApplier, event: PointerEvent) {
263        if let Some(modifier_slices) = self.live_modifier_slices(applier) {
264            self.dispatch_modifier_slices(modifier_slices.as_ref(), event);
265            return;
266        }
267
268        self.diagnostics.record_live_modifier_slice_lookup_miss();
269        self.dispatch_cached_handlers(event);
270    }
271}
272
273pub struct Scene {
274    pub graph: Option<RenderGraph>,
275    pub hits: Vec<HitRegion>,
276    pub next_hit_z: usize,
277    pub node_index: HashMap<NodeId, usize>,
278    diagnostics: Rc<RenderDiagnostics>,
279}
280
281impl Scene {
282    pub fn new() -> Self {
283        Self {
284            graph: None,
285            hits: Vec::new(),
286            next_hit_z: 0,
287            node_index: HashMap::new(),
288            diagnostics: Rc::new(RenderDiagnostics::new()),
289        }
290    }
291
292    pub fn diagnostics(&self) -> &RenderDiagnostics {
293        self.diagnostics.as_ref()
294    }
295
296    pub fn push_hit(
297        &mut self,
298        node_id: NodeId,
299        capture_path: Vec<NodeId>,
300        geometry: HitGeometry,
301        shape: Option<RoundedCornerShape>,
302        click_actions: Vec<ClickAction>,
303        pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
304    ) {
305        if click_actions.is_empty() && pointer_inputs.is_empty() {
306            return;
307        }
308
309        let z_index = self.next_hit_z;
310        self.next_hit_z += 1;
311        let hit_index = self.hits.len();
312        self.hits.push(HitRegion::with_diagnostics(HitRegionInit {
313            node_id,
314            capture_path,
315            geometry,
316            shape,
317            click_actions,
318            pointer_inputs,
319            z_index,
320            diagnostics: Rc::clone(&self.diagnostics),
321        }));
322        self.node_index.insert(node_id, hit_index);
323    }
324
325    pub fn replace_graph(&mut self, graph: RenderGraph) {
326        self.graph = Some(graph);
327    }
328}
329
330impl Default for Scene {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336impl RenderScene for Scene {
337    type HitTarget = HitRegion;
338
339    fn clear(&mut self) {
340        self.graph = None;
341        self.hits.clear();
342        self.node_index.clear();
343        self.next_hit_z = 0;
344    }
345
346    fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
347        let mut hit_indices: Vec<usize> = self
348            .hits
349            .iter()
350            .enumerate()
351            .filter_map(|(index, hit)| hit.contains(x, y).then_some(index))
352            .collect();
353
354        hit_indices.sort_by_key(|&index| Reverse(self.hits[index].z_index));
355        hit_indices
356            .into_iter()
357            .map(|index| self.hits[index].clone())
358            .collect()
359    }
360
361    fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
362        self.node_index
363            .get(&node_id)
364            .and_then(|&index| self.hits.get(index))
365            .cloned()
366    }
367}
368
369fn point_in_rounded_rect(point: Point, rect: Rect, shape: RoundedCornerShape) -> bool {
370    if !rect.contains(point.x, point.y) {
371        return false;
372    }
373
374    let local_x = point.x - rect.x;
375    let local_y = point.y - rect.y;
376    let radii = shape.resolve(rect.width, rect.height);
377    let tl = radii.top_left;
378    let tr = radii.top_right;
379    let bl = radii.bottom_left;
380    let br = radii.bottom_right;
381
382    if local_x < tl && local_y < tl {
383        let dx = tl - local_x;
384        let dy = tl - local_y;
385        return dx * dx + dy * dy <= tl * tl;
386    }
387
388    if local_x > rect.width - tr && local_y < tr {
389        let dx = local_x - (rect.width - tr);
390        let dy = tr - local_y;
391        return dx * dx + dy * dy <= tr * tr;
392    }
393
394    if local_x < bl && local_y > rect.height - bl {
395        let dx = bl - local_x;
396        let dy = local_y - (rect.height - bl);
397        return dx * dx + dy * dy <= bl * bl;
398    }
399
400    if local_x > rect.width - br && local_y > rect.height - br {
401        let dx = local_x - (rect.width - br);
402        let dy = local_y - (rect.height - br);
403        return dx * dx + dy * dy <= br * br;
404    }
405
406    true
407}
408
409fn point_in_quad(point: Point, quad: [[f32; 2]; 4]) -> bool {
410    point_in_triangle(point, quad[0], quad[1], quad[3])
411        || point_in_triangle(point, quad[0], quad[3], quad[2])
412}
413
414fn point_in_triangle(point: Point, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
415    let d1 = triangle_sign(point, a, b);
416    let d2 = triangle_sign(point, b, c);
417    let d3 = triangle_sign(point, c, a);
418    let has_negative = d1 < -f32::EPSILON || d2 < -f32::EPSILON || d3 < -f32::EPSILON;
419    let has_positive = d1 > f32::EPSILON || d2 > f32::EPSILON || d3 > f32::EPSILON;
420    !(has_negative && has_positive)
421}
422
423fn triangle_sign(point: Point, a: [f32; 2], b: [f32; 2]) -> f32 {
424    (point.x - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point.y - b[1])
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::cell::Cell;
431
432    fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
433        [
434            [rect.x, rect.y],
435            [rect.x + rect.width, rect.y],
436            [rect.x, rect.y + rect.height],
437            [rect.x + rect.width, rect.y + rect.height],
438        ]
439    }
440
441    fn translated_world_to_local(rect: Rect) -> ProjectiveTransform {
442        ProjectiveTransform::translation(-rect.x, -rect.y)
443    }
444
445    fn local_bounds_for_rect(rect: Rect) -> Rect {
446        Rect {
447            x: 0.0,
448            y: 0.0,
449            width: rect.width,
450            height: rect.height,
451        }
452    }
453
454    fn hit_geometry_for_rect(rect: Rect) -> HitGeometry {
455        HitGeometry {
456            rect,
457            quad: rect_to_quad(rect),
458            local_bounds: local_bounds_for_rect(rect),
459            world_to_local: translated_world_to_local(rect),
460            hit_clip_bounds: None,
461            hit_clips: Vec::new(),
462        }
463    }
464
465    fn test_diagnostics() -> Rc<RenderDiagnostics> {
466        Rc::new(RenderDiagnostics::new())
467    }
468
469    fn make_handler(counter: Rc<Cell<u32>>, consume: bool) -> Rc<dyn Fn(PointerEvent)> {
470        Rc::new(move |event: PointerEvent| {
471            counter.set(counter.get() + 1);
472            if consume {
473                event.consume();
474            }
475        })
476    }
477
478    #[test]
479    fn hit_test_respects_hit_clip() {
480        let mut scene = Scene::new();
481        let rect = Rect {
482            x: 0.0,
483            y: 0.0,
484            width: 100.0,
485            height: 100.0,
486        };
487        let clip = Rect {
488            x: 0.0,
489            y: 0.0,
490            width: 40.0,
491            height: 40.0,
492        };
493        scene.push_hit(
494            1,
495            vec![1],
496            HitGeometry {
497                hit_clip_bounds: Some(clip),
498                hit_clips: vec![HitClip {
499                    quad: rect_to_quad(clip),
500                    bounds: clip,
501                }],
502                ..hit_geometry_for_rect(rect)
503            },
504            None,
505            Vec::new(),
506            vec![Rc::new(|_event: PointerEvent| {})],
507        );
508
509        assert!(scene.hit_test(60.0, 20.0).is_empty());
510        assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
511    }
512
513    #[test]
514    fn hit_test_sorts_by_z_without_duplicating_hit_storage() {
515        let mut scene = Scene::new();
516        let rect = Rect {
517            x: 0.0,
518            y: 0.0,
519            width: 50.0,
520            height: 50.0,
521        };
522
523        scene.push_hit(
524            1,
525            vec![1],
526            hit_geometry_for_rect(rect),
527            None,
528            Vec::new(),
529            vec![Rc::new(|_event: PointerEvent| {})],
530        );
531        scene.push_hit(
532            2,
533            vec![2],
534            hit_geometry_for_rect(rect),
535            None,
536            Vec::new(),
537            vec![Rc::new(|_event: PointerEvent| {})],
538        );
539
540        assert_eq!(scene.node_index.get(&1), Some(&0));
541        assert_eq!(scene.node_index.get(&2), Some(&1));
542
543        let hits = scene.hit_test(10.0, 10.0);
544        assert_eq!(
545            hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
546            vec![2, 1]
547        );
548        assert_eq!(scene.find_target(1).map(|hit| hit.node_id), Some(1));
549        assert_eq!(scene.find_target(2).map(|hit| hit.node_id), Some(2));
550    }
551
552    #[test]
553    fn hit_test_rejects_points_in_rounded_corner_cutout() {
554        let mut scene = Scene::new();
555        let rect = Rect {
556            x: 0.0,
557            y: 0.0,
558            width: 40.0,
559            height: 40.0,
560        };
561        scene.push_hit(
562            1,
563            vec![1],
564            hit_geometry_for_rect(rect),
565            Some(RoundedCornerShape::uniform(20.0)),
566            Vec::new(),
567            vec![Rc::new(|_event: PointerEvent| {})],
568        );
569
570        assert!(scene.hit_test(1.0, 1.0).is_empty());
571        assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
572    }
573
574    #[test]
575    fn render_diagnostics_claim_each_warning_key_once() {
576        let diagnostics = RenderDiagnostics::new();
577
578        assert!(diagnostics.claim_warning_once("pixels.effect-fallback"));
579        assert!(!diagnostics.claim_warning_once("pixels.effect-fallback"));
580        assert!(diagnostics.claim_warning_once("pixels.blend-fallback"));
581    }
582
583    #[test]
584    fn dispatch_stops_after_event_consumed() {
585        let count_first = Rc::new(Cell::new(0));
586        let count_second = Rc::new(Cell::new(0));
587
588        let hit = HitRegion::with_diagnostics(HitRegionInit {
589            node_id: 1,
590            capture_path: vec![1],
591            geometry: hit_geometry_for_rect(Rect {
592                x: 0.0,
593                y: 0.0,
594                width: 50.0,
595                height: 50.0,
596            }),
597            shape: None,
598            click_actions: Vec::new(),
599            pointer_inputs: vec![
600                make_handler(count_first.clone(), true),
601                make_handler(count_second.clone(), false),
602            ],
603            z_index: 0,
604            diagnostics: test_diagnostics(),
605        });
606
607        let event = PointerEvent::new(
608            PointerEventKind::Down,
609            Point { x: 10.0, y: 10.0 },
610            Point { x: 10.0, y: 10.0 },
611        );
612        hit.dispatch(event);
613
614        assert_eq!(count_first.get(), 1);
615        assert_eq!(count_second.get(), 0);
616    }
617
618    #[test]
619    fn dispatch_triggers_click_action_on_down() {
620        let click_count = Rc::new(Cell::new(0));
621        let click_count_for_handler = Rc::clone(&click_count);
622        let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
623            click_count_for_handler.set(click_count_for_handler.get() + 1);
624        })));
625
626        let hit = HitRegion::with_diagnostics(HitRegionInit {
627            node_id: 1,
628            capture_path: vec![1],
629            geometry: hit_geometry_for_rect(Rect {
630                x: 0.0,
631                y: 0.0,
632                width: 50.0,
633                height: 50.0,
634            }),
635            shape: None,
636            click_actions: vec![click_action],
637            pointer_inputs: Vec::new(),
638            z_index: 0,
639            diagnostics: test_diagnostics(),
640        });
641
642        hit.dispatch(PointerEvent::new(
643            PointerEventKind::Down,
644            Point { x: 10.0, y: 10.0 },
645            Point { x: 10.0, y: 10.0 },
646        ));
647        hit.dispatch(PointerEvent::new(
648            PointerEventKind::Move,
649            Point { x: 10.0, y: 10.0 },
650            Point { x: 12.0, y: 12.0 },
651        ));
652
653        assert_eq!(click_count.get(), 1);
654    }
655
656    #[test]
657    fn dispatch_passes_local_position_to_click_action() {
658        let local_positions = Rc::new(RefCell::new(Vec::new()));
659        let local_positions_for_handler = Rc::clone(&local_positions);
660        let click_action = ClickAction::WithPoint(Rc::new(move |point| {
661            local_positions_for_handler.borrow_mut().push(point);
662        }));
663
664        let hit = HitRegion::with_diagnostics(HitRegionInit {
665            node_id: 1,
666            capture_path: vec![1],
667            geometry: hit_geometry_for_rect(Rect {
668                x: 10.0,
669                y: 12.0,
670                width: 50.0,
671                height: 50.0,
672            }),
673            shape: None,
674            click_actions: vec![click_action],
675            pointer_inputs: Vec::new(),
676            z_index: 0,
677            diagnostics: test_diagnostics(),
678        });
679
680        hit.dispatch(PointerEvent::new(
681            PointerEventKind::Down,
682            Point { x: 15.0, y: 17.0 },
683            Point { x: 15.0, y: 17.0 },
684        ));
685
686        assert_eq!(*local_positions.borrow(), vec![Point { x: 5.0, y: 5.0 }]);
687    }
688
689    #[test]
690    fn dispatch_does_not_trigger_click_action_when_consumed() {
691        let click_count = Rc::new(Cell::new(0));
692        let click_count_for_handler = Rc::clone(&click_count);
693        let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
694            click_count_for_handler.set(click_count_for_handler.get() + 1);
695        })));
696
697        let hit = HitRegion::with_diagnostics(HitRegionInit {
698            node_id: 1,
699            capture_path: vec![1],
700            geometry: hit_geometry_for_rect(Rect {
701                x: 0.0,
702                y: 0.0,
703                width: 50.0,
704                height: 50.0,
705            }),
706            shape: None,
707            click_actions: vec![click_action],
708            pointer_inputs: vec![Rc::new(|event: PointerEvent| event.consume())],
709            z_index: 0,
710            diagnostics: test_diagnostics(),
711        });
712
713        hit.dispatch(PointerEvent::new(
714            PointerEventKind::Down,
715            Point { x: 10.0, y: 10.0 },
716            Point { x: 10.0, y: 10.0 },
717        ));
718
719        assert_eq!(click_count.get(), 0);
720    }
721
722    #[test]
723    fn hit_test_uses_exact_quad_for_transformed_region() {
724        let mut scene = Scene::new();
725        let rect = Rect {
726            x: 0.0,
727            y: 0.0,
728            width: 40.0,
729            height: 20.0,
730        };
731        let quad = [[10.0, 10.0], [50.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
732        let world_to_local = ProjectiveTransform::from_rect_to_quad(rect, quad)
733            .inverse()
734            .expect("transformed hit region should be invertible");
735        scene.push_hit(
736            1,
737            vec![1],
738            HitGeometry {
739                rect: Rect {
740                    x: 10.0,
741                    y: 10.0,
742                    width: 50.0,
743                    height: 20.0,
744                },
745                quad,
746                local_bounds: rect,
747                world_to_local,
748                hit_clip_bounds: None,
749                hit_clips: Vec::new(),
750            },
751            None,
752            Vec::new(),
753            vec![Rc::new(|_event: PointerEvent| {})],
754        );
755
756        assert!(
757            scene.hit_test(15.0, 28.0).is_empty(),
758            "point inside the quad bounds but outside the transformed quad must not hit"
759        );
760        assert_eq!(scene.hit_test(30.0, 20.0).len(), 1);
761    }
762
763    #[test]
764    fn dispatch_uses_inverse_transform_for_local_position() {
765        let local_positions = Rc::new(RefCell::new(Vec::new()));
766        let local_positions_for_handler = Rc::clone(&local_positions);
767        let click_action = ClickAction::WithPoint(Rc::new(move |point| {
768            local_positions_for_handler.borrow_mut().push(point);
769        }));
770        let local_bounds = Rect {
771            x: 0.0,
772            y: 0.0,
773            width: 20.0,
774            height: 10.0,
775        };
776        let quad = [[20.0, 10.0], [60.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
777        let world_to_local = ProjectiveTransform::from_rect_to_quad(local_bounds, quad)
778            .inverse()
779            .expect("translated quad should be invertible");
780        let hit = HitRegion::with_diagnostics(HitRegionInit {
781            node_id: 1,
782            capture_path: vec![1],
783            geometry: HitGeometry {
784                rect: Rect {
785                    x: 20.0,
786                    y: 10.0,
787                    width: 40.0,
788                    height: 20.0,
789                },
790                quad,
791                local_bounds,
792                world_to_local,
793                hit_clip_bounds: None,
794                hit_clips: Vec::new(),
795            },
796            shape: None,
797            click_actions: vec![click_action],
798            pointer_inputs: Vec::new(),
799            z_index: 0,
800            diagnostics: test_diagnostics(),
801        });
802
803        hit.dispatch(PointerEvent::new(
804            PointerEventKind::Down,
805            Point { x: 25.0, y: 17.0 },
806            Point { x: 25.0, y: 17.0 },
807        ));
808
809        assert_eq!(*local_positions.borrow(), vec![Point { x: 2.5, y: 3.5 }]);
810    }
811
812    #[test]
813    fn dispatch_with_applier_counts_live_modifier_slice_lookup_misses() {
814        let handler_calls = Rc::new(Cell::new(0));
815        let handler_calls_for_handler = Rc::clone(&handler_calls);
816        let diagnostics = test_diagnostics();
817        let hit = HitRegion::with_diagnostics(HitRegionInit {
818            node_id: 42,
819            capture_path: vec![42],
820            geometry: hit_geometry_for_rect(Rect {
821                x: 0.0,
822                y: 0.0,
823                width: 50.0,
824                height: 50.0,
825            }),
826            shape: None,
827            click_actions: Vec::new(),
828            pointer_inputs: vec![Rc::new(move |_event: PointerEvent| {
829                handler_calls_for_handler.set(handler_calls_for_handler.get() + 1);
830            })],
831            z_index: 0,
832            diagnostics: Rc::clone(&diagnostics),
833        });
834        let misses_before = diagnostics.live_modifier_slice_lookup_miss_count();
835        let mut applier = MemoryApplier::new();
836
837        hit.dispatch_with_applier(
838            &mut applier,
839            PointerEvent::new(
840                PointerEventKind::Down,
841                Point { x: 10.0, y: 10.0 },
842                Point { x: 10.0, y: 10.0 },
843            ),
844        );
845
846        assert_eq!(handler_calls.get(), 1);
847        assert_eq!(
848            diagnostics.live_modifier_slice_lookup_miss_count(),
849            misses_before + 1
850        );
851    }
852}