Skip to main content

cranpose_ui/
renderer.rs

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