1use cranpose_core::NodeId;
4use cranpose_foundation::{PointerEvent, PointerEventKind};
5use cranpose_render_common::{HitTestTarget, RenderScene};
6use cranpose_ui_graphics::{
7 Brush, Color, ColorFilter, ImageBitmap, Point, Rect, RoundedCornerShape,
8};
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::rc::Rc;
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, rect: Rect, x: f32, y: f32) {
21 match self {
22 ClickAction::Simple(handler) => (handler.borrow_mut())(),
23 ClickAction::WithPoint(handler) => handler(Point {
24 x: x - rect.x,
25 y: y - rect.y,
26 }),
27 }
28 }
29}
30
31#[derive(Clone)]
32pub struct DrawShape {
33 pub rect: Rect,
34 pub brush: Brush,
35 pub shape: Option<RoundedCornerShape>,
36 pub z_index: usize,
37 pub clip: Option<Rect>,
38}
39
40#[derive(Clone)]
41pub struct TextDraw {
42 pub node_id: NodeId,
43 pub rect: Rect,
44 pub text: Rc<str>,
45 pub color: Color,
46 pub font_size: f32,
47 pub scale: f32,
48 pub z_index: usize,
49 pub clip: Option<Rect>,
50}
51
52#[derive(Clone)]
53pub struct ImageDraw {
54 pub rect: Rect,
55 pub image: ImageBitmap,
56 pub alpha: f32,
57 pub color_filter: Option<ColorFilter>,
58 pub z_index: usize,
59 pub clip: Option<Rect>,
60 pub src_rect: Option<Rect>,
62}
63
64#[derive(Clone)]
65pub struct HitRegion {
66 pub node_id: NodeId,
67 pub rect: Rect,
68 pub shape: Option<RoundedCornerShape>,
69 pub click_actions: Vec<ClickAction>,
70 pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
71 pub z_index: usize,
72 pub hit_clip: Option<Rect>,
73}
74
75impl HitRegion {
76 fn contains(&self, x: f32, y: f32) -> bool {
77 if let Some(clip) = self.hit_clip {
78 if !clip.contains(x, y) {
79 return false;
80 }
81 }
82 if let Some(shape) = self.shape {
84 point_in_rounded_rect(x, y, self.rect, shape)
85 } else {
86 self.rect.contains(x, 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 let x = event.global_position.x;
101 let y = event.global_position.y;
102 let kind = event.kind;
103 let local_position = Point {
104 x: x - self.rect.x,
105 y: y - self.rect.y,
106 };
107 let local_event = event.copy_with_local_position(local_position);
108 for handler in &self.pointer_inputs {
109 if local_event.is_consumed() {
110 break;
111 }
112 handler(local_event.clone());
113 }
114 if kind == PointerEventKind::Down && !local_event.is_consumed() {
115 for action in &self.click_actions {
116 action.invoke(self.rect, x, y);
117 }
118 }
119 }
120}
121
122pub struct Scene {
123 pub shapes: Vec<DrawShape>,
124 pub images: Vec<ImageDraw>,
125 pub texts: Vec<TextDraw>,
126 pub hits: Vec<HitRegion>,
127 pub next_z: usize,
128 pub node_index: HashMap<NodeId, HitRegion>,
129}
130
131impl Scene {
132 pub fn new() -> Self {
133 Self {
134 shapes: Vec::new(),
135 images: Vec::new(),
136 texts: Vec::new(),
137 hits: Vec::new(),
138 next_z: 0,
139 node_index: HashMap::new(),
140 }
141 }
142
143 pub fn push_shape(
144 &mut self,
145 rect: Rect,
146 brush: Brush,
147 shape: Option<RoundedCornerShape>,
148 clip: Option<Rect>,
149 ) {
150 let z_index = self.next_z;
151 self.next_z += 1;
152 self.shapes.push(DrawShape {
153 rect,
154 brush,
155 shape,
156 z_index,
157 clip,
158 });
159 }
160
161 pub fn push_image(
162 &mut self,
163 rect: Rect,
164 image: ImageBitmap,
165 alpha: f32,
166 color_filter: Option<ColorFilter>,
167 clip: Option<Rect>,
168 src_rect: Option<Rect>,
169 ) {
170 let z_index = self.next_z;
171 self.next_z += 1;
172 self.images.push(ImageDraw {
173 rect,
174 image,
175 alpha: alpha.clamp(0.0, 1.0),
176 color_filter,
177 z_index,
178 clip,
179 src_rect,
180 });
181 }
182
183 #[allow(clippy::too_many_arguments)]
184 pub fn push_text(
185 &mut self,
186 node_id: NodeId,
187 rect: Rect,
188 text: Rc<str>,
189 color: Color,
190 font_size: f32,
191 scale: f32,
192 clip: Option<Rect>,
193 ) {
194 let z_index = self.next_z;
195 self.next_z += 1;
196 self.texts.push(TextDraw {
197 node_id,
198 rect,
199 text,
200 color,
201 font_size,
202 scale,
203 z_index,
204 clip,
205 });
206 }
207
208 pub fn push_hit(
209 &mut self,
210 node_id: NodeId,
211 rect: Rect,
212 shape: Option<RoundedCornerShape>,
213 click_actions: Vec<ClickAction>,
214 pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
215 hit_clip: Option<Rect>,
216 ) {
217 if click_actions.is_empty() && pointer_inputs.is_empty() {
218 return;
219 }
220 let z_index = self.next_z;
221 self.next_z += 1;
222 let hit_region = HitRegion {
223 node_id,
224 rect,
225 shape,
226 click_actions,
227 pointer_inputs,
228 z_index,
229 hit_clip,
230 };
231 self.node_index.insert(node_id, hit_region.clone());
233 self.hits.push(hit_region);
234 }
235}
236
237impl Default for Scene {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243impl RenderScene for Scene {
244 type HitTarget = HitRegion;
245
246 fn clear(&mut self) {
247 self.shapes.clear();
248 self.images.clear();
249 self.texts.clear();
250 self.hits.clear();
251 self.node_index.clear();
252 self.next_z = 0;
253 }
254
255 fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
256 let mut hits = self.hits.clone();
257 hits.retain(|hit| hit.contains(x, y));
258
259 hits.sort_by(|a, b| b.z_index.cmp(&a.z_index));
261 hits
262 }
263
264 fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
265 self.node_index.get(&node_id).cloned()
267 }
268}
269
270fn point_in_rounded_rect(x: f32, y: f32, rect: Rect, shape: RoundedCornerShape) -> bool {
272 if !rect.contains(x, y) {
273 return false;
274 }
275
276 let local_x = x - rect.x;
277 let local_y = y - rect.y;
278
279 let radii = shape.resolve(rect.width, rect.height);
281 let tl = radii.top_left;
282 let tr = radii.top_right;
283 let bl = radii.bottom_left;
284 let br = radii.bottom_right;
285
286 if local_x < tl && local_y < tl {
288 let dx = tl - local_x;
289 let dy = tl - local_y;
290 return dx * dx + dy * dy <= tl * tl;
291 }
292
293 if local_x > rect.width - tr && local_y < tr {
295 let dx = local_x - (rect.width - tr);
296 let dy = tr - local_y;
297 return dx * dx + dy * dy <= tr * tr;
298 }
299
300 if local_x < bl && local_y > rect.height - bl {
302 let dx = bl - local_x;
303 let dy = local_y - (rect.height - bl);
304 return dx * dx + dy * dy <= bl * bl;
305 }
306
307 if local_x > rect.width - br && local_y > rect.height - br {
309 let dx = local_x - (rect.width - br);
310 let dy = local_y - (rect.height - br);
311 return dx * dx + dy * dy <= br * br;
312 }
313
314 true
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::cell::{Cell, RefCell};
321 use std::rc::Rc;
322
323 fn make_handler(counter: Rc<Cell<u32>>, consume: bool) -> Rc<dyn Fn(PointerEvent)> {
324 Rc::new(move |event: PointerEvent| {
325 counter.set(counter.get() + 1);
326 if consume {
327 event.consume();
328 }
329 })
330 }
331
332 #[test]
333 fn hit_test_respects_hit_clip() {
334 let mut scene = Scene::new();
335 let rect = Rect {
336 x: 0.0,
337 y: 0.0,
338 width: 100.0,
339 height: 100.0,
340 };
341 let clip = Rect {
342 x: 0.0,
343 y: 0.0,
344 width: 40.0,
345 height: 40.0,
346 };
347 scene.push_hit(
348 1,
349 rect,
350 None,
351 Vec::new(),
352 vec![Rc::new(|_event: PointerEvent| {})],
353 Some(clip),
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 dispatch_stops_after_event_consumed() {
362 let count_first = Rc::new(Cell::new(0));
363 let count_second = Rc::new(Cell::new(0));
364
365 let hit = HitRegion {
366 node_id: 1,
367 rect: Rect {
368 x: 0.0,
369 y: 0.0,
370 width: 50.0,
371 height: 50.0,
372 },
373 shape: None,
374 click_actions: Vec::new(),
375 pointer_inputs: vec![
376 make_handler(count_first.clone(), true),
377 make_handler(count_second.clone(), false),
378 ],
379 z_index: 0,
380 hit_clip: None,
381 };
382
383 let event = PointerEvent::new(
384 PointerEventKind::Down,
385 Point { x: 10.0, y: 10.0 },
386 Point { x: 10.0, y: 10.0 },
387 );
388 hit.dispatch(event);
389
390 assert_eq!(count_first.get(), 1);
391 assert_eq!(count_second.get(), 0);
392 }
393
394 #[test]
395 fn dispatch_triggers_click_action_on_down() {
396 let click_count = Rc::new(Cell::new(0));
397 let click_count_for_handler = Rc::clone(&click_count);
398 let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
399 click_count_for_handler.set(click_count_for_handler.get() + 1);
400 })));
401
402 let hit = HitRegion {
403 node_id: 1,
404 rect: Rect {
405 x: 0.0,
406 y: 0.0,
407 width: 50.0,
408 height: 50.0,
409 },
410 shape: None,
411 click_actions: vec![click_action],
412 pointer_inputs: Vec::new(),
413 z_index: 0,
414 hit_clip: None,
415 };
416
417 hit.dispatch(PointerEvent::new(
418 PointerEventKind::Down,
419 Point { x: 10.0, y: 10.0 },
420 Point { x: 10.0, y: 10.0 },
421 ));
422 hit.dispatch(PointerEvent::new(
423 PointerEventKind::Move,
424 Point { x: 10.0, y: 10.0 },
425 Point { x: 12.0, y: 12.0 },
426 ));
427
428 assert_eq!(click_count.get(), 1);
429 }
430
431 #[test]
432 fn dispatch_does_not_trigger_click_action_when_consumed() {
433 let click_count = Rc::new(Cell::new(0));
434 let click_count_for_handler = Rc::clone(&click_count);
435 let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
436 click_count_for_handler.set(click_count_for_handler.get() + 1);
437 })));
438
439 let hit = HitRegion {
440 node_id: 1,
441 rect: Rect {
442 x: 0.0,
443 y: 0.0,
444 width: 50.0,
445 height: 50.0,
446 },
447 shape: None,
448 click_actions: vec![click_action],
449 pointer_inputs: vec![Rc::new(|event: PointerEvent| event.consume())],
450 z_index: 0,
451 hit_clip: None,
452 };
453
454 hit.dispatch(PointerEvent::new(
455 PointerEventKind::Down,
456 Point { x: 10.0, y: 10.0 },
457 Point { x: 10.0, y: 10.0 },
458 ));
459
460 assert_eq!(click_count.get(), 0);
461 }
462}