1use cranpose_core::NodeId;
4use cranpose_foundation::{PointerEvent, PointerEventKind};
5use cranpose_render_common::{HitTestTarget, RenderScene};
6use cranpose_ui::{TextLayoutOptions, TextStyle};
7use cranpose_ui_graphics::{
8 BlendMode, Brush, Color, ColorFilter, ImageBitmap, Point, Rect, RenderEffect,
9 RoundedCornerShape,
10};
11use std::cell::RefCell;
12use std::cmp::Reverse;
13use std::collections::HashMap;
14use std::rc::Rc;
15
16#[derive(Clone)]
17pub enum ClickAction {
18 Simple(Rc<RefCell<dyn FnMut()>>),
19 WithPoint(Rc<dyn Fn(Point)>),
20}
21
22impl ClickAction {
23 fn invoke(&self, rect: Rect, x: f32, y: f32) {
24 match self {
25 ClickAction::Simple(handler) => (handler.borrow_mut())(),
26 ClickAction::WithPoint(handler) => handler(Point {
27 x: x - rect.x,
28 y: y - rect.y,
29 }),
30 }
31 }
32}
33
34#[derive(Clone)]
35pub struct DrawShape {
36 pub rect: Rect,
37 pub local_rect: Rect,
38 pub quad: [[f32; 2]; 4],
39 pub brush: Brush,
40 pub shape: Option<RoundedCornerShape>,
41 pub z_index: usize,
42 pub clip: Option<Rect>,
43 pub blend_mode: BlendMode,
44}
45
46#[derive(Clone)]
47pub struct TextDraw {
48 pub node_id: NodeId,
49 pub rect: Rect,
50 pub text: Rc<cranpose_ui::text::AnnotatedString>,
51 pub color: Color,
52 pub text_style: TextStyle,
53 pub font_size: f32,
54 pub scale: f32,
55 pub layout_options: TextLayoutOptions,
56 pub z_index: usize,
57 pub clip: Option<Rect>,
58}
59
60#[derive(Clone)]
61pub struct ImageDraw {
62 pub rect: Rect,
63 pub local_rect: Rect,
64 pub quad: [[f32; 2]; 4],
65 pub image: ImageBitmap,
66 pub alpha: f32,
67 pub color_filter: Option<ColorFilter>,
68 pub z_index: usize,
69 pub clip: Option<Rect>,
70 pub blend_mode: BlendMode,
71 pub src_rect: Option<Rect>,
73}
74
75#[derive(Clone)]
76pub struct HitRegion {
77 pub node_id: NodeId,
78 pub rect: Rect,
79 pub shape: Option<RoundedCornerShape>,
80 pub click_actions: Vec<ClickAction>,
81 pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
82 pub z_index: usize,
83 pub hit_clip: Option<Rect>,
84}
85
86impl HitRegion {
87 fn contains(&self, x: f32, y: f32) -> bool {
88 if let Some(clip) = self.hit_clip {
89 if !clip.contains(x, y) {
90 return false;
91 }
92 }
93 if let Some(shape) = self.shape {
95 point_in_rounded_rect(x, y, self.rect, shape)
96 } else {
97 self.rect.contains(x, y)
98 }
99 }
100}
101
102impl HitTestTarget for HitRegion {
103 fn node_id(&self) -> NodeId {
104 self.node_id
105 }
106
107 fn dispatch(&self, event: PointerEvent) {
108 if event.is_consumed() {
109 return;
110 }
111 let x = event.global_position.x;
112 let y = event.global_position.y;
113 let kind = event.kind;
114 let local_position = Point {
115 x: x - self.rect.x,
116 y: y - self.rect.y,
117 };
118 let local_event = event.copy_with_local_position(local_position);
119 for handler in &self.pointer_inputs {
120 if local_event.is_consumed() {
121 break;
122 }
123 handler(local_event.clone());
124 }
125 if kind == PointerEventKind::Down && !local_event.is_consumed() {
126 for action in &self.click_actions {
127 action.invoke(self.rect, x, y);
128 }
129 }
130 }
131}
132
133#[derive(Clone)]
135pub struct ShadowDraw {
136 pub shapes: Vec<(DrawShape, BlendMode)>,
139 pub texts: Vec<TextDraw>,
141 pub blur_radius: f32,
143 pub clip: Option<Rect>,
145 pub z_index: usize,
147}
148
149#[derive(Clone)]
151pub struct EffectLayer {
152 pub rect: Rect,
153 pub clip: Option<Rect>,
154 pub effect: Option<RenderEffect>,
157 pub blend_mode: BlendMode,
159 pub composite_alpha: f32,
161 pub z_start: usize,
163 pub z_end: usize,
165}
166
167#[derive(Clone)]
169pub struct BackdropLayer {
170 pub rect: Rect,
171 pub clip: Option<Rect>,
172 pub effect: RenderEffect,
173 pub z_index: usize,
175}
176
177pub struct Scene {
178 pub shapes: Vec<DrawShape>,
179 pub images: Vec<ImageDraw>,
180 pub texts: Vec<TextDraw>,
181 pub shadow_draws: Vec<ShadowDraw>,
182 pub hits: Vec<HitRegion>,
183 pub effect_layers: Vec<EffectLayer>,
184 pub backdrop_layers: Vec<BackdropLayer>,
185 pub next_z: usize,
186 pub node_index: HashMap<NodeId, usize>,
187}
188
189impl Scene {
190 pub fn new() -> Self {
191 Self {
192 shapes: Vec::new(),
193 images: Vec::new(),
194 texts: Vec::new(),
195 shadow_draws: Vec::new(),
196 hits: Vec::new(),
197 effect_layers: Vec::new(),
198 backdrop_layers: Vec::new(),
199 next_z: 0,
200 node_index: HashMap::new(),
201 }
202 }
203
204 pub fn push_shape(
205 &mut self,
206 rect: Rect,
207 brush: Brush,
208 shape: Option<RoundedCornerShape>,
209 clip: Option<Rect>,
210 blend_mode: BlendMode,
211 ) {
212 self.push_shape_with_geometry(
213 rect,
214 rect,
215 rect_to_quad(rect),
216 brush,
217 shape,
218 clip,
219 blend_mode,
220 );
221 }
222
223 #[allow(clippy::too_many_arguments)]
224 pub fn push_shape_with_geometry(
225 &mut self,
226 rect: Rect,
227 local_rect: Rect,
228 quad: [[f32; 2]; 4],
229 brush: Brush,
230 shape: Option<RoundedCornerShape>,
231 clip: Option<Rect>,
232 blend_mode: BlendMode,
233 ) {
234 let z_index = self.next_z;
235 self.next_z += 1;
236 self.shapes.push(DrawShape {
237 rect,
238 local_rect,
239 quad,
240 brush,
241 shape,
242 z_index,
243 clip,
244 blend_mode,
245 });
246 }
247
248 #[allow(clippy::too_many_arguments)]
249 pub fn push_image(
250 &mut self,
251 rect: Rect,
252 image: ImageBitmap,
253 alpha: f32,
254 color_filter: Option<ColorFilter>,
255 clip: Option<Rect>,
256 src_rect: Option<Rect>,
257 blend_mode: BlendMode,
258 ) {
259 self.push_image_with_geometry(
260 rect,
261 rect,
262 rect_to_quad(rect),
263 image,
264 alpha,
265 color_filter,
266 clip,
267 src_rect,
268 blend_mode,
269 );
270 }
271
272 #[allow(clippy::too_many_arguments)]
273 pub fn push_image_with_geometry(
274 &mut self,
275 rect: Rect,
276 local_rect: Rect,
277 quad: [[f32; 2]; 4],
278 image: ImageBitmap,
279 alpha: f32,
280 color_filter: Option<ColorFilter>,
281 clip: Option<Rect>,
282 src_rect: Option<Rect>,
283 blend_mode: BlendMode,
284 ) {
285 let z_index = self.next_z;
286 self.next_z += 1;
287 self.images.push(ImageDraw {
288 rect,
289 local_rect,
290 quad,
291 image,
292 alpha: alpha.clamp(0.0, 1.0),
293 color_filter,
294 z_index,
295 clip,
296 blend_mode,
297 src_rect,
298 });
299 }
300
301 #[allow(clippy::too_many_arguments)]
302 pub fn push_text(
303 &mut self,
304 node_id: NodeId,
305 rect: Rect,
306 text: Rc<cranpose_ui::text::AnnotatedString>,
307 color: Color,
308 text_style: TextStyle,
309 font_size: f32,
310 scale: f32,
311 layout_options: TextLayoutOptions,
312 clip: Option<Rect>,
313 ) {
314 let z_index = self.next_z;
315 self.next_z += 1;
316 self.texts.push(TextDraw {
317 node_id,
318 rect,
319 text,
320 color,
321 text_style,
322 font_size,
323 scale,
324 layout_options,
325 z_index,
326 clip,
327 });
328 }
329
330 pub fn push_hit(
331 &mut self,
332 node_id: NodeId,
333 rect: Rect,
334 shape: Option<RoundedCornerShape>,
335 click_actions: Vec<ClickAction>,
336 pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
337 hit_clip: Option<Rect>,
338 ) {
339 if click_actions.is_empty() && pointer_inputs.is_empty() {
340 return;
341 }
342 let z_index = self.next_z;
343 self.next_z += 1;
344 let hit_region = HitRegion {
345 node_id,
346 rect,
347 shape,
348 click_actions,
349 pointer_inputs,
350 z_index,
351 hit_clip,
352 };
353 let hit_index = self.hits.len();
354 self.hits.push(hit_region);
355 self.node_index.insert(node_id, hit_index);
356 }
357
358 pub fn push_shadow_draw(&mut self, mut draw: ShadowDraw) {
359 let z_index = self.next_z;
360 self.next_z += 1;
361 draw.z_index = z_index;
362 self.shadow_draws.push(draw);
363 }
364}
365
366impl Default for Scene {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
373 [
374 [rect.x, rect.y],
375 [rect.x + rect.width, rect.y],
376 [rect.x, rect.y + rect.height],
377 [rect.x + rect.width, rect.y + rect.height],
378 ]
379}
380
381impl RenderScene for Scene {
382 type HitTarget = HitRegion;
383
384 fn clear(&mut self) {
385 self.shapes.clear();
386 self.images.clear();
387 self.texts.clear();
388 self.shadow_draws.clear();
389 self.hits.clear();
390 self.effect_layers.clear();
391 self.backdrop_layers.clear();
392 self.node_index.clear();
393 self.next_z = 0;
394 }
395
396 fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
397 let mut hit_indices: Vec<usize> = self
398 .hits
399 .iter()
400 .enumerate()
401 .filter_map(|(index, hit)| hit.contains(x, y).then_some(index))
402 .collect();
403
404 hit_indices.sort_by_key(|&index| Reverse(self.hits[index].z_index));
405 hit_indices
406 .into_iter()
407 .map(|index| self.hits[index].clone())
408 .collect()
409 }
410
411 fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
412 self.node_index
413 .get(&node_id)
414 .and_then(|&index| self.hits.get(index))
415 .cloned()
416 }
417}
418
419fn point_in_rounded_rect(x: f32, y: f32, rect: Rect, shape: RoundedCornerShape) -> bool {
421 if !rect.contains(x, y) {
422 return false;
423 }
424
425 let local_x = x - rect.x;
426 let local_y = y - rect.y;
427
428 let radii = shape.resolve(rect.width, rect.height);
430 let tl = radii.top_left;
431 let tr = radii.top_right;
432 let bl = radii.bottom_left;
433 let br = radii.bottom_right;
434
435 if local_x < tl && local_y < tl {
437 let dx = tl - local_x;
438 let dy = tl - local_y;
439 return dx * dx + dy * dy <= tl * tl;
440 }
441
442 if local_x > rect.width - tr && local_y < tr {
444 let dx = local_x - (rect.width - tr);
445 let dy = tr - local_y;
446 return dx * dx + dy * dy <= tr * tr;
447 }
448
449 if local_x < bl && local_y > rect.height - bl {
451 let dx = bl - local_x;
452 let dy = local_y - (rect.height - bl);
453 return dx * dx + dy * dy <= bl * bl;
454 }
455
456 if local_x > rect.width - br && local_y > rect.height - br {
458 let dx = local_x - (rect.width - br);
459 let dy = local_y - (rect.height - br);
460 return dx * dx + dy * dy <= br * br;
461 }
462
463 true
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::cell::{Cell, RefCell};
470 use std::rc::Rc;
471
472 fn make_handler(counter: Rc<Cell<u32>>, consume: bool) -> Rc<dyn Fn(PointerEvent)> {
473 Rc::new(move |event: PointerEvent| {
474 counter.set(counter.get() + 1);
475 if consume {
476 event.consume();
477 }
478 })
479 }
480
481 #[test]
482 fn hit_test_respects_hit_clip() {
483 let mut scene = Scene::new();
484 let rect = Rect {
485 x: 0.0,
486 y: 0.0,
487 width: 100.0,
488 height: 100.0,
489 };
490 let clip = Rect {
491 x: 0.0,
492 y: 0.0,
493 width: 40.0,
494 height: 40.0,
495 };
496 scene.push_hit(
497 1,
498 rect,
499 None,
500 Vec::new(),
501 vec![Rc::new(|_event: PointerEvent| {})],
502 Some(clip),
503 );
504
505 assert!(scene.hit_test(60.0, 20.0).is_empty());
506 assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
507 }
508
509 #[test]
510 fn hit_test_sorts_by_z_without_duplicating_hit_storage() {
511 let mut scene = Scene::new();
512 let rect = Rect {
513 x: 0.0,
514 y: 0.0,
515 width: 50.0,
516 height: 50.0,
517 };
518
519 scene.push_hit(
520 1,
521 rect,
522 None,
523 Vec::new(),
524 vec![Rc::new(|_event: PointerEvent| {})],
525 None,
526 );
527 scene.push_hit(
528 2,
529 rect,
530 None,
531 Vec::new(),
532 vec![Rc::new(|_event: PointerEvent| {})],
533 None,
534 );
535
536 assert_eq!(scene.node_index.get(&1), Some(&0));
537 assert_eq!(scene.node_index.get(&2), Some(&1));
538
539 let hits = scene.hit_test(10.0, 10.0);
540 assert_eq!(
541 hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
542 vec![2, 1]
543 );
544 assert_eq!(scene.find_target(1).map(|hit| hit.node_id), Some(1));
545 assert_eq!(scene.find_target(2).map(|hit| hit.node_id), Some(2));
546 }
547
548 #[test]
549 fn dispatch_stops_after_event_consumed() {
550 let count_first = Rc::new(Cell::new(0));
551 let count_second = Rc::new(Cell::new(0));
552
553 let hit = HitRegion {
554 node_id: 1,
555 rect: Rect {
556 x: 0.0,
557 y: 0.0,
558 width: 50.0,
559 height: 50.0,
560 },
561 shape: None,
562 click_actions: Vec::new(),
563 pointer_inputs: vec![
564 make_handler(count_first.clone(), true),
565 make_handler(count_second.clone(), false),
566 ],
567 z_index: 0,
568 hit_clip: None,
569 };
570
571 let event = PointerEvent::new(
572 PointerEventKind::Down,
573 Point { x: 10.0, y: 10.0 },
574 Point { x: 10.0, y: 10.0 },
575 );
576 hit.dispatch(event);
577
578 assert_eq!(count_first.get(), 1);
579 assert_eq!(count_second.get(), 0);
580 }
581
582 #[test]
583 fn dispatch_triggers_click_action_on_down() {
584 let click_count = Rc::new(Cell::new(0));
585 let click_count_for_handler = Rc::clone(&click_count);
586 let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
587 click_count_for_handler.set(click_count_for_handler.get() + 1);
588 })));
589
590 let hit = HitRegion {
591 node_id: 1,
592 rect: Rect {
593 x: 0.0,
594 y: 0.0,
595 width: 50.0,
596 height: 50.0,
597 },
598 shape: None,
599 click_actions: vec![click_action],
600 pointer_inputs: Vec::new(),
601 z_index: 0,
602 hit_clip: None,
603 };
604
605 hit.dispatch(PointerEvent::new(
606 PointerEventKind::Down,
607 Point { x: 10.0, y: 10.0 },
608 Point { x: 10.0, y: 10.0 },
609 ));
610 hit.dispatch(PointerEvent::new(
611 PointerEventKind::Move,
612 Point { x: 10.0, y: 10.0 },
613 Point { x: 12.0, y: 12.0 },
614 ));
615
616 assert_eq!(click_count.get(), 1);
617 }
618
619 #[test]
620 fn dispatch_does_not_trigger_click_action_when_consumed() {
621 let click_count = Rc::new(Cell::new(0));
622 let click_count_for_handler = Rc::clone(&click_count);
623 let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
624 click_count_for_handler.set(click_count_for_handler.get() + 1);
625 })));
626
627 let hit = HitRegion {
628 node_id: 1,
629 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![Rc::new(|event: PointerEvent| event.consume())],
638 z_index: 0,
639 hit_clip: None,
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
648 assert_eq!(click_count.get(), 0);
649 }
650}