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