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}