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 query: Query<(&ComputedNode, &ImageNode, &mut CanvasSurface)>,
242) {
243    for (node, image_node, mut surface) in &mut query {
244        let (w, h) = clamp_physical_size(node.size);
245        if w == 0 || h == 0 {
246            continue; // not laid out yet; pending commands stay queued
247        }
248        // `contains` (not `get_mut`) so an idle canvas doesn't flag the asset
249        // changed — and thus re-uploaded — every frame.
250        if !images.contains(&image_node.image) {
251            continue;
252        }
253        // Draw commands are in logical (CSS) pixels matching the node's layout
254        // size; the texture is physical-pixel sized for HiDPI crispness, so scale
255        // the drawing up by the device pixel ratio (`1 / inverse_scale_factor`).
256        let scale = if node.inverse_scale_factor > 0.0 {
257            node.inverse_scale_factor.recip()
258        } else {
259            1.0
260        };
261        let Some(data) = surface.sync(w, h, scale) else {
262            continue;
263        };
264        let Some(mut image) = images.get_mut(&image_node.image) else {
265            continue;
266        };
267        let extent = Extent3d {
268            width: w,
269            height: h,
270            depth_or_array_layers: 1,
271        };
272        if image.texture_descriptor.size != extent {
273            image.resize(extent);
274        }
275        image.data = Some(data);
276    }
277}
278
279/// Replay `cmds` onto the retained pixmap using the persistent raster state.
280/// Draw coordinates are logical pixels; `scale` (the device pixel ratio) maps
281/// them onto the physical-pixel buffer, so the drawing fills the texture and
282/// stays crisp on HiDPI. The sole rasterizer backend — swap the body to change
283/// engines.
284fn apply_cmds(pixmap: &mut Pixmap, state: &mut RasterState, cmds: &[DrawCmd], scale: f32) {
285    // Logical-pixel draw coords → physical-pixel buffer. Applied to every fill /
286    // stroke, so it scales geometry, stroke width, and arc radii uniformly.
287    let xf = Transform::from_scale(scale, scale);
288
289    for cmd in cmds {
290        match cmd {
291            DrawCmd::BeginPath => {
292                state.path = PathBuilder::new();
293                state.has_point = false;
294            }
295            DrawCmd::MoveTo { x, y } => {
296                state.path.move_to(*x, *y);
297                state.has_point = true;
298            }
299            DrawCmd::LineTo { x, y } => {
300                // A `lineTo` with no current point starts the subpath there,
301                // matching the HTML canvas behavior.
302                if state.has_point {
303                    state.path.line_to(*x, *y);
304                } else {
305                    state.path.move_to(*x, *y);
306                    state.has_point = true;
307                }
308            }
309            DrawCmd::QuadTo { cx, cy, x, y } => {
310                if state.has_point {
311                    state.path.quad_to(*cx, *cy, *x, *y);
312                }
313            }
314            DrawCmd::BezierTo {
315                c1x,
316                c1y,
317                c2x,
318                c2y,
319                x,
320                y,
321            } => {
322                if state.has_point {
323                    state.path.cubic_to(*c1x, *c1y, *c2x, *c2y, *x, *y);
324                }
325            }
326            DrawCmd::Arc {
327                x,
328                y,
329                r,
330                start,
331                end,
332            } => {
333                push_arc(
334                    &mut state.path,
335                    *x,
336                    *y,
337                    *r,
338                    *start,
339                    *end,
340                    &mut state.has_point,
341                );
342            }
343            DrawCmd::Rect { x, y, w, h } => {
344                if let Some(rect) = tiny_skia::Rect::from_xywh(*x, *y, *w, *h) {
345                    state.path.push_rect(rect);
346                }
347            }
348            DrawCmd::ClosePath => state.path.close(),
349            DrawCmd::FillStyle { color } => state.fill = parse_rgba8(color),
350            DrawCmd::StrokeStyle { color } => state.stroke = parse_rgba8(color),
351            DrawCmd::LineWidth { w } => {
352                // The HTML canvas ignores invalid widths (0, negative, NaN, ∞)
353                // and keeps the previous value; tiny-skia's stroker would
354                // reject them ("path stroking failed").
355                if w.is_finite() && *w > 0.0 {
356                    state.line_width = *w;
357                }
358            }
359            DrawCmd::Fill => {
360                if let Some(p) = state.path.clone().finish() {
361                    pixmap.fill_path(&p, &solid(state.fill), FillRule::Winding, xf, None);
362                }
363            }
364            DrawCmd::Stroke => {
365                if let Some(p) = state.path.clone().finish() {
366                    // A single-point path — e.g. a stationary drag's
367                    // `moveTo(p); lineTo(p)` — has an empty butt-cap outline:
368                    // tiny-skia's stroker returns `None` for it and warns
369                    // "path stroking failed". The web draws nothing too, so
370                    // skip it silently.
371                    let b = p.bounds();
372                    if b.width() > 0.0 || b.height() > 0.0 {
373                        let stroke_opts = Stroke {
374                            width: state.line_width,
375                            ..Default::default()
376                        };
377                        pixmap.stroke_path(&p, &solid(state.stroke), &stroke_opts, xf, None);
378                    }
379                }
380            }
381            DrawCmd::ClearRect { x, y, w, h } => {
382                if let Some(rect) = tiny_skia::Rect::from_xywh(*x, *y, *w, *h) {
383                    let paint = Paint {
384                        blend_mode: BlendMode::Clear,
385                        anti_alias: true,
386                        ..Default::default()
387                    };
388                    pixmap.fill_rect(rect, &paint, xf, None);
389                }
390            }
391            DrawCmd::Clear => pixmap.fill(Color::TRANSPARENT),
392        }
393    }
394}
395
396/// Copy the pixmap out as an RGBA8 (straight-alpha, sRGB) pixel buffer.
397/// tiny-skia stores premultiplied alpha; Bevy's UI shader expects straight
398/// alpha, so demultiply each pixel on the way out.
399fn to_straight_alpha(pixmap: &Pixmap) -> Vec<u8> {
400    let mut out = Vec::with_capacity((pixmap.width() * pixmap.height() * 4) as usize);
401    for px in pixmap.pixels() {
402        let c = px.demultiply();
403        out.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
404    }
405    out
406}
407
408/// An anti-aliased solid-color paint from straight-alpha RGBA bytes.
409fn solid(rgba: [u8; 4]) -> Paint<'static> {
410    let mut paint = Paint {
411        anti_alias: true,
412        ..Default::default()
413    };
414    paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
415    paint
416}
417
418/// Append a circular arc to `path` as short line segments. Mirrors the HTML
419/// canvas `arc`: if the path already has a point, a line is drawn to the arc's
420/// start; otherwise the arc's start becomes the subpath origin.
421fn push_arc(
422    path: &mut PathBuilder,
423    cx: f32,
424    cy: f32,
425    r: f32,
426    start: f32,
427    end: f32,
428    has_point: &mut bool,
429) {
430    // ~2° per segment, at least one — plenty smooth for typical chart radii.
431    let span = (end - start).abs();
432    let steps = ((span / (std::f32::consts::PI / 90.0)).ceil() as usize).max(1);
433    for i in 0..=steps {
434        let t = start + (end - start) * (i as f32 / steps as f32);
435        let (px, py) = (cx + r * t.cos(), cy + r * t.sin());
436        if i == 0 && !*has_point {
437            path.move_to(px, py);
438            *has_point = true;
439        } else {
440            path.line_to(px, py);
441            *has_point = true;
442        }
443    }
444}
445
446/// Parse a CSS color string (see [`parse_css_color`]) into straight-alpha RGBA
447/// bytes. Anything unparseable falls back to opaque black.
448fn parse_rgba8(s: &str) -> [u8; 4] {
449    let c = parse_css_color(s).unwrap_or(bevy::color::Srgba::new(0.0, 0.0, 0.0, 1.0));
450    [
451        (c.red.clamp(0.0, 1.0) * 255.0).round() as u8,
452        (c.green.clamp(0.0, 1.0) * 255.0).round() as u8,
453        (c.blue.clamp(0.0, 1.0) * 255.0).round() as u8,
454        (c.alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
455    ]
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    /// One-shot shim matching the old pure `rasterize` signature: a fresh
463    /// surface, one clear+replay paint.
464    fn rasterize(cmds: &[DrawCmd], width: u32, height: u32, scale: f32) -> Vec<u8> {
465        let mut s = CanvasSurface::new(cmds.to_vec());
466        s.sync(width, height, scale)
467            .expect("first sync always paints")
468    }
469
470    /// The RGBA bytes of pixel `(x, y)` in a `w`-wide buffer.
471    fn px(buf: &[u8], w: usize, x: usize, y: usize) -> &[u8] {
472        let i = (y * w + x) * 4;
473        &buf[i..i + 4]
474    }
475
476    fn fill_rect(color: &str, x: f32, y: f32, w: f32, h: f32) -> Vec<DrawCmd> {
477        vec![
478            DrawCmd::BeginPath,
479            DrawCmd::FillStyle {
480                color: color.into(),
481            },
482            DrawCmd::Rect { x, y, w, h },
483            DrawCmd::Fill,
484        ]
485    }
486
487    #[test]
488    fn parses_hex_colors() {
489        assert_eq!(parse_rgba8("#ff0000"), [255, 0, 0, 255]);
490        assert_eq!(parse_rgba8("#00ff0080"), [0, 255, 0, 128]);
491        assert_eq!(parse_rgba8("#f00"), [255, 0, 0, 255]);
492        assert_eq!(parse_rgba8("#0f08"), [0, 255, 0, 136]);
493        assert_eq!(parse_rgba8("garbage"), [0, 0, 0, 255]);
494    }
495
496    #[test]
497    fn rasterizes_a_filled_rect_opaquely() {
498        let buf = rasterize(&fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0), 4, 4, 1.0);
499        assert_eq!(buf.len(), 4 * 4 * 4);
500        // An interior pixel (x=1, y=1) is solid red.
501        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
502    }
503
504    #[test]
505    fn scale_maps_logical_coords_onto_the_physical_buffer() {
506        // A 2×2 logical rect at 2× scale fills a 4×4 physical buffer entirely.
507        let buf = rasterize(&fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0), 4, 4, 2.0);
508        // The far corner pixel (x=3, y=3) is covered — drawing scaled to fill.
509        assert_eq!(px(&buf, 4, 3, 3), &[255, 0, 0, 255]);
510    }
511
512    #[test]
513    fn paint_accumulates_across_batches() {
514        let mut s = CanvasSurface::new(vec![]);
515        s.enqueue(fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0));
516        s.sync(4, 4, 1.0).expect("painted");
517        s.enqueue(fill_rect("#0000ff", 2.0, 2.0, 2.0, 2.0));
518        let buf = s.sync(4, 4, 1.0).expect("painted");
519        // The first batch's red survives the second batch's blue.
520        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
521        assert_eq!(px(&buf, 4, 3, 3), &[0, 0, 255, 255]);
522    }
523
524    #[test]
525    fn style_and_path_state_persist_across_batches() {
526        let mut s = CanvasSurface::new(vec![]);
527        // Batch 1 only sets the fill color and builds a path — no paint yet.
528        s.enqueue(vec![
529            DrawCmd::FillStyle {
530                color: "#ff0000".into(),
531            },
532            DrawCmd::Rect {
533                x: 0.0,
534                y: 0.0,
535                w: 4.0,
536                h: 4.0,
537            },
538        ]);
539        s.sync(4, 4, 1.0);
540        // Batch 2 fills using the retained color and path.
541        s.enqueue(vec![DrawCmd::Fill]);
542        let buf = s.sync(4, 4, 1.0).expect("painted");
543        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
544    }
545
546    #[test]
547    fn clear_rect_erases_only_inside() {
548        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
549        s.sync(4, 4, 1.0);
550        s.enqueue(vec![DrawCmd::ClearRect {
551            x: 1.0,
552            y: 1.0,
553            w: 2.0,
554            h: 2.0,
555        }]);
556        let buf = s.sync(4, 4, 1.0).expect("painted");
557        assert_eq!(px(&buf, 4, 2, 2)[3], 0, "inside is transparent");
558        assert_eq!(px(&buf, 4, 0, 0), &[255, 0, 0, 255], "outside intact");
559    }
560
561    #[test]
562    fn clear_erases_the_whole_surface() {
563        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
564        s.sync(4, 4, 1.0);
565        s.enqueue(vec![DrawCmd::Clear]);
566        let buf = s.sync(4, 4, 1.0).expect("painted");
567        assert!(buf.iter().all(|&b| b == 0));
568    }
569
570    #[test]
571    fn resize_clears_pixels_and_resets_state() {
572        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
573        s.sync(4, 4, 1.0);
574        // The resize alone repaints (cleared), even with nothing pending.
575        let buf = s.sync(8, 8, 1.0).expect("resize repaints");
576        assert_eq!(buf.len(), 8 * 8 * 4);
577        assert!(buf.iter().all(|&b| b == 0), "cleared on resize");
578        // Raster state was reset: an unstyled fill uses the default (white).
579        s.enqueue(vec![
580            DrawCmd::Rect {
581                x: 0.0,
582                y: 0.0,
583                w: 8.0,
584                h: 8.0,
585            },
586            DrawCmd::Fill,
587        ]);
588        let buf = s.sync(8, 8, 1.0).expect("painted");
589        assert_eq!(px(&buf, 8, 4, 4), &[255, 255, 255, 255]);
590    }
591
592    #[test]
593    fn commands_enqueued_before_first_layout_paint_once_sized() {
594        let mut s = CanvasSurface::new(vec![]);
595        s.enqueue(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
596        // First sized sync (first layout) drains the queue.
597        let buf = s.sync(4, 4, 1.0).expect("painted");
598        assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
599    }
600
601    #[test]
602    fn set_display_list_replaces_the_picture() {
603        let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0));
604        s.sync(4, 4, 1.0);
605        s.set_display_list(fill_rect("#0000ff", 2.0, 2.0, 2.0, 2.0));
606        let buf = s.sync(4, 4, 1.0).expect("painted");
607        assert_eq!(px(&buf, 4, 1, 1)[3], 0, "old pixels cleared");
608        assert_eq!(px(&buf, 4, 3, 3), &[0, 0, 255, 255], "new pixels painted");
609    }
610
611    #[test]
612    fn degenerate_stroke_paints_nothing_without_failing() {
613        // A stationary drag records `moveTo(p); lineTo(p); stroke()` — a
614        // single-point path. tiny-skia's stroker rejects it (an empty
615        // butt-cap outline), so the rasterizer must skip it silently.
616        let mut s = CanvasSurface::new(vec![]);
617        s.enqueue(vec![
618            DrawCmd::BeginPath,
619            DrawCmd::MoveTo { x: 2.0, y: 2.0 },
620            DrawCmd::LineTo { x: 2.0, y: 2.0 },
621            DrawCmd::Stroke,
622        ]);
623        let buf = s.sync(4, 4, 1.0).expect("painted");
624        assert!(buf.iter().all(|&b| b == 0), "nothing stroked");
625    }
626
627    #[test]
628    fn invalid_line_width_is_ignored() {
629        // Web semantics: assigning 0 / negative / non-finite keeps the
630        // previous width (tiny-skia would reject the stroke outright).
631        let mut s = CanvasSurface::new(vec![]);
632        s.enqueue(vec![
633            DrawCmd::StrokeStyle {
634                color: "#ff0000".into(),
635            },
636            DrawCmd::LineWidth { w: 2.0 },
637            DrawCmd::LineWidth { w: 0.0 },
638            DrawCmd::LineWidth { w: -3.0 },
639            DrawCmd::LineWidth { w: f32::NAN },
640            DrawCmd::BeginPath,
641            DrawCmd::MoveTo { x: 0.0, y: 2.0 },
642            DrawCmd::LineTo { x: 4.0, y: 2.0 },
643            DrawCmd::Stroke,
644        ]);
645        let buf = s.sync(4, 4, 1.0).expect("painted");
646        // Width 2 (the last valid value) covers rows 1..3; row 1 is opaque red.
647        assert_eq!(px(&buf, 4, 2, 1), &[255, 0, 0, 255]);
648    }
649
650    #[test]
651    fn idle_sync_returns_none() {
652        let mut s = CanvasSurface::new(vec![]);
653        assert!(s.sync(4, 4, 1.0).is_some(), "first paint uploads the clear");
654        assert!(s.sync(4, 4, 1.0).is_none(), "nothing pending, no repaint");
655    }
656}