pub mod state;
pub mod path;
mod conversions;
pub mod gradient;
pub mod image;
pub mod text;
pub use path::Path;
pub use state::{LineCap, LineJoin};
pub use state::FillRule;
pub use gradient::{ConicGradient, LinearGradient, RadialGradient};
pub use image::{CanvasImage, ImageError};
#[cfg(feature = "canvas")]
pub use text::{FontSpec, FontStyle, FontWeight, TextMetrics};
use alloc::boxed::Box;
use waterui_core::layout::{Point, Rect, Size};
use vello::{kurbo, peniko};
use crate::canvas::conversions::{point_to_kurbo, rect_to_kurbo, resolved_color_to_peniko};
use crate::canvas::state::{DrawingState, FillStyle, StrokeStyle};
use crate::{GpuContext, GpuFrame, GpuRenderer, GpuSurface};
pub struct Canvas {
draw_fn: Box<dyn FnMut(&mut DrawingContext) + Send>,
}
impl core::fmt::Debug for Canvas {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Canvas").finish_non_exhaustive()
}
}
impl Canvas {
#[must_use]
pub fn new<F>(draw: F) -> Self
where
F: FnMut(&mut DrawingContext) + Send + 'static,
{
Self {
draw_fn: Box::new(draw),
}
}
}
impl waterui_core::View for Canvas {
fn body(self, _env: &waterui_core::Environment) -> impl waterui_core::View {
GpuSurface::new(CanvasRenderer::new(self.draw_fn))
}
}
pub struct DrawingContext<'a> {
scene: &'a mut vello::Scene,
pub width: f32,
pub height: f32,
state_stack: Vec<DrawingState>,
current_state: DrawingState,
}
impl core::fmt::Debug for DrawingContext<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("DrawingContext")
.field("width", &self.width)
.field("height", &self.height)
.finish_non_exhaustive()
}
}
impl DrawingContext<'_> {
#[must_use]
pub const fn size(&self) -> Size {
Size::new(self.width, self.height)
}
#[must_use]
pub fn center(&self) -> Point {
Point::new(self.width / 2.0, self.height / 2.0)
}
pub fn push_clip_rect(&mut self, rect: Rect) {
let kurbo_rect = rect_to_kurbo(rect);
self.scene
.push_clip_layer(self.current_state.transform, &kurbo_rect);
}
pub fn push_clip_path(&mut self, path: &Path) {
self.scene
.push_clip_layer(self.current_state.transform, path.inner());
}
pub fn push_alpha_rect(&mut self, alpha: f32, rect: Rect) {
let kurbo_rect = rect_to_kurbo(rect);
self.scene.push_layer(
self.current_state.blend_mode,
alpha.clamp(0.0, 1.0),
self.current_state.transform,
&kurbo_rect,
);
}
pub fn push_alpha_path(&mut self, alpha: f32, path: &Path) {
self.scene.push_layer(
self.current_state.blend_mode,
alpha.clamp(0.0, 1.0),
self.current_state.transform,
path.inner(),
);
}
pub fn pop_layer(&mut self) {
self.scene.pop_layer();
}
pub fn save(&mut self) {
self.state_stack.push(self.current_state.clone());
}
pub fn restore(&mut self) {
if let Some(state) = self.state_stack.pop() {
self.current_state = state;
}
}
pub fn translate(&mut self, x: f32, y: f32) {
let translation = kurbo::Affine::translate((f64::from(x), f64::from(y)));
self.current_state.transform *= translation;
}
pub fn rotate(&mut self, angle: f32) {
let rotation = kurbo::Affine::rotate(f64::from(angle));
self.current_state.transform *= rotation;
}
pub fn scale(&mut self, x: f32, y: f32) {
let scale = kurbo::Affine::scale_non_uniform(f64::from(x), f64::from(y));
self.current_state.transform *= scale;
}
#[allow(clippy::many_single_char_names)]
pub fn transform(&mut self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) {
let affine = kurbo::Affine::new([
f64::from(a),
f64::from(b),
f64::from(c),
f64::from(d),
f64::from(e),
f64::from(f),
]);
self.current_state.transform *= affine;
}
#[allow(clippy::many_single_char_names)]
pub fn set_transform(&mut self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) {
self.current_state.transform = kurbo::Affine::new([
f64::from(a),
f64::from(b),
f64::from(c),
f64::from(d),
f64::from(e),
f64::from(f),
]);
}
pub const fn reset_transform(&mut self) {
self.current_state.transform = kurbo::Affine::IDENTITY;
}
#[must_use]
pub fn begin_path(&self) -> Path {
Path::new()
}
pub fn fill_path(&mut self, path: &Path) {
let brush = self.resolve_fill_style();
self.scene.fill(
self.current_state.fill_rule,
self.current_state.transform,
&brush,
None,
path.inner(),
);
}
pub fn stroke_path(&mut self, path: &Path) {
let brush = self.resolve_stroke_style();
let stroke = self.current_state.build_stroke();
self.scene.stroke(
&stroke,
self.current_state.transform,
&brush,
None,
path.inner(),
);
}
pub fn fill_rect(&mut self, rect: Rect) {
let kurbo_rect = rect_to_kurbo(rect);
let brush = self.resolve_fill_style();
self.scene.fill(
self.current_state.fill_rule,
self.current_state.transform,
&brush,
None,
&kurbo_rect,
);
}
pub fn stroke_rect(&mut self, rect: Rect) {
let kurbo_rect = rect_to_kurbo(rect);
let brush = self.resolve_stroke_style();
let stroke = self.current_state.build_stroke();
self.scene.stroke(
&stroke,
self.current_state.transform,
&brush,
None,
&kurbo_rect,
);
}
pub fn clear_rect(&mut self, rect: Rect) {
let kurbo_rect = rect_to_kurbo(rect);
let transparent = peniko::Color::TRANSPARENT;
self.scene.fill(
self.current_state.fill_rule,
self.current_state.transform,
transparent,
None,
&kurbo_rect,
);
}
pub fn fill_circle(&mut self, center: Point, radius: f32) {
let brush = self.resolve_fill_style();
let circle = kurbo::Circle::new(point_to_kurbo(center), f64::from(radius));
self.scene.fill(
self.current_state.fill_rule,
self.current_state.transform,
&brush,
None,
&circle,
);
}
pub fn stroke_circle(&mut self, center: Point, radius: f32) {
let brush = self.resolve_stroke_style();
let stroke = self.current_state.build_stroke();
let circle = kurbo::Circle::new(point_to_kurbo(center), f64::from(radius));
self.scene
.stroke(&stroke, self.current_state.transform, &brush, None, &circle);
}
pub fn stroke_line(&mut self, start: Point, end: Point) {
let brush = self.resolve_stroke_style();
let stroke = self.current_state.build_stroke();
let line = kurbo::Line::new(point_to_kurbo(start), point_to_kurbo(end));
self.scene
.stroke(&stroke, self.current_state.transform, &brush, None, &line);
}
pub fn set_fill_style(&mut self, style: impl Into<FillStyle>) {
self.current_state.fill_style = style.into();
}
pub fn set_stroke_style(&mut self, style: impl Into<StrokeStyle>) {
self.current_state.stroke_style = style.into();
}
pub const fn set_line_width(&mut self, width: f32) {
self.current_state.line_width = width;
}
pub const fn set_line_cap(&mut self, cap: LineCap) {
self.current_state.line_cap = cap;
}
pub const fn set_line_join(&mut self, join: LineJoin) {
self.current_state.line_join = join;
}
pub const fn set_miter_limit(&mut self, limit: f32) {
self.current_state.miter_limit = limit;
}
pub fn set_line_dash(&mut self, segments: Vec<f32>) {
self.current_state.line_dash = segments;
}
pub const fn set_line_dash_offset(&mut self, offset: f32) {
self.current_state.line_dash_offset = offset;
}
pub const fn set_global_alpha(&mut self, alpha: f32) {
self.current_state.global_alpha = alpha.clamp(0.0, 1.0);
}
pub const fn set_shadow_blur(&mut self, blur: f32) {
self.current_state.shadow_blur = blur.max(0.0);
}
pub fn set_shadow_color(&mut self, color: impl Into<waterui_color::ResolvedColor>) {
self.current_state.shadow_color = color.into();
}
pub const fn set_shadow_offset(&mut self, x: f32, y: f32) {
self.current_state.shadow_offset_x = x;
self.current_state.shadow_offset_y = y;
}
pub const fn set_fill_rule(&mut self, rule: FillRule) {
self.current_state.fill_rule = rule.to_peniko();
}
#[must_use]
pub const fn create_linear_gradient(
&self,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
) -> LinearGradient {
LinearGradient::new(x0, y0, x1, y1)
}
#[must_use]
pub const fn create_radial_gradient(
&self,
x0: f32,
y0: f32,
r0: f32,
x1: f32,
y1: f32,
r1: f32,
) -> RadialGradient {
RadialGradient::new(x0, y0, r0, x1, y1, r1)
}
#[must_use]
pub const fn create_conic_gradient(&self, start_angle: f32, x: f32, y: f32) -> ConicGradient {
ConicGradient::new(start_angle, x, y)
}
pub fn draw_image(&mut self, image: &CanvasImage, pos: Point) {
let size = image.size();
let dest_rect = Rect::new(pos, size);
self.draw_image_scaled(image, dest_rect);
}
pub fn draw_image_scaled(&mut self, image: &CanvasImage, dest: Rect) {
let scale_x = f64::from(dest.size().width) / f64::from(image.width());
let scale_y = f64::from(dest.size().height) / f64::from(image.height());
let image_transform =
kurbo::Affine::translate((f64::from(dest.origin().x), f64::from(dest.origin().y)))
* kurbo::Affine::scale_non_uniform(scale_x, scale_y);
let final_transform = self.current_state.transform * image_transform;
let image_brush = peniko::ImageBrush::new(image.inner().clone());
self.scene.draw_image(&image_brush, final_transform);
}
pub fn draw_image_sub(&mut self, image: &CanvasImage, src: Rect, dest: Rect) {
let src_offset =
kurbo::Affine::translate((-f64::from(src.origin().x), -f64::from(src.origin().y)));
let scale_x = f64::from(dest.size().width) / f64::from(src.size().width);
let scale_y = f64::from(dest.size().height) / f64::from(src.size().height);
let scale = kurbo::Affine::scale_non_uniform(scale_x, scale_y);
let dest_offset =
kurbo::Affine::translate((f64::from(dest.origin().x), f64::from(dest.origin().y)));
let image_transform = src_offset * scale * dest_offset;
let final_transform = self.current_state.transform * image_transform;
let clip_rect = rect_to_kurbo(dest);
self.scene
.push_clip_layer(self.current_state.transform, &clip_rect);
let image_brush = peniko::ImageBrush::new(image.inner().clone());
self.scene.draw_image(&image_brush, final_transform);
self.scene.pop_layer();
}
pub fn set_font(&mut self, font: FontSpec) {
self.current_state.font = font;
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn measure_text(&self, text: &str) -> TextMetrics {
let char_count = text.chars().count() as f32;
let font_size = self.current_state.font.size;
let width = char_count * font_size * 0.6;
let height = font_size;
TextMetrics::new(width, height)
}
pub fn fill_text(&mut self, _text: &str, _pos: Point) {
tracing::warn!("fill_text is not yet fully implemented - requires Parley integration");
}
pub fn stroke_text(&mut self, _text: &str, _pos: Point) {
tracing::warn!("stroke_text is not yet fully implemented - requires skrifa integration");
}
fn resolve_fill_style(&self) -> peniko::Brush {
match &self.current_state.fill_style {
FillStyle::Color(color) => {
let peniko_color = resolved_color_to_peniko(*color);
peniko_color.into()
}
FillStyle::LinearGradient(gradient) => gradient.build(),
FillStyle::RadialGradient(gradient) => gradient.build(),
FillStyle::ConicGradient(gradient) => gradient.build(),
}
}
fn resolve_stroke_style(&self) -> peniko::Brush {
match &self.current_state.stroke_style {
StrokeStyle::Color(color) => {
let peniko_color = resolved_color_to_peniko(*color);
peniko_color.into()
}
StrokeStyle::LinearGradient(gradient) => gradient.build(),
StrokeStyle::RadialGradient(gradient) => gradient.build(),
StrokeStyle::ConicGradient(gradient) => gradient.build(),
}
}
}
struct CanvasRenderer {
draw_fn: Box<dyn FnMut(&mut DrawingContext) + Send>,
scene: vello::Scene,
renderer: Option<vello::Renderer>,
intermediate_texture: Option<wgpu::Texture>,
intermediate_view: Option<wgpu::TextureView>,
blit_pipeline: Option<wgpu::RenderPipeline>,
blit_bind_group_layout: Option<wgpu::BindGroupLayout>,
blit_sampler: Option<wgpu::Sampler>,
intermediate_size: (u32, u32),
}
impl CanvasRenderer {
fn new(draw_fn: Box<dyn FnMut(&mut DrawingContext) + Send>) -> Self {
Self {
draw_fn,
scene: vello::Scene::new(),
renderer: None,
intermediate_texture: None,
intermediate_view: None,
blit_pipeline: None,
blit_bind_group_layout: None,
blit_sampler: None,
intermediate_size: (0, 0),
}
}
}
impl GpuRenderer for CanvasRenderer {
fn setup(&mut self, ctx: &GpuContext) {
let renderer = vello::Renderer::new(
ctx.device,
vello::RendererOptions {
use_cpu: false,
antialiasing_support: vello::AaSupport::area_only(),
num_init_threads: std::num::NonZeroUsize::new(1), pipeline_cache: None,
},
)
.expect("Failed to create Vello renderer");
self.renderer = Some(renderer);
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Canvas Blit Shader"),
source: wgpu::ShaderSource::Wgsl(BLIT_SHADER.into()),
});
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Canvas Blit Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Canvas Blit Pipeline Layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = ctx
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Canvas Blit Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: ctx.surface_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = ctx.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Canvas Blit Sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
self.blit_pipeline = Some(pipeline);
self.blit_bind_group_layout = Some(bind_group_layout);
self.blit_sampler = Some(sampler);
}
fn resize(&mut self, width: u32, height: u32) {
self.intermediate_size = (0, 0);
let _ = (width, height);
}
#[allow(clippy::too_many_lines)]
fn render(&mut self, frame: &GpuFrame) {
let Some(renderer) = &mut self.renderer else {
return;
};
if self.intermediate_size != (frame.width, frame.height) {
let texture = frame.device.create_texture(&wgpu::TextureDescriptor {
label: Some("Canvas Intermediate Texture"),
size: wgpu::Extent3d {
width: frame.width,
height: frame.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.intermediate_texture = Some(texture);
self.intermediate_view = Some(view);
self.intermediate_size = (frame.width, frame.height);
}
let Some(intermediate_view) = &self.intermediate_view else {
return;
};
self.scene.reset();
#[allow(clippy::cast_precision_loss)]
let mut ctx = DrawingContext {
scene: &mut self.scene,
width: frame.width as f32,
height: frame.height as f32,
state_stack: Vec::new(),
current_state: DrawingState::new(),
};
(self.draw_fn)(&mut ctx);
let params = vello::RenderParams {
base_color: peniko::Color::TRANSPARENT,
width: frame.width,
height: frame.height,
antialiasing_method: vello::AaConfig::Area,
};
renderer
.render_to_texture(
frame.device,
frame.queue,
&self.scene,
intermediate_view,
¶ms,
)
.expect("Failed to render Vello scene");
let Some(pipeline) = &self.blit_pipeline else {
return;
};
let Some(bind_group_layout) = &self.blit_bind_group_layout else {
return;
};
let Some(sampler) = &self.blit_sampler else {
return;
};
let bind_group = frame.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Canvas Blit Bind Group"),
layout: bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(intermediate_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
});
let mut encoder = frame
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Canvas Blit Encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Canvas Blit Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
render_pass.set_pipeline(pipeline);
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..6, 0..1);
}
frame.queue.submit(std::iter::once(encoder.finish()));
}
}
const BLIT_SHADER: &str = r"
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) tex_coord: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
// Full-screen triangle pair
var positions = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(1.0, -1.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>(1.0, -1.0),
vec2<f32>(1.0, 1.0),
);
var tex_coords = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
);
var output: VertexOutput;
output.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
output.tex_coord = tex_coords[vertex_index];
return output;
}
@group(0) @binding(0) var t_source: texture_2d<f32>;
@group(0) @binding(1) var s_source: sampler;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(t_source, s_source, in.tex_coord);
}
";