1use std::cell::RefCell;
2use std::cmp::Reverse;
3use std::collections::HashMap;
4use std::rc::Rc;
5
6use cranpose_core::NodeId;
7use cranpose_foundation::{PointerEvent, PointerEventKind};
8use cranpose_ui_graphics::{Point, Rect, RoundedCornerShape};
9
10use crate::graph::{ProjectiveTransform, RenderGraph};
11use crate::{HitTestTarget, RenderScene};
12
13#[derive(Clone)]
14pub enum ClickAction {
15 Simple(Rc<RefCell<dyn FnMut()>>),
16 WithPoint(Rc<dyn Fn(Point)>),
17}
18
19impl ClickAction {
20 fn invoke(&self, local_position: Point) {
21 match self {
22 ClickAction::Simple(handler) => (handler.borrow_mut())(),
23 ClickAction::WithPoint(handler) => handler(local_position),
24 }
25 }
26}
27
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct HitClip {
30 pub quad: [[f32; 2]; 4],
31 pub bounds: Rect,
32}
33
34#[derive(Clone)]
35pub struct HitGeometry {
36 pub rect: Rect,
37 pub quad: [[f32; 2]; 4],
38 pub local_bounds: Rect,
39 pub world_to_local: ProjectiveTransform,
40 pub hit_clip_bounds: Option<Rect>,
41 pub hit_clips: Vec<HitClip>,
42}
43
44#[derive(Clone)]
45pub struct HitRegion {
46 pub node_id: NodeId,
47 pub rect: Rect,
48 pub quad: [[f32; 2]; 4],
49 pub local_bounds: Rect,
50 pub world_to_local: ProjectiveTransform,
51 pub shape: Option<RoundedCornerShape>,
52 pub click_actions: Vec<ClickAction>,
53 pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
54 pub z_index: usize,
55 pub hit_clip_bounds: Option<Rect>,
56 pub hit_clips: Vec<HitClip>,
57}
58
59impl HitRegion {
60 fn contains(&self, x: f32, y: f32) -> bool {
61 if !self.rect.contains(x, y) {
62 return false;
63 }
64
65 if let Some(clip_bounds) = self.hit_clip_bounds {
66 if !clip_bounds.contains(x, y) {
67 return false;
68 }
69 }
70
71 let point = Point { x, y };
72 if !point_in_quad(point, self.quad) {
73 return false;
74 }
75
76 for clip in &self.hit_clips {
77 if !point_in_quad(point, clip.quad) {
78 return false;
79 }
80 }
81
82 let local_point = self.world_to_local.map_point(point);
83 if let Some(shape) = self.shape {
84 point_in_rounded_rect(local_point, self.local_bounds, shape)
85 } else {
86 self.local_bounds.contains(local_point.x, local_point.y)
87 }
88 }
89}
90
91impl HitTestTarget for HitRegion {
92 fn node_id(&self) -> NodeId {
93 self.node_id
94 }
95
96 fn dispatch(&self, event: PointerEvent) {
97 if event.is_consumed() {
98 return;
99 }
100
101 let x = event.global_position.x;
102 let y = event.global_position.y;
103 let kind = event.kind;
104 let local = self.world_to_local.map_point(Point { x, y });
105 let local_position = Point {
106 x: local.x - self.local_bounds.x,
107 y: local.y - self.local_bounds.y,
108 };
109 let local_event = event.copy_with_local_position(local_position);
110 for handler in &self.pointer_inputs {
111 if local_event.is_consumed() {
112 break;
113 }
114 handler(local_event.clone());
115 }
116
117 if kind == PointerEventKind::Down && !local_event.is_consumed() {
118 for action in &self.click_actions {
119 action.invoke(local_position);
120 }
121 }
122 }
123}
124
125pub struct Scene {
126 pub graph: Option<RenderGraph>,
127 pub hits: Vec<HitRegion>,
128 pub next_hit_z: usize,
129 pub node_index: HashMap<NodeId, usize>,
130}
131
132impl Scene {
133 pub fn new() -> Self {
134 Self {
135 graph: None,
136 hits: Vec::new(),
137 next_hit_z: 0,
138 node_index: HashMap::new(),
139 }
140 }
141
142 pub fn push_hit(
143 &mut self,
144 node_id: NodeId,
145 geometry: HitGeometry,
146 shape: Option<RoundedCornerShape>,
147 click_actions: Vec<ClickAction>,
148 pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
149 ) {
150 if click_actions.is_empty() && pointer_inputs.is_empty() {
151 return;
152 }
153
154 let z_index = self.next_hit_z;
155 self.next_hit_z += 1;
156 let hit_index = self.hits.len();
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.hits.push(HitRegion {
166 node_id,
167 rect,
168 quad,
169 local_bounds,
170 world_to_local,
171 shape,
172 click_actions,
173 pointer_inputs,
174 z_index,
175 hit_clip_bounds,
176 hit_clips,
177 });
178 self.node_index.insert(node_id, hit_index);
179 }
180}
181
182impl Default for Scene {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188impl RenderScene for Scene {
189 type HitTarget = HitRegion;
190
191 fn clear(&mut self) {
192 self.graph = None;
193 self.hits.clear();
194 self.node_index.clear();
195 self.next_hit_z = 0;
196 }
197
198 fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
199 let mut hit_indices: Vec<usize> = self
200 .hits
201 .iter()
202 .enumerate()
203 .filter_map(|(index, hit)| hit.contains(x, y).then_some(index))
204 .collect();
205
206 hit_indices.sort_by_key(|&index| Reverse(self.hits[index].z_index));
207 hit_indices
208 .into_iter()
209 .map(|index| self.hits[index].clone())
210 .collect()
211 }
212
213 fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
214 self.node_index
215 .get(&node_id)
216 .and_then(|&index| self.hits.get(index))
217 .cloned()
218 }
219}
220
221fn point_in_rounded_rect(point: Point, rect: Rect, shape: RoundedCornerShape) -> bool {
222 if !rect.contains(point.x, point.y) {
223 return false;
224 }
225
226 let local_x = point.x - rect.x;
227 let local_y = point.y - rect.y;
228 let radii = shape.resolve(rect.width, rect.height);
229 let tl = radii.top_left;
230 let tr = radii.top_right;
231 let bl = radii.bottom_left;
232 let br = radii.bottom_right;
233
234 if local_x < tl && local_y < tl {
235 let dx = tl - local_x;
236 let dy = tl - local_y;
237 return dx * dx + dy * dy <= tl * tl;
238 }
239
240 if local_x > rect.width - tr && local_y < tr {
241 let dx = local_x - (rect.width - tr);
242 let dy = tr - local_y;
243 return dx * dx + dy * dy <= tr * tr;
244 }
245
246 if local_x < bl && local_y > rect.height - bl {
247 let dx = bl - local_x;
248 let dy = local_y - (rect.height - bl);
249 return dx * dx + dy * dy <= bl * bl;
250 }
251
252 if local_x > rect.width - br && local_y > rect.height - br {
253 let dx = local_x - (rect.width - br);
254 let dy = local_y - (rect.height - br);
255 return dx * dx + dy * dy <= br * br;
256 }
257
258 true
259}
260
261fn point_in_quad(point: Point, quad: [[f32; 2]; 4]) -> bool {
262 point_in_triangle(point, quad[0], quad[1], quad[3])
263 || point_in_triangle(point, quad[0], quad[3], quad[2])
264}
265
266fn point_in_triangle(point: Point, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
267 let d1 = triangle_sign(point, a, b);
268 let d2 = triangle_sign(point, b, c);
269 let d3 = triangle_sign(point, c, a);
270 let has_negative = d1 < -f32::EPSILON || d2 < -f32::EPSILON || d3 < -f32::EPSILON;
271 let has_positive = d1 > f32::EPSILON || d2 > f32::EPSILON || d3 > f32::EPSILON;
272 !(has_negative && has_positive)
273}
274
275fn triangle_sign(point: Point, a: [f32; 2], b: [f32; 2]) -> f32 {
276 (point.x - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point.y - b[1])
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use std::cell::Cell;
283
284 fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
285 [
286 [rect.x, rect.y],
287 [rect.x + rect.width, rect.y],
288 [rect.x, rect.y + rect.height],
289 [rect.x + rect.width, rect.y + rect.height],
290 ]
291 }
292
293 fn translated_world_to_local(rect: Rect) -> ProjectiveTransform {
294 ProjectiveTransform::translation(-rect.x, -rect.y)
295 }
296
297 fn local_bounds_for_rect(rect: Rect) -> Rect {
298 Rect {
299 x: 0.0,
300 y: 0.0,
301 width: rect.width,
302 height: rect.height,
303 }
304 }
305
306 fn hit_geometry_for_rect(rect: Rect) -> HitGeometry {
307 HitGeometry {
308 rect,
309 quad: rect_to_quad(rect),
310 local_bounds: local_bounds_for_rect(rect),
311 world_to_local: translated_world_to_local(rect),
312 hit_clip_bounds: None,
313 hit_clips: Vec::new(),
314 }
315 }
316
317 fn make_handler(counter: Rc<Cell<u32>>, consume: bool) -> Rc<dyn Fn(PointerEvent)> {
318 Rc::new(move |event: PointerEvent| {
319 counter.set(counter.get() + 1);
320 if consume {
321 event.consume();
322 }
323 })
324 }
325
326 #[test]
327 fn hit_test_respects_hit_clip() {
328 let mut scene = Scene::new();
329 let rect = Rect {
330 x: 0.0,
331 y: 0.0,
332 width: 100.0,
333 height: 100.0,
334 };
335 let clip = Rect {
336 x: 0.0,
337 y: 0.0,
338 width: 40.0,
339 height: 40.0,
340 };
341 scene.push_hit(
342 1,
343 HitGeometry {
344 hit_clip_bounds: Some(clip),
345 hit_clips: vec![HitClip {
346 quad: rect_to_quad(clip),
347 bounds: clip,
348 }],
349 ..hit_geometry_for_rect(rect)
350 },
351 None,
352 Vec::new(),
353 vec![Rc::new(|_event: PointerEvent| {})],
354 );
355
356 assert!(scene.hit_test(60.0, 20.0).is_empty());
357 assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
358 }
359
360 #[test]
361 fn hit_test_sorts_by_z_without_duplicating_hit_storage() {
362 let mut scene = Scene::new();
363 let rect = Rect {
364 x: 0.0,
365 y: 0.0,
366 width: 50.0,
367 height: 50.0,
368 };
369
370 scene.push_hit(
371 1,
372 hit_geometry_for_rect(rect),
373 None,
374 Vec::new(),
375 vec![Rc::new(|_event: PointerEvent| {})],
376 );
377 scene.push_hit(
378 2,
379 hit_geometry_for_rect(rect),
380 None,
381 Vec::new(),
382 vec![Rc::new(|_event: PointerEvent| {})],
383 );
384
385 assert_eq!(scene.node_index.get(&1), Some(&0));
386 assert_eq!(scene.node_index.get(&2), Some(&1));
387
388 let hits = scene.hit_test(10.0, 10.0);
389 assert_eq!(
390 hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
391 vec![2, 1]
392 );
393 assert_eq!(scene.find_target(1).map(|hit| hit.node_id), Some(1));
394 assert_eq!(scene.find_target(2).map(|hit| hit.node_id), Some(2));
395 }
396
397 #[test]
398 fn hit_test_rejects_points_in_rounded_corner_cutout() {
399 let mut scene = Scene::new();
400 let rect = Rect {
401 x: 0.0,
402 y: 0.0,
403 width: 40.0,
404 height: 40.0,
405 };
406 scene.push_hit(
407 1,
408 hit_geometry_for_rect(rect),
409 Some(RoundedCornerShape::uniform(20.0)),
410 Vec::new(),
411 vec![Rc::new(|_event: PointerEvent| {})],
412 );
413
414 assert!(scene.hit_test(1.0, 1.0).is_empty());
415 assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
416 }
417
418 #[test]
419 fn dispatch_stops_after_event_consumed() {
420 let count_first = Rc::new(Cell::new(0));
421 let count_second = Rc::new(Cell::new(0));
422
423 let hit = HitRegion {
424 node_id: 1,
425 rect: Rect {
426 x: 0.0,
427 y: 0.0,
428 width: 50.0,
429 height: 50.0,
430 },
431 quad: rect_to_quad(Rect {
432 x: 0.0,
433 y: 0.0,
434 width: 50.0,
435 height: 50.0,
436 }),
437 local_bounds: Rect {
438 x: 0.0,
439 y: 0.0,
440 width: 50.0,
441 height: 50.0,
442 },
443 world_to_local: ProjectiveTransform::identity(),
444 shape: None,
445 click_actions: Vec::new(),
446 pointer_inputs: vec![
447 make_handler(count_first.clone(), true),
448 make_handler(count_second.clone(), false),
449 ],
450 z_index: 0,
451 hit_clip_bounds: None,
452 hit_clips: Vec::new(),
453 };
454
455 let event = PointerEvent::new(
456 PointerEventKind::Down,
457 Point { x: 10.0, y: 10.0 },
458 Point { x: 10.0, y: 10.0 },
459 );
460 hit.dispatch(event);
461
462 assert_eq!(count_first.get(), 1);
463 assert_eq!(count_second.get(), 0);
464 }
465
466 #[test]
467 fn dispatch_triggers_click_action_on_down() {
468 let click_count = Rc::new(Cell::new(0));
469 let click_count_for_handler = Rc::clone(&click_count);
470 let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
471 click_count_for_handler.set(click_count_for_handler.get() + 1);
472 })));
473
474 let hit = HitRegion {
475 node_id: 1,
476 rect: Rect {
477 x: 0.0,
478 y: 0.0,
479 width: 50.0,
480 height: 50.0,
481 },
482 quad: rect_to_quad(Rect {
483 x: 0.0,
484 y: 0.0,
485 width: 50.0,
486 height: 50.0,
487 }),
488 local_bounds: Rect {
489 x: 0.0,
490 y: 0.0,
491 width: 50.0,
492 height: 50.0,
493 },
494 world_to_local: ProjectiveTransform::identity(),
495 shape: None,
496 click_actions: vec![click_action],
497 pointer_inputs: Vec::new(),
498 z_index: 0,
499 hit_clip_bounds: None,
500 hit_clips: Vec::new(),
501 };
502
503 hit.dispatch(PointerEvent::new(
504 PointerEventKind::Down,
505 Point { x: 10.0, y: 10.0 },
506 Point { x: 10.0, y: 10.0 },
507 ));
508 hit.dispatch(PointerEvent::new(
509 PointerEventKind::Move,
510 Point { x: 10.0, y: 10.0 },
511 Point { x: 12.0, y: 12.0 },
512 ));
513
514 assert_eq!(click_count.get(), 1);
515 }
516
517 #[test]
518 fn dispatch_passes_local_position_to_click_action() {
519 let local_positions = Rc::new(RefCell::new(Vec::new()));
520 let local_positions_for_handler = Rc::clone(&local_positions);
521 let click_action = ClickAction::WithPoint(Rc::new(move |point| {
522 local_positions_for_handler.borrow_mut().push(point);
523 }));
524
525 let hit = HitRegion {
526 node_id: 1,
527 rect: Rect {
528 x: 10.0,
529 y: 12.0,
530 width: 50.0,
531 height: 50.0,
532 },
533 quad: rect_to_quad(Rect {
534 x: 10.0,
535 y: 12.0,
536 width: 50.0,
537 height: 50.0,
538 }),
539 local_bounds: Rect {
540 x: 0.0,
541 y: 0.0,
542 width: 50.0,
543 height: 50.0,
544 },
545 world_to_local: ProjectiveTransform::translation(-10.0, -12.0),
546 shape: None,
547 click_actions: vec![click_action],
548 pointer_inputs: Vec::new(),
549 z_index: 0,
550 hit_clip_bounds: None,
551 hit_clips: Vec::new(),
552 };
553
554 hit.dispatch(PointerEvent::new(
555 PointerEventKind::Down,
556 Point { x: 15.0, y: 17.0 },
557 Point { x: 15.0, y: 17.0 },
558 ));
559
560 assert_eq!(*local_positions.borrow(), vec![Point { x: 5.0, y: 5.0 }]);
561 }
562
563 #[test]
564 fn dispatch_does_not_trigger_click_action_when_consumed() {
565 let click_count = Rc::new(Cell::new(0));
566 let click_count_for_handler = Rc::clone(&click_count);
567 let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
568 click_count_for_handler.set(click_count_for_handler.get() + 1);
569 })));
570
571 let hit = HitRegion {
572 node_id: 1,
573 rect: Rect {
574 x: 0.0,
575 y: 0.0,
576 width: 50.0,
577 height: 50.0,
578 },
579 quad: rect_to_quad(Rect {
580 x: 0.0,
581 y: 0.0,
582 width: 50.0,
583 height: 50.0,
584 }),
585 local_bounds: Rect {
586 x: 0.0,
587 y: 0.0,
588 width: 50.0,
589 height: 50.0,
590 },
591 world_to_local: ProjectiveTransform::identity(),
592 shape: None,
593 click_actions: vec![click_action],
594 pointer_inputs: vec![Rc::new(|event: PointerEvent| event.consume())],
595 z_index: 0,
596 hit_clip_bounds: None,
597 hit_clips: Vec::new(),
598 };
599
600 hit.dispatch(PointerEvent::new(
601 PointerEventKind::Down,
602 Point { x: 10.0, y: 10.0 },
603 Point { x: 10.0, y: 10.0 },
604 ));
605
606 assert_eq!(click_count.get(), 0);
607 }
608
609 #[test]
610 fn hit_test_uses_exact_quad_for_transformed_region() {
611 let mut scene = Scene::new();
612 let rect = Rect {
613 x: 0.0,
614 y: 0.0,
615 width: 40.0,
616 height: 20.0,
617 };
618 let quad = [[10.0, 10.0], [50.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
619 let world_to_local = ProjectiveTransform::from_rect_to_quad(rect, quad)
620 .inverse()
621 .expect("transformed hit region should be invertible");
622 scene.push_hit(
623 1,
624 HitGeometry {
625 rect: Rect {
626 x: 10.0,
627 y: 10.0,
628 width: 50.0,
629 height: 20.0,
630 },
631 quad,
632 local_bounds: rect,
633 world_to_local,
634 hit_clip_bounds: None,
635 hit_clips: Vec::new(),
636 },
637 None,
638 Vec::new(),
639 vec![Rc::new(|_event: PointerEvent| {})],
640 );
641
642 assert!(
643 scene.hit_test(15.0, 28.0).is_empty(),
644 "point inside the quad bounds but outside the transformed quad must not hit"
645 );
646 assert_eq!(scene.hit_test(30.0, 20.0).len(), 1);
647 }
648
649 #[test]
650 fn dispatch_uses_inverse_transform_for_local_position() {
651 let local_positions = Rc::new(RefCell::new(Vec::new()));
652 let local_positions_for_handler = Rc::clone(&local_positions);
653 let click_action = ClickAction::WithPoint(Rc::new(move |point| {
654 local_positions_for_handler.borrow_mut().push(point);
655 }));
656 let local_bounds = Rect {
657 x: 0.0,
658 y: 0.0,
659 width: 20.0,
660 height: 10.0,
661 };
662 let quad = [[20.0, 10.0], [60.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
663 let world_to_local = ProjectiveTransform::from_rect_to_quad(local_bounds, quad)
664 .inverse()
665 .expect("translated quad should be invertible");
666 let hit = HitRegion {
667 node_id: 1,
668 rect: Rect {
669 x: 20.0,
670 y: 10.0,
671 width: 40.0,
672 height: 20.0,
673 },
674 quad,
675 local_bounds,
676 world_to_local,
677 shape: None,
678 click_actions: vec![click_action],
679 pointer_inputs: Vec::new(),
680 z_index: 0,
681 hit_clip_bounds: None,
682 hit_clips: Vec::new(),
683 };
684
685 hit.dispatch(PointerEvent::new(
686 PointerEventKind::Down,
687 Point { x: 25.0, y: 17.0 },
688 Point { x: 25.0, y: 17.0 },
689 ));
690
691 assert_eq!(*local_positions.borrow(), vec![Point { x: 2.5, y: 3.5 }]);
692 }
693}