mod color;
pub use color::parse_css_color;
use bevy::asset::RenderAssetUsages;
use bevy::image::Image;
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use bevy::ui::ComputedNode;
use bevy::ui::widget::ImageNode;
use serde::Deserialize;
use tiny_skia::{BlendMode, Color, FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "cmd", rename_all = "camelCase")]
pub enum DrawCmd {
BeginPath,
MoveTo { x: f32, y: f32 },
LineTo { x: f32, y: f32 },
QuadTo { cx: f32, cy: f32, x: f32, y: f32 },
BezierTo {
c1x: f32,
c1y: f32,
c2x: f32,
c2y: f32,
x: f32,
y: f32,
},
Arc {
x: f32,
y: f32,
r: f32,
start: f32,
end: f32,
},
Rect { x: f32, y: f32, w: f32, h: f32 },
ClosePath,
FillStyle { color: String },
StrokeStyle { color: String },
LineWidth { w: f32 },
Fill,
Stroke,
ClearRect { x: f32, y: f32, w: f32, h: f32 },
Clear,
}
pub const MAX_DIM: u32 = 4096;
pub fn clamp_physical_size(size: Vec2) -> (u32, u32) {
(
(size.x.round() as u32).min(MAX_DIM),
(size.y.round() as u32).min(MAX_DIM),
)
}
struct RasterState {
fill: [u8; 4],
stroke: [u8; 4],
line_width: f32,
path: PathBuilder,
has_point: bool,
}
impl Default for RasterState {
fn default() -> Self {
Self {
fill: [255, 255, 255, 255],
stroke: [0, 0, 0, 255],
line_width: 1.0,
path: PathBuilder::new(),
has_point: false,
}
}
}
#[derive(Component)]
pub struct CanvasSurface {
pending: Vec<DrawCmd>,
replace: bool,
state: RasterState,
pixmap: Option<Pixmap>,
last_size: (u32, u32),
}
impl CanvasSurface {
pub fn new(cmds: Vec<DrawCmd>) -> Self {
Self {
pending: cmds,
replace: true,
state: RasterState::default(),
pixmap: None,
last_size: (0, 0),
}
}
pub fn enqueue(&mut self, cmds: Vec<DrawCmd>) {
self.pending.extend(cmds);
}
pub fn set_display_list(&mut self, cmds: Vec<DrawCmd>) {
self.pending = cmds;
self.replace = true;
}
pub(crate) fn sync(&mut self, w: u32, h: u32, scale: f32) -> Option<Vec<u8>> {
let resized = self.pixmap.is_none() || self.last_size != (w, h);
if resized {
self.pixmap = Some(Pixmap::new(w, h).expect("non-zero, bounded canvas size"));
self.state = RasterState::default();
self.last_size = (w, h);
}
let mut cleared = resized;
if self.replace {
self.replace = false;
if !resized {
self.pixmap.as_mut().unwrap().fill(Color::TRANSPARENT);
}
self.state = RasterState::default();
cleared = true;
}
if self.pending.is_empty() && !cleared {
return None;
}
let pixmap = self.pixmap.as_mut().unwrap();
let cmds = std::mem::take(&mut self.pending);
apply_cmds(pixmap, &mut self.state, &cmds, scale);
Some(to_straight_alpha(pixmap))
}
}
pub fn blank_canvas_image() -> Image {
Image::new_fill(
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0, 0, 0, 0],
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
}
pub fn update_canvas_surfaces(
mut images: ResMut<Assets<Image>>,
mut query: Query<(&ComputedNode, &ImageNode, &mut CanvasSurface)>,
) {
for (node, image_node, mut surface) in &mut query {
let (w, h) = clamp_physical_size(node.size);
if w == 0 || h == 0 {
continue; }
if !images.contains(&image_node.image) {
continue;
}
let scale = if node.inverse_scale_factor > 0.0 {
node.inverse_scale_factor.recip()
} else {
1.0
};
let Some(data) = surface.sync(w, h, scale) else {
continue;
};
let Some(mut image) = images.get_mut(&image_node.image) else {
continue;
};
let extent = Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
};
if image.texture_descriptor.size != extent {
image.resize(extent);
}
image.data = Some(data);
}
}
fn apply_cmds(pixmap: &mut Pixmap, state: &mut RasterState, cmds: &[DrawCmd], scale: f32) {
let xf = Transform::from_scale(scale, scale);
for cmd in cmds {
match cmd {
DrawCmd::BeginPath => {
state.path = PathBuilder::new();
state.has_point = false;
}
DrawCmd::MoveTo { x, y } => {
state.path.move_to(*x, *y);
state.has_point = true;
}
DrawCmd::LineTo { x, y } => {
if state.has_point {
state.path.line_to(*x, *y);
} else {
state.path.move_to(*x, *y);
state.has_point = true;
}
}
DrawCmd::QuadTo { cx, cy, x, y } => {
if state.has_point {
state.path.quad_to(*cx, *cy, *x, *y);
}
}
DrawCmd::BezierTo {
c1x,
c1y,
c2x,
c2y,
x,
y,
} => {
if state.has_point {
state.path.cubic_to(*c1x, *c1y, *c2x, *c2y, *x, *y);
}
}
DrawCmd::Arc {
x,
y,
r,
start,
end,
} => {
push_arc(
&mut state.path,
*x,
*y,
*r,
*start,
*end,
&mut state.has_point,
);
}
DrawCmd::Rect { x, y, w, h } => {
if let Some(rect) = tiny_skia::Rect::from_xywh(*x, *y, *w, *h) {
state.path.push_rect(rect);
}
}
DrawCmd::ClosePath => state.path.close(),
DrawCmd::FillStyle { color } => state.fill = parse_rgba8(color),
DrawCmd::StrokeStyle { color } => state.stroke = parse_rgba8(color),
DrawCmd::LineWidth { w } => {
if w.is_finite() && *w > 0.0 {
state.line_width = *w;
}
}
DrawCmd::Fill => {
if let Some(p) = state.path.clone().finish() {
pixmap.fill_path(&p, &solid(state.fill), FillRule::Winding, xf, None);
}
}
DrawCmd::Stroke => {
if let Some(p) = state.path.clone().finish() {
let b = p.bounds();
if b.width() > 0.0 || b.height() > 0.0 {
let stroke_opts = Stroke {
width: state.line_width,
..Default::default()
};
pixmap.stroke_path(&p, &solid(state.stroke), &stroke_opts, xf, None);
}
}
}
DrawCmd::ClearRect { x, y, w, h } => {
if let Some(rect) = tiny_skia::Rect::from_xywh(*x, *y, *w, *h) {
let paint = Paint {
blend_mode: BlendMode::Clear,
anti_alias: true,
..Default::default()
};
pixmap.fill_rect(rect, &paint, xf, None);
}
}
DrawCmd::Clear => pixmap.fill(Color::TRANSPARENT),
}
}
}
fn to_straight_alpha(pixmap: &Pixmap) -> Vec<u8> {
let mut out = Vec::with_capacity((pixmap.width() * pixmap.height() * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
out.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
out
}
fn solid(rgba: [u8; 4]) -> Paint<'static> {
let mut paint = Paint {
anti_alias: true,
..Default::default()
};
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
paint
}
fn push_arc(
path: &mut PathBuilder,
cx: f32,
cy: f32,
r: f32,
start: f32,
end: f32,
has_point: &mut bool,
) {
let span = (end - start).abs();
let steps = ((span / (std::f32::consts::PI / 90.0)).ceil() as usize).max(1);
for i in 0..=steps {
let t = start + (end - start) * (i as f32 / steps as f32);
let (px, py) = (cx + r * t.cos(), cy + r * t.sin());
if i == 0 && !*has_point {
path.move_to(px, py);
*has_point = true;
} else {
path.line_to(px, py);
*has_point = true;
}
}
}
fn parse_rgba8(s: &str) -> [u8; 4] {
let c = parse_css_color(s).unwrap_or(bevy::color::Srgba::new(0.0, 0.0, 0.0, 1.0));
[
(c.red.clamp(0.0, 1.0) * 255.0).round() as u8,
(c.green.clamp(0.0, 1.0) * 255.0).round() as u8,
(c.blue.clamp(0.0, 1.0) * 255.0).round() as u8,
(c.alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
]
}
#[cfg(test)]
mod tests {
use super::*;
fn rasterize(cmds: &[DrawCmd], width: u32, height: u32, scale: f32) -> Vec<u8> {
let mut s = CanvasSurface::new(cmds.to_vec());
s.sync(width, height, scale)
.expect("first sync always paints")
}
fn px(buf: &[u8], w: usize, x: usize, y: usize) -> &[u8] {
let i = (y * w + x) * 4;
&buf[i..i + 4]
}
fn fill_rect(color: &str, x: f32, y: f32, w: f32, h: f32) -> Vec<DrawCmd> {
vec![
DrawCmd::BeginPath,
DrawCmd::FillStyle {
color: color.into(),
},
DrawCmd::Rect { x, y, w, h },
DrawCmd::Fill,
]
}
#[test]
fn parses_hex_colors() {
assert_eq!(parse_rgba8("#ff0000"), [255, 0, 0, 255]);
assert_eq!(parse_rgba8("#00ff0080"), [0, 255, 0, 128]);
assert_eq!(parse_rgba8("#f00"), [255, 0, 0, 255]);
assert_eq!(parse_rgba8("#0f08"), [0, 255, 0, 136]);
assert_eq!(parse_rgba8("garbage"), [0, 0, 0, 255]);
}
#[test]
fn rasterizes_a_filled_rect_opaquely() {
let buf = rasterize(&fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0), 4, 4, 1.0);
assert_eq!(buf.len(), 4 * 4 * 4);
assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
}
#[test]
fn scale_maps_logical_coords_onto_the_physical_buffer() {
let buf = rasterize(&fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0), 4, 4, 2.0);
assert_eq!(px(&buf, 4, 3, 3), &[255, 0, 0, 255]);
}
#[test]
fn paint_accumulates_across_batches() {
let mut s = CanvasSurface::new(vec![]);
s.enqueue(fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0));
s.sync(4, 4, 1.0).expect("painted");
s.enqueue(fill_rect("#0000ff", 2.0, 2.0, 2.0, 2.0));
let buf = s.sync(4, 4, 1.0).expect("painted");
assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
assert_eq!(px(&buf, 4, 3, 3), &[0, 0, 255, 255]);
}
#[test]
fn style_and_path_state_persist_across_batches() {
let mut s = CanvasSurface::new(vec![]);
s.enqueue(vec![
DrawCmd::FillStyle {
color: "#ff0000".into(),
},
DrawCmd::Rect {
x: 0.0,
y: 0.0,
w: 4.0,
h: 4.0,
},
]);
s.sync(4, 4, 1.0);
s.enqueue(vec![DrawCmd::Fill]);
let buf = s.sync(4, 4, 1.0).expect("painted");
assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
}
#[test]
fn clear_rect_erases_only_inside() {
let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
s.sync(4, 4, 1.0);
s.enqueue(vec![DrawCmd::ClearRect {
x: 1.0,
y: 1.0,
w: 2.0,
h: 2.0,
}]);
let buf = s.sync(4, 4, 1.0).expect("painted");
assert_eq!(px(&buf, 4, 2, 2)[3], 0, "inside is transparent");
assert_eq!(px(&buf, 4, 0, 0), &[255, 0, 0, 255], "outside intact");
}
#[test]
fn clear_erases_the_whole_surface() {
let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
s.sync(4, 4, 1.0);
s.enqueue(vec![DrawCmd::Clear]);
let buf = s.sync(4, 4, 1.0).expect("painted");
assert!(buf.iter().all(|&b| b == 0));
}
#[test]
fn resize_clears_pixels_and_resets_state() {
let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
s.sync(4, 4, 1.0);
let buf = s.sync(8, 8, 1.0).expect("resize repaints");
assert_eq!(buf.len(), 8 * 8 * 4);
assert!(buf.iter().all(|&b| b == 0), "cleared on resize");
s.enqueue(vec![
DrawCmd::Rect {
x: 0.0,
y: 0.0,
w: 8.0,
h: 8.0,
},
DrawCmd::Fill,
]);
let buf = s.sync(8, 8, 1.0).expect("painted");
assert_eq!(px(&buf, 8, 4, 4), &[255, 255, 255, 255]);
}
#[test]
fn commands_enqueued_before_first_layout_paint_once_sized() {
let mut s = CanvasSurface::new(vec![]);
s.enqueue(fill_rect("#ff0000", 0.0, 0.0, 4.0, 4.0));
let buf = s.sync(4, 4, 1.0).expect("painted");
assert_eq!(px(&buf, 4, 1, 1), &[255, 0, 0, 255]);
}
#[test]
fn set_display_list_replaces_the_picture() {
let mut s = CanvasSurface::new(fill_rect("#ff0000", 0.0, 0.0, 2.0, 2.0));
s.sync(4, 4, 1.0);
s.set_display_list(fill_rect("#0000ff", 2.0, 2.0, 2.0, 2.0));
let buf = s.sync(4, 4, 1.0).expect("painted");
assert_eq!(px(&buf, 4, 1, 1)[3], 0, "old pixels cleared");
assert_eq!(px(&buf, 4, 3, 3), &[0, 0, 255, 255], "new pixels painted");
}
#[test]
fn degenerate_stroke_paints_nothing_without_failing() {
let mut s = CanvasSurface::new(vec![]);
s.enqueue(vec![
DrawCmd::BeginPath,
DrawCmd::MoveTo { x: 2.0, y: 2.0 },
DrawCmd::LineTo { x: 2.0, y: 2.0 },
DrawCmd::Stroke,
]);
let buf = s.sync(4, 4, 1.0).expect("painted");
assert!(buf.iter().all(|&b| b == 0), "nothing stroked");
}
#[test]
fn invalid_line_width_is_ignored() {
let mut s = CanvasSurface::new(vec![]);
s.enqueue(vec![
DrawCmd::StrokeStyle {
color: "#ff0000".into(),
},
DrawCmd::LineWidth { w: 2.0 },
DrawCmd::LineWidth { w: 0.0 },
DrawCmd::LineWidth { w: -3.0 },
DrawCmd::LineWidth { w: f32::NAN },
DrawCmd::BeginPath,
DrawCmd::MoveTo { x: 0.0, y: 2.0 },
DrawCmd::LineTo { x: 4.0, y: 2.0 },
DrawCmd::Stroke,
]);
let buf = s.sync(4, 4, 1.0).expect("painted");
assert_eq!(px(&buf, 4, 2, 1), &[255, 0, 0, 255]);
}
#[test]
fn idle_sync_returns_none() {
let mut s = CanvasSurface::new(vec![]);
assert!(s.sync(4, 4, 1.0).is_some(), "first paint uploads the clear");
assert!(s.sync(4, 4, 1.0).is_none(), "nothing pending, no repaint");
}
}