Skip to main content

cranpose_ui/
renderer.rs

1use cranpose_core::{MemoryApplier, NodeId};
2use cranpose_ui_graphics::DrawPrimitive;
3
4use crate::{
5    layout::{LayoutBox, LayoutNodeData, LayoutTree},
6    modifier::{DrawCommand as ModifierDrawCommand, Point, Rect, Size},
7    widgets::LayoutNode,
8};
9
10/// Layer that a paint operation targets within the rendering pipeline.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum PaintLayer {
13    Behind,
14    Content,
15    Overlay,
16}
17
18/// A rendered operation emitted by the headless renderer.
19#[derive(Clone, Debug, PartialEq)]
20pub enum RenderOp {
21    Primitive {
22        node_id: NodeId,
23        layer: PaintLayer,
24        primitive: DrawPrimitive,
25    },
26    Text {
27        node_id: NodeId,
28        rect: Rect,
29        value: String,
30    },
31}
32
33/// A collection of render operations for a composed scene.
34#[derive(Clone, Debug, Default, PartialEq)]
35pub struct RecordedRenderScene {
36    operations: Vec<RenderOp>,
37}
38
39impl RecordedRenderScene {
40    pub fn new(operations: Vec<RenderOp>) -> Self {
41        Self { operations }
42    }
43
44    /// Returns a slice of recorded render operations in submission order.
45    pub fn operations(&self) -> &[RenderOp] {
46        &self.operations
47    }
48
49    /// Returns an iterator over primitives that target the provided paint layer.
50    pub fn primitives_for(&self, layer: PaintLayer) -> impl Iterator<Item = &DrawPrimitive> {
51        self.operations.iter().filter_map(move |op| match op {
52            RenderOp::Primitive {
53                layer: op_layer,
54                primitive,
55                ..
56            } if *op_layer == layer => Some(primitive),
57            _ => None,
58        })
59    }
60}
61
62/// A lightweight renderer that walks the layout tree and materialises paint commands.
63#[derive(Default)]
64pub struct HeadlessRenderer;
65
66impl HeadlessRenderer {
67    pub fn new() -> Self {
68        Self
69    }
70
71    pub fn render(&self, tree: &LayoutTree) -> RecordedRenderScene {
72        let mut operations = Vec::new();
73        self.render_box(tree.root(), &mut operations);
74        RecordedRenderScene::new(operations)
75    }
76
77    #[allow(clippy::only_used_in_recursion)]
78    fn render_box(&self, layout: &LayoutBox, operations: &mut Vec<RenderOp>) {
79        let rect = layout.rect;
80        let (mut behind, mut overlay) = evaluate_modifier(layout.node_id, &layout.node_data, rect);
81
82        operations.append(&mut behind);
83
84        // Render text content if present in modifier slices.
85        // This follows Jetpack Compose's pattern where text is a modifier node capability
86        // (TextModifierNode implements LayoutModifierNode + DrawModifierNode + SemanticsNode)
87        if let Some(text) = layout.node_data.modifier_slices().text_content() {
88            operations.push(RenderOp::Text {
89                node_id: layout.node_id,
90                rect,
91                value: text.to_string(),
92            });
93        }
94
95        // Render children
96        for child in &layout.children {
97            self.render_box(child, operations);
98        }
99
100        operations.append(&mut overlay);
101    }
102}
103
104fn evaluate_modifier(
105    node_id: NodeId,
106    data: &LayoutNodeData,
107    rect: Rect,
108) -> (Vec<RenderOp>, Vec<RenderOp>) {
109    let size = Size {
110        width: rect.width,
111        height: rect.height,
112    };
113
114    let behind = collect_primitives_from_commands(
115        node_id,
116        rect,
117        size,
118        data.modifier_slices().draw_commands(),
119        PaintLayer::Behind,
120    );
121    let overlay = collect_primitives_from_commands(
122        node_id,
123        rect,
124        size,
125        data.modifier_slices().draw_commands(),
126        PaintLayer::Overlay,
127    );
128    (behind, overlay)
129}
130
131fn collect_primitives_from_commands(
132    node_id: NodeId,
133    rect: Rect,
134    size: Size,
135    commands: &[ModifierDrawCommand],
136    layer: PaintLayer,
137) -> Vec<RenderOp> {
138    let split_with_content = |primitives: Vec<DrawPrimitive>, layer| {
139        let Some(last_content_idx) = primitives
140            .iter()
141            .rposition(|primitive| matches!(primitive, DrawPrimitive::Content))
142        else {
143            return if layer == PaintLayer::Overlay {
144                primitives
145                    .into_iter()
146                    .filter(|primitive| !matches!(primitive, DrawPrimitive::Content))
147                    .collect()
148            } else {
149                Vec::new()
150            };
151        };
152
153        primitives
154            .into_iter()
155            .enumerate()
156            .filter_map(|(index, primitive)| {
157                if matches!(primitive, DrawPrimitive::Content) {
158                    return None;
159                }
160                let is_before = index < last_content_idx;
161                match layer {
162                    PaintLayer::Behind if is_before => Some(primitive),
163                    PaintLayer::Overlay if !is_before => Some(primitive),
164                    _ => None,
165                }
166            })
167            .collect()
168    };
169
170    let run = |func: &crate::draw::DrawCommandFn| {
171        use cranpose_ui_graphics::DrawScope as _;
172        let mut scope = crate::draw::command_draw_scope(size);
173        func(&mut scope);
174        scope.into_primitives()
175    };
176    let mut ops = Vec::new();
177    for command in commands {
178        let primitives = match (layer, command) {
179            (PaintLayer::Behind, ModifierDrawCommand::Behind(func)) => run(func)
180                .into_iter()
181                .filter(|primitive| !matches!(primitive, DrawPrimitive::Content))
182                .collect(),
183            (PaintLayer::Overlay, ModifierDrawCommand::Overlay(func)) => run(func)
184                .into_iter()
185                .filter(|primitive| !matches!(primitive, DrawPrimitive::Content))
186                .collect(),
187            (PaintLayer::Behind | PaintLayer::Overlay, ModifierDrawCommand::WithContent(func)) => {
188                split_with_content(run(func), layer)
189            }
190            _ => Vec::new(),
191        };
192        for primitive in primitives {
193            ops.push(RenderOp::Primitive {
194                node_id,
195                layer,
196                primitive: translate_primitive(primitive, rect.x, rect.y),
197            });
198        }
199    }
200    ops
201}
202
203fn translate_primitive(primitive: DrawPrimitive, dx: f32, dy: f32) -> DrawPrimitive {
204    match primitive {
205        DrawPrimitive::Content => DrawPrimitive::Content,
206        DrawPrimitive::Blend {
207            primitive,
208            blend_mode,
209        } => DrawPrimitive::Blend {
210            primitive: Box::new(translate_primitive(*primitive, dx, dy)),
211            blend_mode,
212        },
213        DrawPrimitive::Rect {
214            rect,
215            brush,
216            stroke,
217        } => DrawPrimitive::Rect {
218            rect: rect.translate(dx, dy),
219            brush,
220            stroke,
221        },
222        DrawPrimitive::RoundRect {
223            rect,
224            brush,
225            radii,
226            stroke,
227        } => DrawPrimitive::RoundRect {
228            rect: rect.translate(dx, dy),
229            brush,
230            radii,
231            stroke,
232        },
233        DrawPrimitive::Arc {
234            rect,
235            brush,
236            center,
237            radius,
238            start_angle,
239            sweep_angle,
240            stroke,
241            inner_radius,
242        } => DrawPrimitive::Arc {
243            rect: rect.translate(dx, dy),
244            brush,
245            // The arc center lives in the same local space as `rect`, so it
246            // must move with it — translating only the bounding box would
247            // silently shear the arc out of its box.
248            center: Point::new(center.x + dx, center.y + dy),
249            radius,
250            start_angle,
251            sweep_angle,
252            stroke,
253            inner_radius,
254        },
255        DrawPrimitive::Image {
256            rect,
257            image,
258            alpha,
259            color_filter,
260            sampling,
261            src_rect,
262        } => DrawPrimitive::Image {
263            rect: rect.translate(dx, dy),
264            image,
265            alpha,
266            color_filter,
267            sampling,
268            src_rect,
269        },
270        DrawPrimitive::Text(mut text) => {
271            text.rect = text.rect.translate(dx, dy);
272            DrawPrimitive::Text(text)
273        }
274        DrawPrimitive::Shadow(shadow) => {
275            use cranpose_ui_graphics::ShadowPrimitive;
276            DrawPrimitive::Shadow(match shadow {
277                ShadowPrimitive::Drop {
278                    shape,
279                    cutout,
280                    blur_radius,
281                    blend_mode,
282                } => ShadowPrimitive::Drop {
283                    shape: Box::new(translate_primitive(*shape, dx, dy)),
284                    cutout: cutout.map(|cutout| Box::new(translate_primitive(*cutout, dx, dy))),
285                    blur_radius,
286                    blend_mode,
287                },
288                ShadowPrimitive::Inner {
289                    fill,
290                    cutout,
291                    blur_radius,
292                    blend_mode,
293                    clip_rect,
294                } => ShadowPrimitive::Inner {
295                    fill: Box::new(translate_primitive(*fill, dx, dy)),
296                    cutout: Box::new(translate_primitive(*cutout, dx, dy)),
297                    blur_radius,
298                    blend_mode,
299                    clip_rect: clip_rect.translate(dx, dy),
300                },
301            })
302        }
303    }
304}
305
306// ═══════════════════════════════════════════════════════════════════════════
307// Direct Applier Rendering (new architecture)
308// ═══════════════════════════════════════════════════════════════════════════
309
310impl HeadlessRenderer {
311    /// Renders the scene by traversing LayoutNodes directly via the Applier.
312    /// This is the new architecture that eliminates per-frame LayoutTree reconstruction.
313    pub fn render_from_applier(
314        &self,
315        applier: &mut MemoryApplier,
316        root: NodeId,
317    ) -> RecordedRenderScene {
318        let mut operations = Vec::new();
319        self.render_node_from_applier(applier, root, Point::default(), &mut operations);
320        RecordedRenderScene::new(operations)
321    }
322
323    #[allow(clippy::only_used_in_recursion)]
324    fn render_node_from_applier(
325        &self,
326        applier: &mut MemoryApplier,
327        node_id: NodeId,
328        parent_offset: Point,
329        operations: &mut Vec<RenderOp>,
330    ) {
331        // Read layout state and node data from LayoutNode
332        let node_data = match applier.with_node::<LayoutNode, _>(node_id, |node| {
333            let state = node.layout_state();
334            let modifier_slices = node.modifier_slices_snapshot();
335            let children: Vec<NodeId> = node.children.clone();
336            (state, modifier_slices, children)
337        }) {
338            Ok(data) => data,
339            Err(_) => return, // Node not found or type mismatch
340        };
341
342        let (layout_state, modifier_slices, children) = node_data;
343
344        // Skip nodes that weren't placed
345        if !layout_state.is_placed() {
346            return;
347        }
348
349        // Calculate absolute position
350        let abs_x = parent_offset.x + layout_state.position().x;
351        let abs_y = parent_offset.y + layout_state.position().y;
352
353        let rect = Rect {
354            x: abs_x,
355            y: abs_y,
356            width: layout_state.size().width,
357            height: layout_state.size().height,
358        };
359
360        let size = Size {
361            width: rect.width,
362            height: rect.height,
363        };
364
365        // Collect draw commands from modifier slices
366        let mut behind = Vec::new();
367        let mut overlay = Vec::new();
368        behind.extend(collect_primitives_from_commands(
369            node_id,
370            rect,
371            size,
372            modifier_slices.draw_commands(),
373            PaintLayer::Behind,
374        ));
375        overlay.extend(collect_primitives_from_commands(
376            node_id,
377            rect,
378            size,
379            modifier_slices.draw_commands(),
380            PaintLayer::Overlay,
381        ));
382
383        operations.append(&mut behind);
384
385        // Render text content if present
386        if let Some(text) = modifier_slices.text_content() {
387            operations.push(RenderOp::Text {
388                node_id,
389                rect,
390                value: text.to_string(),
391            });
392        }
393
394        // Calculate content offset for children (includes node position + content_offset from padding etc.)
395        let child_offset = Point {
396            x: abs_x + layout_state.content_offset.x,
397            y: abs_y + layout_state.content_offset.y,
398        };
399
400        // Render children
401        for child_id in children {
402            self.render_node_from_applier(applier, child_id, child_offset, operations);
403        }
404
405        operations.append(&mut overlay);
406    }
407}
408
409#[cfg(test)]
410#[path = "tests/renderer_tests.rs"]
411mod tests;