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