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