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}