Skip to main content

teksilo_canvas/
render_frame.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use std::borrow::Cow;
5
6use crate::geometry::{Rect, Transform2D};
7use crate::paint::{FillRule, StrokeSpace, StrokeStyle};
8
9/// The complete render output for one frame. This is the boundary between
10/// platform-independent widget code and GPU-specific rendering code.
11#[derive(Debug, Clone, Default)]
12pub struct RenderFrame {
13    pub glyphs: Vec<GlyphQuad>,
14    pub images: Vec<ImageQuad>,
15    pub decorations: Vec<DecorationRect>,
16    /// Transform-invariant ("cosmetic" / hairline) lines. Unlike
17    /// `decorations`, the width here is NOT baked into geometry — the
18    /// renderer applies a constant device-pixel thickness regardless of the
19    /// active transform. See [`DrawCommand::CosmeticLine`].
20    pub cosmetic_lines: Vec<CosmeticLine>,
21    pub shapes: Vec<ShapeQuad>,
22    pub shadows: Vec<ShadowQuad>,
23    pub rasterized: Vec<RasterizedQuad>,
24    pub paths: Vec<PathEntry>,
25    /// Animated quads (procedural or sprite-atlas kinds). Emitted by
26    /// widgets that opt into the shader-driven animation pipeline via
27    /// `ctx.animated_quad()`. The fragment shader samples per-slot
28    /// state from a renderer-side uniform buffer updated each frame by
29    /// the widget tree — the widget's own `paint()` runs only when
30    /// layout changes, not once per animation frame.
31    pub animated_quads: Vec<AnimatedQuadDraw>,
32    /// Per-slot `AnimParams`, indexed by the `slot` field of each
33    /// `AnimatedQuadDraw`. Recomputed by the widget tree every frame
34    /// (phase advanced, colors resolved against the current theme)
35    /// and uploaded to the renderer's uniform buffer at the top of
36    /// `Renderer::render`. Slots whose widget is dormant / offscreen /
37    /// in an inactive window keep their last-written values — the
38    /// fragment shader still renders, just with stale phase for one
39    /// frame until the next tick resumes.
40    pub anim_params: Vec<AnimParams>,
41    pub draw_order: Vec<DrawCommand>,
42    /// Images that need GPU registration before rendering this frame.
43    pub pending_images: Vec<PendingImage>,
44    /// Opaque [`TextLayout::layout_key`](crate::text_backend::TextLayout::layout_key)
45    /// values for every `draw_text*` call that produced glyphs in this
46    /// frame. When a widget's `cached_paint` is reused without re-running
47    /// `paint()`, the renderer calls `TextBackend::touch_layout(key)` for
48    /// each stored key so the backend can refresh the underlying glyph
49    /// cache timestamps and avoid evicting still-visible glyphs.
50    pub layout_keys: Vec<u64>,
51}
52
53impl RenderFrame {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    pub fn is_empty(&self) -> bool {
59        self.draw_order.is_empty()
60    }
61
62    /// Merge another frame into this one, appending all entries
63    /// and adjusting draw_order indices.
64    pub fn merge(&mut self, other: &RenderFrame) {
65        let glyph_offset = self.glyphs.len();
66        let image_offset = self.images.len();
67        let decoration_offset = self.decorations.len();
68        let cosmetic_line_offset = self.cosmetic_lines.len();
69        let shape_offset = self.shapes.len();
70        let shadow_offset = self.shadows.len();
71        let rasterized_offset = self.rasterized.len();
72        let path_offset = self.paths.len();
73        let animated_offset = self.animated_quads.len();
74
75        self.glyphs.extend_from_slice(&other.glyphs);
76        self.images.extend_from_slice(&other.images);
77        self.decorations.extend_from_slice(&other.decorations);
78        self.cosmetic_lines.extend_from_slice(&other.cosmetic_lines);
79        self.shapes.extend_from_slice(&other.shapes);
80        self.shadows.extend_from_slice(&other.shadows);
81        self.rasterized.extend_from_slice(&other.rasterized);
82        self.paths.extend_from_slice(&other.paths);
83        self.animated_quads.extend_from_slice(&other.animated_quads);
84        // `anim_params` is NOT merged index-wise — the widget tree
85        // writes one authoritative slice per frame (indexed by
86        // registry slot, which is global across the tree). Cached
87        // sub-frames carry empty `anim_params`; the outer tree
88        // replaces it wholesale after `render()` is called.
89        self.layout_keys.extend_from_slice(&other.layout_keys);
90        // Merge pending image registrations (deduped by renderer)
91        for pending in &other.pending_images {
92            if !self.pending_images.iter().any(|p| p.name == pending.name) {
93                self.pending_images.push(pending.clone());
94            }
95        }
96
97        for cmd in &other.draw_order {
98            let shifted = match cmd {
99                DrawCommand::Glyph(i) => DrawCommand::Glyph(i + glyph_offset),
100                DrawCommand::Image(i) => DrawCommand::Image(i + image_offset),
101                DrawCommand::Decoration(i) => DrawCommand::Decoration(i + decoration_offset),
102                DrawCommand::CosmeticLine(i) => DrawCommand::CosmeticLine(i + cosmetic_line_offset),
103                DrawCommand::Shape(i) => DrawCommand::Shape(i + shape_offset),
104                DrawCommand::Shadow(i) => DrawCommand::Shadow(i + shadow_offset),
105                DrawCommand::Rasterized(i) => DrawCommand::Rasterized(i + rasterized_offset),
106                DrawCommand::Path(i) => DrawCommand::Path(i + path_offset),
107                DrawCommand::AnimatedQuad(i) => DrawCommand::AnimatedQuad(i + animated_offset),
108                other => other.clone(),
109            };
110            self.draw_order.push(shifted);
111        }
112    }
113}
114
115impl RenderFrame {
116    /// Validate that clip and opacity stacks are balanced in the draw order.
117    /// Only runs in debug builds. Panics with a descriptive message if
118    /// any push/pop pair is unbalanced.
119    pub fn debug_validate_stacks(&self) {
120        if !cfg!(debug_assertions) {
121            return;
122        }
123        let mut clip_depth: i32 = 0;
124        let mut opacity_depth: i32 = 0;
125        let mut blend_depth: i32 = 0;
126        let mut transform_depth: i32 = 0;
127        let mut blur_depth: i32 = 0;
128        for (i, cmd) in self.draw_order.iter().enumerate() {
129            match cmd {
130                DrawCommand::SetClip(_) => clip_depth += 1,
131                DrawCommand::ClearClip => {
132                    clip_depth -= 1;
133                    debug_assert!(
134                        clip_depth >= 0,
135                        "RenderFrame: ClearClip without matching SetClip at draw_order[{i}]"
136                    );
137                }
138                DrawCommand::SetOpacity(_) => opacity_depth += 1,
139                DrawCommand::RestoreOpacity => {
140                    opacity_depth -= 1;
141                    debug_assert!(
142                        opacity_depth >= 0,
143                        "RenderFrame: RestoreOpacity without matching SetOpacity at draw_order[{i}]"
144                    );
145                }
146                DrawCommand::SetBlendMode(_) => blend_depth += 1,
147                DrawCommand::RestoreBlendMode => {
148                    blend_depth -= 1;
149                    debug_assert!(
150                        blend_depth >= 0,
151                        "RenderFrame: RestoreBlendMode without matching SetBlendMode at draw_order[{i}]"
152                    );
153                }
154                DrawCommand::PushTransform(_) => transform_depth += 1,
155                DrawCommand::PopTransform => {
156                    transform_depth -= 1;
157                    debug_assert!(
158                        transform_depth >= 0,
159                        "RenderFrame: PopTransform without matching PushTransform at draw_order[{i}]"
160                    );
161                }
162                DrawCommand::BeginBlurredSubtree { .. } => blur_depth += 1,
163                DrawCommand::EndBlurredSubtree => {
164                    blur_depth -= 1;
165                    debug_assert!(
166                        blur_depth >= 0,
167                        "RenderFrame: EndBlurredSubtree without matching BeginBlurredSubtree at draw_order[{i}]"
168                    );
169                }
170                _ => {}
171            }
172        }
173        debug_assert!(
174            clip_depth == 0,
175            "RenderFrame: {clip_depth} unmatched SetClip(s) without ClearClip"
176        );
177        debug_assert!(
178            opacity_depth == 0,
179            "RenderFrame: {opacity_depth} unmatched SetOpacity(s) without RestoreOpacity"
180        );
181        debug_assert!(
182            blend_depth == 0,
183            "RenderFrame: {blend_depth} unmatched SetBlendMode(s) without RestoreBlendMode"
184        );
185        debug_assert!(
186            transform_depth == 0,
187            "RenderFrame: {transform_depth} unmatched PushTransform(s) without PopTransform"
188        );
189        debug_assert!(
190            blur_depth == 0,
191            "RenderFrame: {blur_depth} unmatched BeginBlurredSubtree(s) without EndBlurredSubtree"
192        );
193    }
194}
195
196/// A positioned glyph to render as a textured rectangle from the glyph atlas.
197#[derive(Debug, Clone, Copy, PartialEq)]
198pub struct GlyphQuad {
199    /// Screen position and size: [x, y, width, height] in logical pixels.
200    pub screen: [f32; 4],
201    /// Atlas position and size: [x, y, width, height] in atlas texture coordinates.
202    pub atlas: [f32; 4],
203    /// Glyph color: [r, g, b, a]. For monochrome glyphs this is the text
204    /// tint. For color emoji it is `[1, 1, 1, 1]` — the atlas region
205    /// already holds the pre-multiplied RGBA bitmap, so the renderer
206    /// samples `texture.rgb` directly.
207    pub color: [f32; 4],
208    /// `true` if the atlas region holds a pre-multiplied RGBA color
209    /// bitmap (color emoji via COLR/CBDT/sbix). When set, the renderer
210    /// must sample `texture.rgb` instead of using the texture as an
211    /// alpha mask.
212    pub is_color: bool,
213}
214
215/// An image quad to render as a textured rectangle.
216#[derive(Debug, Clone, PartialEq)]
217pub struct ImageQuad {
218    /// Screen position and size: [x, y, width, height] in logical pixels.
219    pub screen: [f32; 4],
220    /// Resource name of the image.
221    pub name: String,
222    /// When `Some(color)`, the image is rendered as an alpha mask tinted
223    /// with this color (shader flag=0). When `None`, the image is rendered
224    /// in full color (shader flag=1, existing behavior).
225    pub tint: Option<[f32; 4]>,
226}
227
228/// An image that needs to be registered (uploaded to GPU) before rendering.
229/// Widgets emit these during paint for embedded raster resources.
230#[derive(Debug, Clone)]
231pub struct PendingImage {
232    /// Resource name to register under.
233    pub name: String,
234    /// Image width in pixels.
235    pub width: u32,
236    /// Image height in pixels.
237    pub height: u32,
238    /// RGBA pixel data. Uses `Cow` for zero-copy with compile-time data.
239    pub pixels: Cow<'static, [u8]>,
240}
241
242/// A colored rectangle for decorations (selections, cursors, underlines, borders, etc.).
243#[derive(Debug, Clone, Copy, PartialEq)]
244pub struct DecorationRect {
245    /// Position and size: [x, y, width, height] in logical pixels.
246    pub rect: [f32; 4],
247    /// Color: [r, g, b, a].
248    pub color: [f32; 4],
249    /// What kind of decoration this is.
250    pub kind: DecorationKind,
251}
252
253/// The kind of decoration a DecorationRect represents.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum DecorationKind {
256    WidgetBackground,
257    Selection,
258    Cursor,
259    Underline,
260    Overline,
261    Strikeout,
262    FocusRing,
263    DropIndicator,
264    TableBorder,
265    TableCellBackground,
266    BlockBackground,
267    TextBackground,
268    CellSelection,
269}
270
271/// A transform-invariant ("cosmetic" / hairline) line. The endpoints are in
272/// logical pixels and follow the active transform; `width` is in logical
273/// pixels but applied as a constant device thickness (× scale_factor), NOT
274/// scaled by the transform's zoom. Emitted by
275/// [`Canvas::draw_line`](crate::Canvas::draw_line) /
276/// [`Canvas::stroke_rect`](crate::Canvas::stroke_rect) when the stroke is
277/// [`StrokeSpace::Device`].
278#[derive(Debug, Clone, Copy, PartialEq)]
279pub struct CosmeticLine {
280    /// Start point [x, y] in logical pixels.
281    pub from: [f32; 2],
282    /// End point [x, y] in logical pixels.
283    pub to: [f32; 2],
284    /// Stroke thickness in logical pixels, held transform-invariant.
285    pub width: f32,
286    /// Color: [r, g, b, a].
287    pub color: [f32; 4],
288}
289
290/// A shape rendered via SDF (signed distance field) shaders.
291#[derive(Debug, Clone, PartialEq)]
292pub struct ShapeQuad {
293    /// Screen position and size: [x, y, width, height] in logical pixels.
294    pub screen: [f32; 4],
295    /// Fill color: [r, g, b, a].
296    pub color: [f32; 4],
297    /// What shape to render.
298    pub shape: ShapeKind,
299    /// Stroke width (0.0 for filled shapes).
300    pub stroke_width: f32,
301    /// Whether the stroke width is logical (scales with the view transform) or
302    /// device/cosmetic (held constant in device pixels, invariant to zoom). A
303    /// cosmetic stroke keeps a hairline border crisp at any scene zoom. Fills
304    /// (`stroke_width == 0.0`) ignore this.
305    pub stroke_space: StrokeSpace,
306    /// Corner radii: [top_left, top_right, bottom_right, bottom_left].
307    pub corner_radii: [f32; 4],
308    /// Paint type for the shape.
309    pub paint_data: PaintData,
310}
311
312/// The kind of SDF shape.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ShapeKind {
315    RoundedRect,
316    Circle,
317    Ellipse,
318}
319
320/// A CPU-rasterized path result, stored in the shape atlas.
321#[derive(Debug, Clone, Copy, PartialEq)]
322pub struct RasterizedQuad {
323    /// Screen position and size: [x, y, width, height] in logical pixels.
324    pub screen: [f32; 4],
325    /// Shape atlas position and size: [x, y, width, height] in atlas coordinates.
326    pub atlas: [f32; 4],
327    /// Tint color: [r, g, b, a].
328    pub color: [f32; 4],
329}
330
331/// A shadow rendered behind a shape using a separate GPU pipeline with Gaussian blur.
332#[derive(Debug, Clone, PartialEq)]
333pub struct ShadowQuad {
334    /// Shadow bounding box (expanded by blur + spread + offset): [x, y, width, height].
335    pub screen: [f32; 4],
336    /// Shadow color: [r, g, b, a].
337    pub color: [f32; 4],
338    /// Corner radii matching the shape: [top_left, top_right, bottom_right, bottom_left].
339    pub corner_radii: [f32; 4],
340    /// The original shape rect (before offset/spread): [x, y, width, height].
341    pub shape_rect: [f32; 4],
342    /// Gaussian blur radius.
343    pub blur_radius: f32,
344    /// Shadow spread amount.
345    pub spread: f32,
346}
347
348/// A path to be rasterized on the CPU (Tier 3). Stored in the RenderFrame
349/// until the renderer rasterizes it into the shape atlas and converts it
350/// to a [`RasterizedQuad`].
351#[derive(Debug, Clone, PartialEq)]
352pub struct PathEntry {
353    /// The path commands to rasterize.
354    pub path: crate::path::Path,
355    /// Fill color: [r, g, b, a].
356    pub color: [f32; 4],
357    /// Stroke style (width, dash pattern, line cap). A zero width signals
358    /// a fill (the rasterizer branches on it).
359    pub stroke_style: StrokeStyle,
360    /// Fill rule for the fill branch (ignored when stroking).
361    pub fill_rule: FillRule,
362    /// Bounding rect in logical pixels (computed from path bounds).
363    pub bounds: [f32; 4],
364    /// Paint type for the fill. Mirrors `ShapeQuad::paint_data` (Tier 2):
365    /// `Solid` fills draw through the lean `quad_pipeline` (tinted by
366    /// `color`); gradient variants draw through the dedicated
367    /// `path_gradient` pipeline, which ignores `color` and instead
368    /// samples an analytic gradient using this data.
369    ///
370    /// Strokes may carry a gradient too (`Canvas::stroke_path_with_paint`): the
371    /// gradient pipeline samples the atlas coverage mask, and the mask is the
372    /// stroked outline rather than the filled interior — nothing else about the
373    /// draw changes. Note the gradient is normalized against `bounds`, which for
374    /// a stroke is the outline's *expanded* rect; the canvas re-bases the
375    /// gradient's coordinates accordingly, so callers always express them
376    /// relative to the path's own bounds.
377    pub paint_data: PaintData,
378}
379
380/// Paint data for SDF shapes, passed to the GPU shader.
381#[derive(Debug, Default, Clone, PartialEq)]
382pub enum PaintData {
383    #[default]
384    Solid,
385    LinearGradient {
386        start: [f32; 2],
387        end: [f32; 2],
388        stops: Vec<crate::paint::GradientStop>,
389    },
390    RadialGradient {
391        center: [f32; 2],
392        radius: f32,
393        stops: Vec<crate::paint::GradientStop>,
394    },
395    ConicGradient {
396        center: [f32; 2],
397        start_angle: f32,
398        stops: Vec<crate::paint::GradientStop>,
399    },
400}
401
402/// Compositing blend mode.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
404pub enum BlendMode {
405    #[default]
406    Normal,
407    Multiply,
408    Screen,
409    Overlay,
410    Darken,
411    Lighten,
412    ColorDodge,
413    ColorBurn,
414}
415
416/// A draw command referencing an entry in one of the RenderFrame arrays.
417/// Commands are recorded in painter's order (back-to-front).
418#[derive(Debug, Clone, PartialEq)]
419pub enum DrawCommand {
420    Glyph(usize),
421    Image(usize),
422    Decoration(usize),
423    /// A transform-invariant cosmetic line — index into
424    /// [`RenderFrame::cosmetic_lines`].
425    CosmeticLine(usize),
426    Shape(usize),
427    Shadow(usize),
428    Rasterized(usize),
429    Path(usize),
430    /// Shader-driven animated quad — index into `RenderFrame::animated_quads`.
431    /// The per-frame state (phase, frame_index, colors) is NOT in the
432    /// vertex data; the renderer looks it up from its uniform buffer
433    /// via the `slot` stored in `AnimatedQuadDraw`.
434    AnimatedQuad(usize),
435    SetClip(Rect),
436    ClearClip,
437    SetOpacity(f32),
438    RestoreOpacity,
439    SetBlendMode(BlendMode),
440    RestoreBlendMode,
441    /// Set the renderer's current transform. **Composes** with the
442    /// top of the renderer's transform stack: the new current is
443    /// `t.then(stack_top)` — the supplied transform is applied to a
444    /// local point *first*, then the stack's outer ancestors compose
445    /// outward. For widgets not under any `PushTransform` scope the
446    /// stack is `[identity]`, so this behaves as "set absolute" —
447    /// backwards compatible.
448    SetTransform(Transform2D),
449    /// Push a new transform onto the renderer's transform stack.
450    /// The new top becomes `t.then(prev_top)` — the deepest (innermost)
451    /// `t` applies to a pre-transform local point first, then outer
452    /// ancestors compose outward. That becomes the renderer's
453    /// `current_transform` until the matching
454    /// [`DrawCommand::PopTransform`]. Emitted by the render walker
455    /// around a subtree whose root has a `transform_prop` set. See
456    /// `WidgetArena::effective_transform` in `teksilo-core` for the
457    /// composition mirrored on the arena side (used by hit-testing
458    /// and a11y bounds projection).
459    PushTransform(Transform2D),
460    /// Pop the renderer's transform stack, restoring the previous
461    /// top as the new `current_transform`. Must be paired with a
462    /// [`DrawCommand::PushTransform`].
463    PopTransform,
464    /// Begin an offscreen-rendered, blurred subtree. The renderer
465    /// allocates an intermediate texture sized to `bounds` (in logical
466    /// pixels), redirects subsequent drawing into it, and on the
467    /// matching [`DrawCommand::EndBlurredSubtree`] runs a dual-Kawase
468    /// blur chain at the requested `radius` and composites the result
469    /// back into the parent pass at `bounds`.
470    BeginBlurredSubtree {
471        bounds: Rect,
472        radius: f32,
473    },
474    /// End an offscreen-rendered, blurred subtree. Must be paired with
475    /// a preceding [`DrawCommand::BeginBlurredSubtree`].
476    EndBlurredSubtree,
477}
478
479/// An animated quad to render with one of the shader-animation pipelines.
480/// The fragment shader samples per-slot state from the renderer's uniform
481/// buffer (updated each frame by the widget tree's animated-quad
482/// registry) — the `slot` field selects which entry to read.
483#[derive(Debug, Clone, PartialEq)]
484pub struct AnimatedQuadDraw {
485    /// Screen-space bounds: [x, y, width, height] in logical pixels.
486    pub screen: [f32; 4],
487    /// Dense index into the renderer's `AnimParams` uniform array. Owned
488    /// and allocated by the widget tree's `AnimatedQuadRegistry`; stable
489    /// for the lifetime of one widget mount (freed on rebuild/destroy).
490    pub slot: u32,
491    /// Which pipeline draws this quad — procedural (sweep, pulse…) or
492    /// sprite (texture-atlas frame cycling). Picked once at emit time.
493    pub class: AnimatedQuadClass,
494}
495
496/// Which shader pipeline a [`DrawCommand::AnimatedQuad`] is routed to.
497/// Chosen by the widget at `Canvas::draw_animated_quad` time based on
498/// its `AnimatedQuadKind`; the renderer binds the matching pipeline.
499#[derive(Debug, Clone, PartialEq)]
500pub enum AnimatedQuadClass {
501    /// Fully procedural — no texture binding. IndeterminateSweep,
502    /// Pulse, Shimmer, etc.
503    Procedural,
504    /// Samples a texture atlas. Carries the image name so the renderer
505    /// can resolve the bind group (same path registered images use).
506    Sprite { image_name: String },
507}
508
509/// GPU-visible per-slot state for a shader-driven animated quad.
510/// Layout must match the WGSL `AnimParams` struct in
511/// `teksilo-render/src/shaders/anim_procedural.wgsl` (and the sprite
512/// variant). `repr(C)` with explicit `_pad` fields for `std140`
513/// compatibility.
514///
515/// Lives in `teksilo-canvas` (not `teksilo-core`) because it is the
516/// serialized-over-the-wire data type between the tree's animated-quad
517/// registry and the renderer, and `RenderFrame` is already the
518/// tree→renderer data channel.
519#[repr(C)]
520#[derive(Debug, Clone, Copy, PartialEq, Default, bytemuck::Pod, bytemuck::Zeroable)]
521pub struct AnimParams {
522    /// Discriminator — 0 = IndeterminateSweep, 1 = SpriteCycle,
523    /// 2 = SpinnerArc, ... Matches the `kind: u32` constant in the
524    /// fragment shader switch.
525    pub kind: u32,
526    /// Continuous phase for procedural kinds (0..1) OR integer frame
527    /// index (as f32) for sprite kinds. SpinnerArc: rotation phase
528    /// (0..1, one full rotation per period).
529    pub phase: f32,
530    /// IndeterminateSweep: sweep band width (0..1).
531    /// SpinnerArc: arc length as a fraction of the full circle.
532    /// Other kinds unused.
533    pub sweep_ratio: f32,
534    /// Generic per-kind parameter slot. SpinnerArc: stroke thickness
535    /// as a fraction of the smaller extent (0..0.5). Other kinds
536    /// treat this as padding for std140 alignment.
537    pub _pad0: f32,
538    /// IndeterminateSweep: track color. Unused for sprite and spinner.
539    pub color0: [f32; 4],
540    /// IndeterminateSweep: fill color. SpriteCycle: tint (alpha 0 = no
541    /// tint). SpinnerArc: arc color.
542    pub color1: [f32; 4],
543    /// Sprite atlas grid width (cols). Unused for procedural.
544    pub atlas_cols: f32,
545    /// Sprite atlas grid height (rows). Unused for procedural.
546    pub atlas_rows: f32,
547    pub _pad1: [f32; 2],
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn merge_frames() {
556        let mut a = RenderFrame::new();
557        a.shapes.push(ShapeQuad {
558            screen: [0.0, 0.0, 10.0, 10.0],
559            color: [1.0, 0.0, 0.0, 1.0],
560            shape: ShapeKind::RoundedRect,
561            stroke_width: 0.0,
562            stroke_space: StrokeSpace::Logical,
563            corner_radii: [0.0; 4],
564            paint_data: PaintData::Solid,
565        });
566        a.draw_order.push(DrawCommand::Shape(0));
567
568        let mut b = RenderFrame::new();
569        b.decorations.push(DecorationRect {
570            rect: [0.0, 0.0, 5.0, 5.0],
571            color: [0.0, 0.0, 1.0, 1.0],
572            kind: DecorationKind::FocusRing,
573        });
574        b.draw_order.push(DrawCommand::Decoration(0));
575
576        a.merge(&b);
577        assert_eq!(a.shapes.len(), 1);
578        assert_eq!(a.decorations.len(), 1);
579        assert_eq!(a.draw_order.len(), 2);
580        assert_eq!(a.draw_order[1], DrawCommand::Decoration(0));
581    }
582
583    #[test]
584    fn merge_preserves_state_commands() {
585        let mut a = RenderFrame::new();
586        a.draw_order.push(DrawCommand::SetOpacity(0.5));
587        let mut b = RenderFrame::new();
588        b.draw_order.push(DrawCommand::RestoreOpacity);
589        a.merge(&b);
590        assert_eq!(a.draw_order.len(), 2);
591        assert_eq!(a.draw_order[0], DrawCommand::SetOpacity(0.5));
592        assert_eq!(a.draw_order[1], DrawCommand::RestoreOpacity);
593    }
594}