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::{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    /// Source sub-region in image-pixel coordinates. `None` means full image.
71    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        // Simple rect check + shape check if needed
93        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/// A shadow that requires GPU blur processing.
133#[derive(Clone)]
134pub struct ShadowDraw {
135    /// Shapes to render to offscreen target before blur.
136    /// Each shape carries its own blend mode (SrcOver for fill, DstOut for cutout).
137    pub shapes: Vec<(DrawShape, BlendMode)>,
138    /// Texts to render to offscreen target before blur.
139    pub texts: Vec<TextDraw>,
140    /// Gaussian blur radius in pixels.
141    pub blur_radius: f32,
142    /// Optional clip rect applied when compositing (inner shadows clip to element bounds).
143    pub clip: Option<Rect>,
144    /// Z-index for correct draw ordering.
145    pub z_index: usize,
146}
147
148/// A subtree that should be rendered offscreen and processed by a RenderEffect.
149#[derive(Clone)]
150pub struct EffectLayer {
151    pub rect: Rect,
152    pub clip: Option<Rect>,
153    /// Optional effect to apply to the offscreen subtree.
154    /// `None` means isolate/composite only (no post-effect shader).
155    pub effect: Option<RenderEffect>,
156    /// Blend mode used when compositing the offscreen subtree back to the parent.
157    pub blend_mode: BlendMode,
158    /// Alpha applied when compositing the offscreen subtree back to the parent.
159    pub composite_alpha: f32,
160    /// Z-index of the first draw item in this effect layer's subtree.
161    pub z_start: usize,
162    /// Z-index one past the last draw item in this effect layer's subtree.
163    pub z_end: usize,
164}
165
166/// A backdrop effect applied to already-rendered content behind a node.
167#[derive(Clone)]
168pub struct BackdropLayer {
169    pub rect: Rect,
170    pub clip: Option<Rect>,
171    pub effect: RenderEffect,
172    /// Z-index at which this backdrop effect should be applied.
173    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        // Populate both the list and the index for O(1) lookup
353        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        // Sort by z-index descending (top to bottom)
400        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        // O(1) lookup using the node index
406        self.node_index.get(&node_id).cloned()
407    }
408}
409
410// Helper function for rounded rectangle hit testing
411fn 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    // Check corners
420    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    // Top-left corner
427    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    // Top-right corner
434    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    // Bottom-left corner
441    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    // Bottom-right corner
448    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}