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