Skip to main content

bevy_react/canvas/
mod.rs

1//! The `canvas` host element: an arbitrary anti-aliased vector drawing surface.
2//!
3//! A `<canvas>` is a normal styled UI node carrying an [`ImageNode`] whose
4//! texture this module paints. Semantics are web-faithful: the surface is a
5//! **retained pixel buffer** that paint accumulates onto. React-side drawing
6//! calls (`ctx.moveTo`/`lineTo`/`fill`/`clearRect`/…) record [`DrawCmd`]s that
7//! cross the bridge — either as the declarative `draw` prop (clear + replay)
8//! or as imperative `draw` ops from a persistent canvas handle (append) — and
9//! land in the [`CanvasSurface`]'s pending queue. Each frame,
10//! [`update_canvas_surfaces`] drains the queue onto the retained pixmap at the
11//! node's laid-out pixel size.
12//!
13//! Like an HTML canvas whose `width`/`height` is set, a layout resize
14//! **clears** the surface (the pixmap is recreated transparent and the raster
15//! state resets); the core crate emits a `"resize"` UI event so the app — or
16//! the runtime's automatic replay of a declarative painter — redraws.
17//! Fill/stroke styles, line width, and the current path persist across
18//! drawing sessions until such a reset, mirroring `CanvasRenderingContext2D`.
19//!
20//! Rasterization is **CPU-side** (via `tiny-skia`), so it is fully decoupled
21//! from Bevy's render internals — the canvas is "an image we paint into",
22//! reusing the existing [`ImageNode`] plumbing. The rasterizer is isolated in
23//! `apply_cmds`; a future GPU backend (e.g. `bevy_vello`) could replace it
24//! without touching the protocol, the reconciler, or the JS side.
25
26mod color;
27pub use color::parse_css_color;
28
29use bevy::asset::RenderAssetUsages;
30use bevy::image::Image;
31use bevy::prelude::*;
32use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
33use bevy::ui::ComputedNode;
34use bevy::ui::widget::ImageNode;
35use serde::Deserialize;
36use tiny_skia::{BlendMode, Color, FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
37
38/// One vector drawing command in a `canvas` element's display list. Mirrors a
39/// subset of the HTML `CanvasRenderingContext2D` path API; coordinates are in
40/// logical (CSS) pixels matching the node's layout size, top-left origin — the
41/// rasterizer scales them to physical pixels by the device pixel ratio. Bevy-free,
42/// decoded on the Rust side and replayed into the rasterizer by
43/// [`update_canvas_surfaces`].
44#[derive(Debug, Clone, PartialEq, Deserialize)]
45#[serde(tag = "cmd", rename_all = "camelCase")]
46pub enum DrawCmd {
47    /// Start a fresh (empty) path, discarding the current one.
48    BeginPath,
49    /// Move the pen to `(x, y)`, beginning a new subpath.
50    MoveTo { x: f32, y: f32 },
51    /// Add a straight segment from the current point to `(x, y)`.
52    LineTo { x: f32, y: f32 },
53    /// Add a quadratic Bézier to `(x, y)` with control point `(cx, cy)`.
54    QuadTo { cx: f32, cy: f32, x: f32, y: f32 },
55    /// Add a cubic Bézier to `(x, y)` with controls `(c1x, c1y)`, `(c2x, c2y)`.
56    BezierTo {
57        c1x: f32,
58        c1y: f32,
59        c2x: f32,
60        c2y: f32,
61        x: f32,
62        y: f32,
63    },
64    /// Add a circular arc centered at `(x, y)`, radius `r`, from `start` to `end`
65    /// radians (clockwise). Approximated by short segments.
66    Arc {
67        x: f32,
68        y: f32,
69        r: f32,
70        start: f32,
71        end: f32,
72    },
73    /// Add an axis-aligned rectangle subpath.
74    Rect { x: f32, y: f32, w: f32, h: f32 },
75    /// Close the current subpath back to its start.
76    ClosePath,
77    /// Set the fill color (hex `#rgb` / `#rrggbb` / `#rrggbbaa`).
78    FillStyle { color: String },
79    /// Set the stroke color (hex, same forms as `FillStyle`).
80    StrokeStyle { color: String },
81    /// Set the stroke width in canvas pixels.
82    LineWidth { w: f32 },
83    /// Fill the current path with the current fill color.
84    Fill,
85    /// Stroke the current path with the current stroke color and line width.
86    Stroke,
87    /// Erase a rectangle back to transparent. Like the HTML `clearRect`, it
88    /// touches only pixels — path and style state stay intact.
89    ClearRect { x: f32, y: f32, w: f32, h: f32 },
90    /// Erase the whole surface back to transparent. A non-standard convenience:
91    /// JS may not know the laid-out size synchronously. Like [`ClearRect`],
92    /// leaves path and style state intact.
93    ///
94    /// [`ClearRect`]: DrawCmd::ClearRect
95    Clear,
96}
97
98/// Largest backing-texture dimension we allocate, in physical pixels. A guard
99/// against a degenerate layout asking for an enormous buffer.
100pub const MAX_DIM: u32 = 4096;
101
102/// Round + clamp a laid-out physical size (a `ComputedNode.size`) to the
103/// rasterizable range. A `0` component means "not laid out yet". Shared with
104/// the core crate's resize-event emitter so the size reported to JS always
105/// matches the actual buffer.
106pub fn clamp_physical_size(size: Vec2) -> (u32, u32) {
107    (
108        (size.x.round() as u32).min(MAX_DIM),
109        (size.y.round() as u32).min(MAX_DIM),
110    )
111}
112
113/// Drawing state that persists across drawing sessions — like the HTML canvas,
114/// where fill/stroke styles, line width, and the current path survive between
115/// calls until reset by a resize or a declarative replay.
116struct RasterState {
117    fill: [u8; 4],
118    stroke: [u8; 4],
119    line_width: f32,
120    path: PathBuilder,
121    has_point: bool,
122}
123
124impl Default for RasterState {
125    fn default() -> Self {
126        Self {
127            fill: [255, 255, 255, 255],
128            stroke: [0, 0, 0, 255],
129            line_width: 1.0,
130            path: PathBuilder::new(),
131            has_point: false,
132        }
133    }
134}
135
136/// The drawing state of a `canvas` element: a retained premultiplied pixel
137/// buffer, the persistent raster state, and the queue of commands recorded
138/// since the last paint. Paint **accumulates** — a batch draws on top of what
139/// is already there — except when `replace` is set (the declarative `draw`
140/// prop: clear + replay) or the laid-out size changes (clear-on-resize).
141#[derive(Component)]
142pub struct CanvasSurface {
143    /// Commands recorded since the last paint, not yet applied.
144    pending: Vec<DrawCmd>,
145    /// Clear the surface and reset raster state before draining `pending`.
146    replace: bool,
147    /// Fill/stroke/line-width and the current path, persisting across batches.
148    state: RasterState,
149    /// The retained pixels, premultiplied (tiny-skia native). `None` until the
150    /// node is first laid out.
151    pixmap: Option<Pixmap>,
152    /// Physical size of `pixmap`; a mismatch with the laid-out size recreates
153    /// it cleared (HTML width/height-set semantics).
154    last_size: (u32, u32),
155}
156
157impl CanvasSurface {
158    /// A fresh surface whose first paint clears and replays `cmds` (the
159    /// element's initial declarative `draw` prop; empty for imperative-only
160    /// canvases).
161    pub fn new(cmds: Vec<DrawCmd>) -> Self {
162        Self {
163            pending: cmds,
164            replace: true,
165            state: RasterState::default(),
166            pixmap: None,
167            last_size: (0, 0),
168        }
169    }
170
171    /// Append imperative commands (an `Op::Draw` from a canvas handle). Paint
172    /// accumulates on the retained pixels.
173    pub fn enqueue(&mut self, cmds: Vec<DrawCmd>) {
174        self.pending.extend(cmds);
175    }
176
177    /// Replace the picture with `cmds` (a changed declarative `draw` prop):
178    /// the next paint clears the surface, resets raster state, and replays.
179    /// Anything still pending is dropped — it would be erased anyway.
180    pub fn set_display_list(&mut self, cmds: Vec<DrawCmd>) {
181        self.pending = cmds;
182        self.replace = true;
183    }
184
185    /// Sync the surface to the laid-out physical size `(w, h)`: recreate the
186    /// pixmap on a size change (clear-on-resize), honor a pending replace,
187    /// drain queued commands. Returns the straight-alpha RGBA buffer when the
188    /// pixels changed (painted, cleared, or resized), else `None`. `scale` is
189    /// the device pixel ratio mapping logical draw coords onto the buffer.
190    pub(crate) fn sync(&mut self, w: u32, h: u32, scale: f32) -> Option<Vec<u8>> {
191        let resized = self.pixmap.is_none() || self.last_size != (w, h);
192        if resized {
193            // `w`/`h` are clamped to `1..=MAX_DIM` by the caller, so `new` holds.
194            self.pixmap = Some(Pixmap::new(w, h).expect("non-zero, bounded canvas size"));
195            self.state = RasterState::default();
196            self.last_size = (w, h);
197        }
198        let mut cleared = resized;
199        if self.replace {
200            self.replace = false;
201            if !resized {
202                self.pixmap.as_mut().unwrap().fill(Color::TRANSPARENT);
203            }
204            self.state = RasterState::default();
205            cleared = true;
206        }
207        if self.pending.is_empty() && !cleared {
208            return None;
209        }
210        let pixmap = self.pixmap.as_mut().unwrap();
211        let cmds = std::mem::take(&mut self.pending);
212        apply_cmds(pixmap, &mut self.state, &cmds, scale);
213        Some(to_straight_alpha(pixmap))
214    }
215}
216
217/// A 1×1 transparent image to back a freshly-spawned canvas until its first
218/// rasterization (which happens once the node has a laid-out size). Kept in both
219/// worlds so [`update_canvas_surfaces`] can mutate the CPU copy and have it
220/// re-upload.
221pub fn blank_canvas_image() -> Image {
222    Image::new_fill(
223        Extent3d {
224            width: 1,
225            height: 1,
226            depth_or_array_layers: 1,
227        },
228        TextureDimension::D2,
229        &[0, 0, 0, 0],
230        TextureFormat::Rgba8UnormSrgb,
231        RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
232    )
233}
234
235/// Paint every canvas with pending work (queued commands, a replace, or a
236/// layout resize — which clears, per HTML canvas semantics) and upload the
237/// result into the backing image. Reads the node's size from [`ComputedNode`]
238/// (already in physical pixels, so the result is crisp on HiDPI).
239pub fn update_canvas_surfaces(
240    mut images: ResMut<Assets<Image>>,
241    mut dirt: ResMut<crate::layer::LayerContentDirt>,
242    mut query: Query<(Entity, &ComputedNode, &ImageNode, &mut CanvasSurface)>,
243) {
244    for (entity, node, image_node, mut surface) in &mut query {
245        let (w, h) = clamp_physical_size(node.size);
246        if w == 0 || h == 0 {
247            continue; // not laid out yet; pending commands stay queued
248        }
249        // `contains` (not `get_mut`) so an idle canvas doesn't flag the asset
250        // changed — and thus re-uploaded — every frame.
251        if !images.contains(&image_node.image) {
252            continue;
253        }
254        // Draw commands are in logical (CSS) pixels matching the node's layout
255        // size; the texture is physical-pixel sized for HiDPI crispness, so scale
256        // the drawing up by the device pixel ratio (`1 / inverse_scale_factor`).
257        let scale = if node.inverse_scale_factor > 0.0 {
258            node.inverse_scale_factor.recip()
259        } else {
260            1.0
261        };
262        let Some(data) = surface.sync(w, h, scale) else {
263            continue;
264        };
265        // Real pixel upload → the owning layer's capture is stale. (An idle
266        // canvas returns `None` above and touches nothing.)
267        dirt.nodes.push(entity);
268        let Some(mut image) = images.get_mut(&image_node.image) else {
269            continue;
270        };
271        let extent = Extent3d {
272            width: w,
273            height: h,
274            depth_or_array_layers: 1,
275        };
276        if image.texture_descriptor.size != extent {
277            image.resize(extent);
278        }
279        image.data = Some(data);
280    }
281}
282
283/// Replay `cmds` onto the retained pixmap using the persistent raster state.
284/// Draw coordinates are logical pixels; `scale` (the device pixel ratio) maps
285/// them onto the physical-pixel buffer, so the drawing fills the texture and
286/// stays crisp on HiDPI. The sole rasterizer backend — swap the body to change
287/// engines.
288fn apply_cmds(pixmap: &mut Pixmap, state: &mut RasterState, cmds: &[DrawCmd], scale: f32) {
289    // Logical-pixel draw coords → physical-pixel buffer. Applied to every fill /
290    // stroke, so it scales geometry, stroke width, and arc radii uniformly.
291    let xf = Transform::from_scale(scale, scale);
292
293    for cmd in cmds {
294        match cmd {
295            DrawCmd::BeginPath => {
296                state.path = PathBuilder::new();
297                state.has_point = false;
298            }
299            DrawCmd::MoveTo { x, y } => {
300                state.path.move_to(*x, *y);
301                state.has_point = true;
302            }
303            DrawCmd::LineTo { x, y } => {
304                // A `lineTo` with no current point starts the subpath there,
305                // matching the HTML canvas behavior.
306                if state.has_point {
307                    state.path.line_to(*x, *y);
308                } else {
309                    state.path.move_to(*x, *y);
310                    state.has_point = true;
311                }
312            }
313            DrawCmd::QuadTo { cx, cy, x, y } => {
314                if state.has_point {
315                    state.path.quad_to(*cx, *cy, *x, *y);
316                }
317            }
318            DrawCmd::BezierTo {
319                c1x,
320                c1y,
321                c2x,
322                c2y,
323                x,
324                y,
325            } => {
326                if state.has_point {
327                    state.path.cubic_to(*c1x, *c1y, *c2x, *c2y, *x, *y);
328                }
329            }
330            DrawCmd::Arc {
331                x,
332                y,
333                r,
334                start,
335                end,
336            } => {
337                push_arc(
338                    &mut state.path,
339                    *x,
340                    *y,
341                    *r,
342                    *start,
343                    *end,
344                    &mut state.has_point,
345                );
346            }
347            DrawCmd::Rect { x, y, w, h } => {
348                if let Some(rect) = tiny_skia::Rect::from_xywh(*x, *y, *w, *h) {
349                    state.path.push_rect(rect);
350                }
351            }
352            DrawCmd::ClosePath => state.path.close(),
353            DrawCmd::FillStyle { color } => state.fill = parse_rgba8(color),
354            DrawCmd::StrokeStyle { color } => state.stroke = parse_rgba8(color),
355            DrawCmd::LineWidth { w } => {
356                // The HTML canvas ignores invalid widths (0, negative, NaN, ∞)
357                // and keeps the previous value; tiny-skia's stroker would
358                // reject them ("path stroking failed").
359                if w.is_finite() && *w > 0.0 {
360                    state.line_width = *w;
361                }
362            }
363            DrawCmd::Fill => {
364                if let Some(p) = state.path.clone().finish() {
365                    pixmap.fill_path(&p, &solid(state.fill), FillRule::Winding, xf, None);
366                }
367            }
368            DrawCmd::Stroke => {
369                if let Some(p) = state.path.clone().finish() {
370                    // A single-point path — e.g. a stationary drag's
371                    // `moveTo(p); lineTo(p)` — has an empty butt-cap outline:
372                    // tiny-skia's stroker returns `None` for it and warns
373                    // "path stroking failed". The web draws nothing too, so
374                    // skip it silently.
375                    let b = p.bounds();
376                    if b.width() > 0.0 || b.height() > 0.0 {
377                        let stroke_opts = Stroke {
378                            width: state.line_width,
379                            ..Default::default()
380                        };
381                        pixmap.stroke_path(&p, &solid(state.stroke), &stroke_opts, xf, None);
382                    }
383                }
384            }
385            DrawCmd::ClearRect { x, y, w, h } => {
386                if let Some(rect) = tiny_skia::Rect::from_xywh(*x, *y, *w, *h) {
387                    let paint = Paint {
388                        blend_mode: BlendMode::Clear,
389                        anti_alias: true,
390                        ..Default::default()
391                    };
392                    pixmap.fill_rect(rect, &paint, xf, None);
393                }
394            }
395            DrawCmd::Clear => pixmap.fill(Color::TRANSPARENT),
396        }
397    }
398}
399
400/// Copy the pixmap out as an RGBA8 (straight-alpha, sRGB) pixel buffer.
401/// tiny-skia stores premultiplied alpha; Bevy's UI shader expects straight
402/// alpha, so demultiply each pixel on the way out. Shared with `crate::svg`,
403/// whose resvg output is premultiplied the same way.
404pub(crate) fn to_straight_alpha(pixmap: &Pixmap) -> Vec<u8> {
405    let mut out = Vec::with_capacity((pixmap.width() * pixmap.height() * 4) as usize);
406    for px in pixmap.pixels() {
407        let c = px.demultiply();
408        out.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
409    }
410    out
411}
412
413/// An anti-aliased solid-color paint from straight-alpha RGBA bytes.
414fn solid(rgba: [u8; 4]) -> Paint<'static> {
415    let mut paint = Paint {
416        anti_alias: true,
417        ..Default::default()
418    };
419    paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
420    paint
421}
422
423/// Append a circular arc to `path` as short line segments. Mirrors the HTML
424/// canvas `arc`: if the path already has a point, a line is drawn to the arc's
425/// start; otherwise the arc's start becomes the subpath origin.
426fn push_arc(
427    path: &mut PathBuilder,
428    cx: f32,
429    cy: f32,
430    r: f32,
431    start: f32,
432    end: f32,
433    has_point: &mut bool,
434) {
435    // ~2° per segment, at least one — plenty smooth for typical chart radii.
436    let span = (end - start).abs();
437    let steps = ((span / (std::f32::consts::PI / 90.0)).ceil() as usize).max(1);
438    for i in 0..=steps {
439        let t = start + (end - start) * (i as f32 / steps as f32);
440        let (px, py) = (cx + r * t.cos(), cy + r * t.sin());
441        if i == 0 && !*has_point {
442            path.move_to(px, py);
443            *has_point = true;
444        } else {
445            path.line_to(px, py);
446            *has_point = true;
447        }
448    }
449}
450
451/// Parse a CSS color string (see [`parse_css_color`]) into straight-alpha RGBA
452/// bytes. Anything unparseable falls back to opaque black.
453fn parse_rgba8(s: &str) -> [u8; 4] {
454    let c = parse_css_color(s).unwrap_or(bevy::color::Srgba::new(0.0, 0.0, 0.0, 1.0));
455    [
456        (c.red.clamp(0.0, 1.0) * 255.0).round() as u8,
457        (c.green.clamp(0.0, 1.0) * 255.0).round() as u8,
458        (c.blue.clamp(0.0, 1.0) * 255.0).round() as u8,
459        (c.alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
460    ]
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    /// One-shot shim matching the old pure `rasterize` signature: a fresh
468    /// surface, one clear+replay paint.
469    fn rasterize(cmds: &[DrawCmd], width: u32, height: u32, scale: f32) -> Vec<u8> {
470        let mut s = CanvasSurface::new(cmds.to_vec());
471        s.sync(width, height, scale)
472            .expect("first sync always paints")
473    }
474
475    /// The RGBA bytes of pixel `(x, y)` in a `w`-wide buffer.
476    fn px(buf: &[u8], w: usize, x: usize, y: usize) -> &[u8] {
477        let i = (y * w + x) * 4;
478        &buf[i..i + 4]
479    }
480
481    fn fill_rect(color: &str, x: f32, y: f32, w: f32, h: f32) -> Vec<DrawCmd> {
482        vec![
483            DrawCmd::BeginPath,
484            DrawCmd::FillStyle {
485                color: color.into(),
486            },
487            DrawCmd::Rect { x, y, w, h },
488            DrawCmd::Fill,
489        ]
490    }
491
492    #[test]
493    fn parses_hex_colors() {
494        assert_eq!(parse_rgba8("#ff0000"), [255, 0, 0, 255]);
495        assert_eq!(parse_rgba8("#00ff0080"), [0, 255, 0, 128]);
496        assert_eq!(parse_rgba8("#f00"), [255, 0, 0, 255]);
497        assert_eq!(parse_rgba8("#0f08"), [0, 255, 0, 136]);
498        assert_eq!(parse_rgba8("garbage"), [0, 0, 0, 255]);
499    }
500
501    #[test]
502    fn rasterizes_a_filled_rect_opaquely() {
503        let buf = rasterize(&fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0), 4, 4, 1.0);
504        assert_eq!(buf.len(), 4 * 4 * 4);
505        // An interior pixel (x=1, y=1) is solid red.
506        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
507    }
508
509    #[test]
510    fn scale_maps_logical_coords_onto_the_physical_buffer() {
511        // A 2×2 logical rect at 2× scale fills a 4×4 physical buffer entirely.
512        let buf = rasterize(&fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0), 4, 4, 2.0);
513        // The far corner pixel (x=3, y=3) is covered — drawing scaled to fill.
514        assert_eq!(px(&buf, 4, 3, 3), &[255, 0, 0, 255]);
515    }
516
517    #[test]
518    fn paint_accumulates_across_batches() {
519        let mut s = CanvasSurface::new(vec![]);
520        s.enqueue(fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0));
521        s.sync(4, 4, 1.0).expect("painted");
522        s.enqueue(fill_rect("#0000ff", 2.0, 2.0, 2.0, 2.0));
523        let buf = s.sync(4, 4, 1.0).expect("painted");
524        // The first batch's red survives the second batch's blue.
525        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
526        assert_eq!(px(&buf, 4, 3, 3), &[0, 0, 255, 255]);
527    }
528
529    #[test]
530    fn style_and_path_state_persist_across_batches() {
531        let mut s = CanvasSurface::new(vec![]);
532        // Batch 1 only sets the fill color and builds a path — no paint yet.
533        s.enqueue(vec![
534            DrawCmd::FillStyle {
535                color: "#ff0000".into(),
536            },
537            DrawCmd::Rect {
538                x: 0.0,
539                y: 0.0,
540                w: 4.0,
541                h: 4.0,
542            },
543        ]);
544        s.sync(4, 4, 1.0);
545        // Batch 2 fills using the retained color and path.
546        s.enqueue(vec![DrawCmd::Fill]);
547        let buf = s.sync(4, 4, 1.0).expect("painted");
548        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
549    }
550
551    #[test]
552    fn clear_rect_erases_only_inside() {
553        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
554        s.sync(4, 4, 1.0);
555        s.enqueue(vec![DrawCmd::ClearRect {
556            x: 1.0,
557            y: 1.0,
558            w: 2.0,
559            h: 2.0,
560        }]);
561        let buf = s.sync(4, 4, 1.0).expect("painted");
562        assert_eq!(px(&buf, 4, 2, 2)[3], 0, "inside is transparent");
563        assert_eq!(px(&buf, 4, 0, 0), &[255, 0, 0, 255], "outside intact");
564    }
565
566    #[test]
567    fn clear_erases_the_whole_surface() {
568        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
569        s.sync(4, 4, 1.0);
570        s.enqueue(vec![DrawCmd::Clear]);
571        let buf = s.sync(4, 4, 1.0).expect("painted");
572        assert!(buf.iter().all(|&b| b == 0));
573    }
574
575    #[test]
576    fn resize_clears_pixels_and_resets_state() {
577        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
578        s.sync(4, 4, 1.0);
579        // The resize alone repaints (cleared), even with nothing pending.
580        let buf = s.sync(8, 8, 1.0).expect("resize repaints");
581        assert_eq!(buf.len(), 8 * 8 * 4);
582        assert!(buf.iter().all(|&b| b == 0), "cleared on resize");
583        // Raster state was reset: an unstyled fill uses the default (white).
584        s.enqueue(vec![
585            DrawCmd::Rect {
586                x: 0.0,
587                y: 0.0,
588                w: 8.0,
589                h: 8.0,
590            },
591            DrawCmd::Fill,
592        ]);
593        let buf = s.sync(8, 8, 1.0).expect("painted");
594        assert_eq!(px(&buf, 8, 4, 4), &[255, 255, 255, 255]);
595    }
596
597    #[test]
598    fn commands_enqueued_before_first_layout_paint_once_sized() {
599        let mut s = CanvasSurface::new(vec![]);
600        s.enqueue(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
601        // First sized sync (first layout) drains the queue.
602        let buf = s.sync(4, 4, 1.0).expect("painted");
603        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
604    }
605
606    #[test]
607    fn set_display_list_replaces_the_picture() {
608        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0));
609        s.sync(4, 4, 1.0);
610        s.set_display_list(fill_rect("#0000ff", 2.0, 2.0, 2.0, 2.0));
611        let buf = s.sync(4, 4, 1.0).expect("painted");
612        assert_eq!(px(&buf, 4, 1, 1)[3], 0, "old pixels cleared");
613        assert_eq!(px(&buf, 4, 3, 3), &[0, 0, 255, 255], "new pixels painted");
614    }
615
616    #[test]
617    fn degenerate_stroke_paints_nothing_without_failing() {
618        // A stationary drag records `moveTo(p); lineTo(p); stroke()` — a
619        // single-point path. tiny-skia's stroker rejects it (an empty
620        // butt-cap outline), so the rasterizer must skip it silently.
621        let mut s = CanvasSurface::new(vec![]);
622        s.enqueue(vec![
623            DrawCmd::BeginPath,
624            DrawCmd::MoveTo { x: 2.0, y: 2.0 },
625            DrawCmd::LineTo { x: 2.0, y: 2.0 },
626            DrawCmd::Stroke,
627        ]);
628        let buf = s.sync(4, 4, 1.0).expect("painted");
629        assert!(buf.iter().all(|&b| b == 0), "nothing stroked");
630    }
631
632    #[test]
633    fn invalid_line_width_is_ignored() {
634        // Web semantics: assigning 0 / negative / non-finite keeps the
635        // previous width (tiny-skia would reject the stroke outright).
636        let mut s = CanvasSurface::new(vec![]);
637        s.enqueue(vec![
638            DrawCmd::StrokeStyle {
639                color: "#ff0000".into(),
640            },
641            DrawCmd::LineWidth { w: 2.0 },
642            DrawCmd::LineWidth { w: 0.0 },
643            DrawCmd::LineWidth { w: -3.0 },
644            DrawCmd::LineWidth { w: f32::NAN },
645            DrawCmd::BeginPath,
646            DrawCmd::MoveTo { x: 0.0, y: 2.0 },
647            DrawCmd::LineTo { x: 4.0, y: 2.0 },
648            DrawCmd::Stroke,
649        ]);
650        let buf = s.sync(4, 4, 1.0).expect("painted");
651        // Width 2 (the last valid value) covers rows 1..3; row 1 is opaque red.
652        assert_eq!(px(&buf, 4, 2, 1), &[255, 0, 0, 255]);
653    }
654
655    #[test]
656    fn idle_sync_returns_none() {
657        let mut s = CanvasSurface::new(vec![]);
658        assert!(s.sync(4, 4, 1.0).is_some(), "first paint uploads the clear");
659        assert!(s.sync(4, 4, 1.0).is_none(), "nothing pending, no repaint");
660    }
661}