Skip to main content

teksilo_canvas/
render_frame.rs

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