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