Skip to main content

cranpose_render_wgpu/
scene.rs

1//! Scene structures for GPU rendering
2
3use 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    /// Source sub-region in image-pixel coordinates. `None` means full image.
61    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        // Simple rect check + shape check if needed
83        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        // Populate both the list and the index for O(1) lookup
232        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        // Sort by z-index descending (top to bottom)
260        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        // O(1) lookup using the node index
266        self.node_index.get(&node_id).cloned()
267    }
268}
269
270// Helper function for rounded rectangle hit testing
271fn 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    // Check corners
280    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    // Top-left corner
287    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    // Top-right corner
294    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    // Bottom-left corner
301    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    // Bottom-right corner
308    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}