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