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}