1447 lines
60 KiB
Rust
1447 lines
60 KiB
Rust
//! # GPU-Accelerated 4K Canvas Renderer (DO NOT MODIFY / PROTECT FROM REGRESSIONS)
|
||
//!
|
||
//! ⚠️ CRITICAL PERFORMANCE WARNING — DO NOT REMOVE OR BYPASS THESE OPTIMIZATIONS:
|
||
//! - This file implements a custom `iced::widget::shader::Program` using a persistent wgpu pipeline.
|
||
//! - Replaces the legacy full-copy `ImageHandle::from_rgba` pipeline which copied 33MB of pixels on the main thread every frame.
|
||
//! - Uses `queue.write_texture()` for partial sub-region updates (typically 41x51 = ~8KB per brush stroke) to achieve 100+ FPS at 4K.
|
||
//! - All texture coordinates are mapped by aligning the wgpu render pass viewport directly to the widget absolute bounds (`render_pass.set_viewport(...)`).
|
||
//! - Uses `wgpu::FilterMode::Nearest` magnification/minification to prevent linear blending seams/gaps at 256x256 tile boundaries.
|
||
//! - Consumes and resets dirty regions during `view()` in canvas/mod.rs via `RefCell` to prevent frame-drop race conditions under multi-event updates.
|
||
//!
|
||
//! ## Architecture
|
||
//! - **`CanvasShaderProgram`** — handles event routing (zoom, pan, drawing message emission) and instantiates the primitive.
|
||
//! - **`CanvasShaderPrimitive`** — holds layout bounds and the Arc-shared composite pixels, defining prepare/render loops.
|
||
//! - **`CanvasShaderPipeline`** — persistent resource container containing pipelines, samplers, uniforms, and the wgpu texture.
|
||
//!
|
||
//! All automated agents must preserve these structures and keep the wgpu/advanced feature dependencies.
|
||
//!
|
||
|
||
use super::texture_update::{SharedCompositePixels, TextureUpdate};
|
||
use crate::app::Message;
|
||
use iced::advanced::Shell;
|
||
use iced::mouse;
|
||
use iced::widget::shader;
|
||
use iced::widget::shader::wgpu::util::DeviceExt;
|
||
use iced::{Point, Rectangle, Vector};
|
||
|
||
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN};
|
||
|
||
/// Screen-space checkerboard square size in pixels (matches egui behaviour).
|
||
const CHECKER_SQUARE: f32 = 20.0;
|
||
|
||
// ─── Uniform buffer struct ───────────────────────────────────────────────────
|
||
|
||
/// GPU uniform buffer for the canvas shader.
|
||
///
|
||
/// Contains transform parameters (zoom/pan → NDC), canvas dimensions,
|
||
/// viewport dimensions, and checkerboard size. Aligned to 16 bytes
|
||
/// for wgpu uniform buffer requirements.
|
||
#[repr(C)]
|
||
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||
struct CanvasUniforms {
|
||
/// X scale factor (canvas_display_width / viewport_width * 2.0)
|
||
scale_x: f32,
|
||
/// Y scale factor (canvas_display_height / viewport_height * 2.0)
|
||
scale_y: f32,
|
||
/// X translation in NDC
|
||
translate_x: f32,
|
||
/// Y translation in NDC
|
||
translate_y: f32,
|
||
/// Engine canvas width in pixels
|
||
canvas_w: f32,
|
||
/// Engine canvas height in pixels
|
||
canvas_h: f32,
|
||
/// Viewport width in pixels
|
||
viewport_w: f32,
|
||
/// Viewport height in pixels
|
||
viewport_h: f32,
|
||
/// Checkerboard square size in pixels
|
||
checker_size: f32,
|
||
/// Marching ants animation time in seconds
|
||
anim_time: f32,
|
||
/// 1.0 if selection mask is bound, 0.0 otherwise
|
||
has_selection: f32,
|
||
/// 1.0 for red quick-mask tint, 0.0 for normal blue tint
|
||
quick_mask: f32,
|
||
}
|
||
|
||
// ─── Vertex struct ───────────────────────────────────────────────────────────
|
||
|
||
/// Vertex for the textured canvas quad.
|
||
///
|
||
/// Position is in [0,1] range (the vertex shader transforms to NDC using uniforms).
|
||
/// UV maps to the composite texture.
|
||
#[repr(C)]
|
||
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||
struct CanvasVertex {
|
||
/// Position in [0,1] unit space (transformed by uniforms in vertex shader)
|
||
position: [f32; 2],
|
||
/// Texture coordinate [0,1]
|
||
uv: [f32; 2],
|
||
}
|
||
|
||
/// Vertex buffer layout for `CanvasVertex`.
|
||
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||
array_stride: std::mem::size_of::<CanvasVertex>() as wgpu::BufferAddress,
|
||
step_mode: wgpu::VertexStepMode::Vertex,
|
||
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2],
|
||
};
|
||
|
||
/// 6 vertices forming two triangles for the canvas quad.
|
||
/// Position [0,1] × [0,1], UV [0,1] × [0,1].
|
||
const QUAD_VERTICES: [CanvasVertex; 6] = [
|
||
// Triangle 1: top-left, bottom-left, bottom-right
|
||
CanvasVertex {
|
||
position: [0.0, 0.0],
|
||
uv: [0.0, 0.0],
|
||
},
|
||
CanvasVertex {
|
||
position: [0.0, 1.0],
|
||
uv: [0.0, 1.0],
|
||
},
|
||
CanvasVertex {
|
||
position: [1.0, 1.0],
|
||
uv: [1.0, 1.0],
|
||
},
|
||
// Triangle 2: top-left, bottom-right, top-right
|
||
CanvasVertex {
|
||
position: [0.0, 0.0],
|
||
uv: [0.0, 0.0],
|
||
},
|
||
CanvasVertex {
|
||
position: [1.0, 1.0],
|
||
uv: [1.0, 1.0],
|
||
},
|
||
CanvasVertex {
|
||
position: [1.0, 0.0],
|
||
uv: [1.0, 0.0],
|
||
},
|
||
];
|
||
|
||
// ─── wgpu re-export (from iced's shader module) ──────────────────────────────
|
||
|
||
use iced::widget::shader::wgpu;
|
||
|
||
// ─── Pipeline (persisted in Storage across frames) ───────────────────────────
|
||
|
||
/// Persistent GPU resources for the canvas shader.
|
||
///
|
||
/// Created once in `prepare()` on the first frame, then reused.
|
||
/// The composite texture is recreated only when the canvas dimensions change.
|
||
///
|
||
/// ## Side Effects
|
||
/// - Owns a `wgpu::Texture` of size `canvas_w × canvas_h` (RGBA8)
|
||
/// - Owns vertex buffer, uniform buffer, bind group, render pipeline
|
||
struct CanvasShaderPipeline {
|
||
/// Render pipeline (created once)
|
||
pipeline: wgpu::RenderPipeline,
|
||
/// Vertex buffer (6 vertices, static)
|
||
vertex_buffer: wgpu::Buffer,
|
||
/// Uniform buffer (updated every frame with zoom/pan)
|
||
uniform_buffer: wgpu::Buffer,
|
||
/// Bind group layout (for recreation on texture resize)
|
||
bind_group_layout: wgpu::BindGroupLayout,
|
||
/// Current bind group (texture + sampler + uniforms)
|
||
bind_group: wgpu::BindGroup,
|
||
/// Composite texture (RGBA8, canvas_w × canvas_h)
|
||
texture: wgpu::Texture,
|
||
/// Texture view for binding
|
||
texture_view: wgpu::TextureView,
|
||
/// Sampler (nearest-neighbor for pixel art)
|
||
sampler: wgpu::Sampler,
|
||
/// Current texture dimensions (to detect resize)
|
||
texture_w: u32,
|
||
texture_h: u32,
|
||
/// Selection mask texture (R8, canvas_w × canvas_h)
|
||
sel_texture: wgpu::Texture,
|
||
/// Selection mask texture view
|
||
sel_texture_view: wgpu::TextureView,
|
||
/// Selection mask sampler
|
||
sel_sampler: wgpu::Sampler,
|
||
}
|
||
|
||
impl CanvasShaderPipeline {
|
||
/// Create the pipeline and all GPU resources.
|
||
///
|
||
/// ## Arguments
|
||
/// * `device` — wgpu device for resource creation
|
||
/// * `queue` — wgpu queue for initial data upload
|
||
/// * `format` — target texture format (from Iced)
|
||
/// * `canvas_w` — engine canvas width in pixels
|
||
/// * `canvas_h` — engine canvas height in pixels
|
||
/// * `initial_pixels` — full RGBA pixel data for the initial texture upload
|
||
fn new(
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
format: wgpu::TextureFormat,
|
||
canvas_w: u32,
|
||
canvas_h: u32,
|
||
initial_pixels: &[u8],
|
||
) -> Self {
|
||
log::debug!(
|
||
"[CanvasShaderPipeline::new] Creating pipeline for {}×{} canvas, format={:?}",
|
||
canvas_w,
|
||
canvas_h,
|
||
format
|
||
);
|
||
|
||
// ── Texture ──────────────────────────────────────────────────────
|
||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("hcie-composite-texture"),
|
||
size: wgpu::Extent3d {
|
||
width: canvas_w.max(1),
|
||
height: canvas_h.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||
view_formats: &[],
|
||
});
|
||
|
||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||
|
||
// Upload initial pixel data
|
||
if !initial_pixels.is_empty() && initial_pixels.len() == (canvas_w * canvas_h * 4) as usize
|
||
{
|
||
queue.write_texture(
|
||
wgpu::ImageCopyTexture {
|
||
texture: &texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
initial_pixels,
|
||
wgpu::ImageDataLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(canvas_w * 4),
|
||
rows_per_image: Some(canvas_h),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: canvas_w.max(1),
|
||
height: canvas_h.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
log::debug!(
|
||
"[CanvasShaderPipeline::new] Uploaded initial {}×{} texture ({} bytes)",
|
||
canvas_w,
|
||
canvas_h,
|
||
initial_pixels.len()
|
||
);
|
||
super::perf::record_upload(initial_pixels.len(), true);
|
||
}
|
||
|
||
// ── Sampler (nearest for zoom-in, linear for zoom-out) ───────────
|
||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||
label: Some("hcie-composite-sampler"),
|
||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||
mag_filter: wgpu::FilterMode::Nearest,
|
||
min_filter: wgpu::FilterMode::Nearest,
|
||
mipmap_filter: wgpu::FilterMode::Nearest,
|
||
..Default::default()
|
||
});
|
||
|
||
// ── Selection mask texture (R8, same size as canvas) ─────────────
|
||
let sel_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("hcie-selection-texture"),
|
||
size: wgpu::Extent3d {
|
||
width: canvas_w.max(1),
|
||
height: canvas_h.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::R8Unorm,
|
||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||
view_formats: &[],
|
||
});
|
||
let sel_texture_view = sel_texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||
let sel_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||
label: Some("hcie-selection-sampler"),
|
||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||
mag_filter: wgpu::FilterMode::Nearest,
|
||
min_filter: wgpu::FilterMode::Nearest,
|
||
mipmap_filter: wgpu::FilterMode::Nearest,
|
||
..Default::default()
|
||
});
|
||
|
||
// ── Uniform buffer ───────────────────────────────────────────────
|
||
let uniform_data = CanvasUniforms {
|
||
scale_x: 1.0,
|
||
scale_y: 1.0,
|
||
translate_x: 0.0,
|
||
translate_y: 0.0,
|
||
canvas_w: canvas_w as f32,
|
||
canvas_h: canvas_h as f32,
|
||
viewport_w: 800.0,
|
||
viewport_h: 600.0,
|
||
checker_size: CHECKER_SQUARE,
|
||
anim_time: 0.0,
|
||
has_selection: 0.0,
|
||
quick_mask: 0.0,
|
||
};
|
||
|
||
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||
label: Some("hcie-canvas-uniforms"),
|
||
contents: bytemuck::bytes_of(&uniform_data),
|
||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||
});
|
||
|
||
// ── Bind group layout ────────────────────────────────────────────
|
||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||
label: Some("hcie-canvas-bind-group-layout"),
|
||
entries: &[
|
||
// @binding(0): Uniforms
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 0,
|
||
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Buffer {
|
||
ty: wgpu::BufferBindingType::Uniform,
|
||
has_dynamic_offset: false,
|
||
min_binding_size: None,
|
||
},
|
||
count: None,
|
||
},
|
||
// @binding(1): Composite texture
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 1,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
// @binding(2): Composite sampler
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 2,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
// @binding(3): Selection mask texture (R8)
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 3,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Texture {
|
||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||
view_dimension: wgpu::TextureViewDimension::D2,
|
||
multisampled: false,
|
||
},
|
||
count: None,
|
||
},
|
||
// @binding(4): Selection mask sampler
|
||
wgpu::BindGroupLayoutEntry {
|
||
binding: 4,
|
||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||
count: None,
|
||
},
|
||
],
|
||
});
|
||
|
||
// ── Bind group ───────────────────────────────────────────────────
|
||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("hcie-canvas-bind-group"),
|
||
layout: &bind_group_layout,
|
||
entries: &[
|
||
wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: uniform_buffer.as_entire_binding(),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 1,
|
||
resource: wgpu::BindingResource::TextureView(&texture_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 3,
|
||
resource: wgpu::BindingResource::TextureView(&sel_texture_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 4,
|
||
resource: wgpu::BindingResource::Sampler(&sel_sampler),
|
||
},
|
||
],
|
||
});
|
||
|
||
// ── Vertex buffer ────────────────────────────────────────────────
|
||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||
label: Some("hcie-canvas-vertices"),
|
||
contents: bytemuck::cast_slice(&QUAD_VERTICES),
|
||
usage: wgpu::BufferUsages::VERTEX,
|
||
});
|
||
|
||
// ── Shader module ────────────────────────────────────────────────
|
||
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||
label: Some("hcie-canvas-shader"),
|
||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||
"canvas.wgsl"
|
||
))),
|
||
});
|
||
|
||
// ── Pipeline layout ─────────────────────────────────────────────
|
||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||
label: Some("hcie-canvas-pipeline-layout"),
|
||
bind_group_layouts: &[&bind_group_layout],
|
||
push_constant_ranges: &[],
|
||
});
|
||
|
||
// ── Render pipeline ──────────────────────────────────────────────
|
||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||
label: Some("hcie-canvas-pipeline"),
|
||
layout: Some(&pipeline_layout),
|
||
vertex: wgpu::VertexState {
|
||
module: &shader_module,
|
||
entry_point: "vs_main",
|
||
buffers: &[VERTEX_LAYOUT],
|
||
},
|
||
primitive: wgpu::PrimitiveState {
|
||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||
strip_index_format: None,
|
||
front_face: wgpu::FrontFace::Ccw,
|
||
cull_mode: None,
|
||
unclipped_depth: false,
|
||
polygon_mode: wgpu::PolygonMode::Fill,
|
||
conservative: false,
|
||
},
|
||
depth_stencil: None,
|
||
multisample: wgpu::MultisampleState::default(),
|
||
fragment: Some(wgpu::FragmentState {
|
||
module: &shader_module,
|
||
entry_point: "fs_main",
|
||
targets: &[Some(wgpu::ColorTargetState {
|
||
format,
|
||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||
write_mask: wgpu::ColorWrites::ALL,
|
||
})],
|
||
}),
|
||
multiview: None,
|
||
});
|
||
|
||
log::info!(
|
||
"[CanvasShaderPipeline::new] Pipeline created successfully for {}×{} canvas",
|
||
canvas_w,
|
||
canvas_h
|
||
);
|
||
|
||
Self {
|
||
pipeline,
|
||
vertex_buffer,
|
||
uniform_buffer,
|
||
bind_group_layout,
|
||
bind_group,
|
||
texture,
|
||
texture_view,
|
||
sampler,
|
||
texture_w: canvas_w,
|
||
texture_h: canvas_h,
|
||
sel_texture,
|
||
sel_texture_view,
|
||
sel_sampler,
|
||
}
|
||
}
|
||
|
||
/// Recreate the texture and bind group when canvas dimensions change.
|
||
///
|
||
/// ## Arguments
|
||
/// * `device` — wgpu device
|
||
/// * `queue` — wgpu queue
|
||
/// * `canvas_w` — new width
|
||
/// * `canvas_h` — new height
|
||
/// * `pixels` — full RGBA pixel data for the new dimensions
|
||
fn resize_texture(
|
||
&mut self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
canvas_w: u32,
|
||
canvas_h: u32,
|
||
pixels: &[u8],
|
||
) {
|
||
log::info!(
|
||
"[CanvasShaderPipeline::resize_texture] {}×{} → {}×{}",
|
||
self.texture_w,
|
||
self.texture_h,
|
||
canvas_w,
|
||
canvas_h
|
||
);
|
||
|
||
self.texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("hcie-composite-texture"),
|
||
size: wgpu::Extent3d {
|
||
width: canvas_w.max(1),
|
||
height: canvas_h.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||
view_formats: &[],
|
||
});
|
||
|
||
self.texture_view = self
|
||
.texture
|
||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||
|
||
// Upload full pixel data
|
||
if pixels.len() == (canvas_w * canvas_h * 4) as usize {
|
||
queue.write_texture(
|
||
wgpu::ImageCopyTexture {
|
||
texture: &self.texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
pixels,
|
||
wgpu::ImageDataLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(canvas_w * 4),
|
||
rows_per_image: Some(canvas_h),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: canvas_w.max(1),
|
||
height: canvas_h.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
super::perf::record_upload(pixels.len(), true);
|
||
}
|
||
|
||
// Recreate selection mask texture with new dimensions
|
||
self.sel_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||
label: Some("hcie-selection-texture"),
|
||
size: wgpu::Extent3d {
|
||
width: canvas_w.max(1),
|
||
height: canvas_h.max(1),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
mip_level_count: 1,
|
||
sample_count: 1,
|
||
dimension: wgpu::TextureDimension::D2,
|
||
format: wgpu::TextureFormat::R8Unorm,
|
||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||
view_formats: &[],
|
||
});
|
||
self.sel_texture_view = self
|
||
.sel_texture
|
||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||
|
||
// Recreate bind group with new texture views
|
||
self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||
label: Some("hcie-canvas-bind-group"),
|
||
layout: &self.bind_group_layout,
|
||
entries: &[
|
||
wgpu::BindGroupEntry {
|
||
binding: 0,
|
||
resource: self.uniform_buffer.as_entire_binding(),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 1,
|
||
resource: wgpu::BindingResource::TextureView(&self.texture_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 2,
|
||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 3,
|
||
resource: wgpu::BindingResource::TextureView(&self.sel_texture_view),
|
||
},
|
||
wgpu::BindGroupEntry {
|
||
binding: 4,
|
||
resource: wgpu::BindingResource::Sampler(&self.sel_sampler),
|
||
},
|
||
],
|
||
});
|
||
|
||
self.texture_w = canvas_w;
|
||
self.texture_h = canvas_h;
|
||
}
|
||
|
||
/// Replace every pixel in the existing composite texture without rebuilding GPU resources.
|
||
///
|
||
/// ## Arguments
|
||
/// * `queue` — wgpu queue used for the texture write
|
||
/// * `pixels` — complete tightly packed RGBA image matching the current dimensions
|
||
///
|
||
/// ## Returns
|
||
/// Nothing. Invalid buffer lengths are logged and ignored.
|
||
///
|
||
/// ## Side Effects
|
||
/// Enqueues one full texture write while preserving texture views, selection resources,
|
||
/// samplers, and the bind group.
|
||
fn upload_full_texture(&self, queue: &wgpu::Queue, pixels: &[u8]) {
|
||
let expected = self.texture_w as usize * self.texture_h as usize * 4;
|
||
if self.texture_w == 0 || self.texture_h == 0 || pixels.len() != expected {
|
||
log::warn!(
|
||
"[CanvasShaderPipeline::upload_full_texture] size mismatch: pixels={} expected={} canvas={}x{}",
|
||
pixels.len(),
|
||
expected,
|
||
self.texture_w,
|
||
self.texture_h
|
||
);
|
||
return;
|
||
}
|
||
|
||
queue.write_texture(
|
||
wgpu::ImageCopyTexture {
|
||
texture: &self.texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
pixels,
|
||
wgpu::ImageDataLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(self.texture_w * 4),
|
||
rows_per_image: Some(self.texture_h),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: self.texture_w,
|
||
height: self.texture_h,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
super::perf::record_upload(pixels.len(), true);
|
||
log::debug!(
|
||
"[CanvasShaderPipeline::upload_full_texture] uploaded {}x{} texture ({} bytes)",
|
||
self.texture_w,
|
||
self.texture_h,
|
||
pixels.len()
|
||
);
|
||
}
|
||
|
||
/// Upload only the dirty sub-region of the composite buffer to the GPU.
|
||
///
|
||
/// ## Arguments
|
||
/// * `queue` — wgpu queue for the upload
|
||
/// * `update` — tightly packed immutable dirty-region pixels
|
||
fn upload_dirty_region(&self, queue: &wgpu::Queue, update: &TextureUpdate) {
|
||
let [x0, y0, _, _] = update.region;
|
||
let t0 = std::time::Instant::now();
|
||
queue.write_texture(
|
||
wgpu::ImageCopyTexture {
|
||
texture: &self.texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d { x: x0, y: y0, z: 0 },
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
&update.pixels,
|
||
wgpu::ImageDataLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(update.bytes_per_row),
|
||
rows_per_image: Some(update.height()),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: update.width(),
|
||
height: update.height(),
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
let elapsed = t0.elapsed();
|
||
super::perf::record_duration("gpu_upload", elapsed);
|
||
super::perf::record_upload(update.pixels.len(), false);
|
||
log::debug!(
|
||
"[CanvasShaderPipeline::upload_dirty_region] {}×{} region at ({},{}) → {:.2}ms, {} bytes",
|
||
update.width(), update.height(), x0, y0, elapsed.as_secs_f64() * 1000.0, update.pixels.len()
|
||
);
|
||
}
|
||
|
||
/// Upload the full selection mask to the GPU texture.
|
||
///
|
||
/// Called when the selection mask changes (new selection, selection modified).
|
||
/// The mask is a single-channel (R8) texture matching the canvas dimensions.
|
||
///
|
||
/// ## Arguments
|
||
/// * `queue` — wgpu queue for the upload
|
||
/// * `mask` — selection mask bytes (1 byte per pixel, >128 = selected)
|
||
/// * `canvas_w` — mask width in pixels
|
||
/// * `canvas_h` — mask height in pixels
|
||
fn upload_selection_mask(
|
||
&self,
|
||
queue: &wgpu::Queue,
|
||
mask: &[u8],
|
||
canvas_w: u32,
|
||
canvas_h: u32,
|
||
) {
|
||
if mask.len() != (canvas_w * canvas_h) as usize {
|
||
log::warn!(
|
||
"[CanvasShaderPipeline::upload_selection_mask] Size mismatch: mask={} but canvas={}×{}={}",
|
||
mask.len(), canvas_w, canvas_h, canvas_w * canvas_h
|
||
);
|
||
return;
|
||
}
|
||
queue.write_texture(
|
||
wgpu::ImageCopyTexture {
|
||
texture: &self.sel_texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
mask,
|
||
wgpu::ImageDataLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(canvas_w),
|
||
rows_per_image: Some(canvas_h),
|
||
},
|
||
wgpu::Extent3d {
|
||
width: canvas_w,
|
||
height: canvas_h,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Update the uniform buffer with current zoom/pan/viewport values.
|
||
///
|
||
/// ## Arguments
|
||
/// * `queue` — wgpu queue for the update
|
||
/// * `uniforms` — new uniform values
|
||
fn update_uniforms(&self, queue: &wgpu::Queue, uniforms: &CanvasUniforms) {
|
||
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
|
||
}
|
||
}
|
||
|
||
// ─── Primitive (per-frame data carrier) ──────────────────────────────────────
|
||
|
||
/// Per-frame data passed from `draw()` to `prepare()` and `render()`.
|
||
///
|
||
/// Carries the dirty region info and a reference to the composite pixel data
|
||
/// so `prepare()` can upload only the changed sub-region to the GPU texture.
|
||
#[derive(Debug)]
|
||
pub struct CanvasShaderPrimitive {
|
||
/// Engine canvas dimensions
|
||
pub canvas_w: u32,
|
||
pub canvas_h: u32,
|
||
/// Current zoom level
|
||
pub zoom: f32,
|
||
/// Pan offset in screen pixels
|
||
pub pan_offset: Vector,
|
||
/// Tightly packed dirty-region pixels for a normal partial upload.
|
||
pub texture_update: Option<TextureUpdate>,
|
||
/// Stable complete RGBA recovery image for pipeline creation and resize.
|
||
pub composite_pixels: SharedCompositePixels,
|
||
/// Whether this is a full texture upload (resize or first frame)
|
||
pub full_upload: bool,
|
||
/// Absolute widget bounds in the window
|
||
pub bounds: Rectangle,
|
||
/// Encoded selection data (0 unselected, 128 interior, 255 border), if any.
|
||
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
|
||
/// Whether the selection mask has changed since last upload
|
||
pub selection_dirty: bool,
|
||
/// Animation time in seconds for marching ants
|
||
pub anim_time: f32,
|
||
/// Whether quick-mask mode is active (red tint vs blue)
|
||
pub quick_mask: bool,
|
||
}
|
||
|
||
impl shader::Primitive for CanvasShaderPrimitive {
|
||
/// Processes the primitive, uploading dirty pixel data to the GPU texture.
|
||
///
|
||
/// ## Logic
|
||
/// 1. If pipeline doesn't exist in Storage, create it (first frame)
|
||
/// 2. If canvas dimensions changed, recreate texture
|
||
/// 3. Upload dirty region via `queue.write_texture()`
|
||
/// 4. Update uniform buffer with current zoom/pan
|
||
fn prepare(
|
||
&self,
|
||
device: &wgpu::Device,
|
||
queue: &wgpu::Queue,
|
||
format: wgpu::TextureFormat,
|
||
storage: &mut shader::Storage,
|
||
bounds: &Rectangle,
|
||
_viewport: &shader::Viewport,
|
||
) {
|
||
let t0 = std::time::Instant::now();
|
||
let represented_input = self
|
||
.texture_update
|
||
.as_ref()
|
||
.and_then(|update| update.input_at)
|
||
.or_else(|| {
|
||
self.full_upload
|
||
.then(super::perf::latest_render_input)
|
||
.flatten()
|
||
});
|
||
|
||
// ── Create or retrieve pipeline ──────────────────────────────────
|
||
let created_pipeline = !storage.has::<CanvasShaderPipeline>();
|
||
if created_pipeline {
|
||
log::info!("[CanvasShaderPrimitive::prepare] First frame — creating pipeline");
|
||
let pixels = self.composite_pixels.read();
|
||
let pipeline = CanvasShaderPipeline::new(
|
||
device,
|
||
queue,
|
||
format,
|
||
self.canvas_w,
|
||
self.canvas_h,
|
||
&pixels,
|
||
);
|
||
storage.store(pipeline);
|
||
}
|
||
|
||
let pipeline = storage.get_mut::<CanvasShaderPipeline>().unwrap();
|
||
|
||
// ── Resize texture if canvas dimensions changed ──────────────────
|
||
if !created_pipeline
|
||
&& (pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h)
|
||
{
|
||
let pixels = self.composite_pixels.read();
|
||
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
|
||
} else if !created_pipeline && self.full_upload {
|
||
// Same-size document replacement: preserve all persistent GPU resources.
|
||
let pixels = self.composite_pixels.read();
|
||
pipeline.upload_full_texture(queue, &pixels);
|
||
} else if !created_pipeline {
|
||
// ── Partial upload: only the dirty sub-region ────────────────
|
||
if let Some(update) = self.texture_update.as_ref() {
|
||
pipeline.upload_dirty_region(queue, update);
|
||
}
|
||
}
|
||
|
||
// ── Upload selection mask if changed ──────────────────────────────
|
||
if self.selection_dirty {
|
||
if let Some(ref mask) = self.selection_mask {
|
||
pipeline.upload_selection_mask(queue, mask, self.canvas_w, self.canvas_h);
|
||
}
|
||
}
|
||
|
||
// ── Compute uniforms ─────────────────────────────────────────────
|
||
let viewport_w = bounds.width;
|
||
let viewport_h = bounds.height;
|
||
let engine_w = self.canvas_w as f32;
|
||
let engine_h = self.canvas_h as f32;
|
||
let display_w = engine_w * self.zoom;
|
||
let display_h = engine_h * self.zoom;
|
||
|
||
// Canvas origin in viewport coordinates (centered + pan_offset)
|
||
let raw_x = (viewport_w - display_w) / 2.0 + self.pan_offset.x;
|
||
let raw_y = (viewport_h - display_h) / 2.0 + self.pan_offset.y;
|
||
let min_vis_w = display_w * 0.25;
|
||
let min_vis_h = display_h * 0.25;
|
||
let origin_x = raw_x.clamp(
|
||
-(display_w - min_vis_w).max(0.0),
|
||
(viewport_w - min_vis_w).max(0.0),
|
||
);
|
||
let origin_y = raw_y.clamp(
|
||
-(display_h - min_vis_h).max(0.0),
|
||
(viewport_h - min_vis_h).max(0.0),
|
||
);
|
||
|
||
// Convert to NDC: viewport [0, viewport_w] → NDC [-1, 1]
|
||
// The quad vertices are in [0,1], vertex shader does:
|
||
// ndc_x = position.x * scale_x + translate_x
|
||
// ndc_y = position.y * scale_y + translate_y
|
||
let scale_x = (display_w / viewport_w) * 2.0;
|
||
let scale_y = -(display_h / viewport_h) * 2.0; // flip Y (NDC Y is up)
|
||
let translate_x = (origin_x / viewport_w) * 2.0 - 1.0;
|
||
let translate_y = 1.0 - (origin_y / viewport_h) * 2.0; // flip Y
|
||
|
||
let uniforms = CanvasUniforms {
|
||
scale_x,
|
||
scale_y,
|
||
translate_x,
|
||
translate_y,
|
||
canvas_w: engine_w,
|
||
canvas_h: engine_h,
|
||
viewport_w,
|
||
viewport_h,
|
||
checker_size: CHECKER_SQUARE,
|
||
anim_time: self.anim_time,
|
||
has_selection: if self.selection_mask.is_some() {
|
||
1.0
|
||
} else {
|
||
0.0
|
||
},
|
||
quick_mask: if self.quick_mask { 1.0 } else { 0.0 },
|
||
};
|
||
|
||
pipeline.update_uniforms(queue, &uniforms);
|
||
|
||
let elapsed = t0.elapsed();
|
||
super::perf::record_duration("gpu_prepare", elapsed);
|
||
super::perf::record_prepare(represented_input);
|
||
log::trace!(
|
||
"[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}",
|
||
elapsed.as_secs_f64() * 1000.0,
|
||
viewport_w as u32,
|
||
viewport_h as u32,
|
||
self.canvas_w,
|
||
self.canvas_h,
|
||
self.zoom
|
||
);
|
||
}
|
||
|
||
/// Renders the canvas quad with the composite texture.
|
||
///
|
||
/// ## Logic
|
||
/// Single draw call: 6 vertices (two triangles), one bind group.
|
||
/// The fragment shader handles checkerboard + alpha blending.
|
||
fn render(
|
||
&self,
|
||
encoder: &mut wgpu::CommandEncoder,
|
||
storage: &shader::Storage,
|
||
target: &wgpu::TextureView,
|
||
clip_bounds: &Rectangle<u32>,
|
||
) {
|
||
let Some(pipeline) = storage.get::<CanvasShaderPipeline>() else {
|
||
log::warn!("[CanvasShaderPrimitive::render] No pipeline in storage — skipping");
|
||
return;
|
||
};
|
||
|
||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("hcie-canvas-render-pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: target,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
// Don't clear — Iced has already rendered the background
|
||
load: wgpu::LoadOp::Load,
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: None,
|
||
timestamp_writes: None,
|
||
occlusion_query_set: None,
|
||
});
|
||
|
||
render_pass.set_scissor_rect(
|
||
clip_bounds.x,
|
||
clip_bounds.y,
|
||
clip_bounds.width,
|
||
clip_bounds.height,
|
||
);
|
||
// clip_bounds is already in physical pixels from iced — use it for the
|
||
// wgpu viewport which also expects physical pixel coordinates.
|
||
render_pass.set_viewport(
|
||
clip_bounds.x as f32,
|
||
clip_bounds.y as f32,
|
||
clip_bounds.width as f32,
|
||
clip_bounds.height as f32,
|
||
0.0,
|
||
1.0,
|
||
);
|
||
render_pass.set_pipeline(&pipeline.pipeline);
|
||
render_pass.set_bind_group(0, &pipeline.bind_group, &[]);
|
||
render_pass.set_vertex_buffer(0, pipeline.vertex_buffer.slice(..));
|
||
render_pass.draw(0..6, 0..1);
|
||
}
|
||
}
|
||
|
||
// ─── Shader Program (event handling + primitive creation) ─────────────────────
|
||
|
||
/// Shader program state — tracks mouse position, drag state, hover.
|
||
///
|
||
/// This replaces the old `CanvasState` from the `canvas::Program` implementation
|
||
/// with equivalent functionality for `shader::Program`.
|
||
#[derive(Debug, Default)]
|
||
pub struct CanvasShaderState {
|
||
/// Current mouse position in viewport-local coordinates.
|
||
pub cursor_pos: Option<Point>,
|
||
/// Whether mouse is over the canvas widget.
|
||
pub is_hovered: bool,
|
||
/// Pan drag start position (absolute window-space).
|
||
pub pan_start: Option<Point>,
|
||
/// Track left button state.
|
||
pub left_pressed: bool,
|
||
/// Track middle button state.
|
||
pub middle_pressed: bool,
|
||
/// Distinguishes Space+left temporary pan from a physical middle-button pan.
|
||
pub space_left_pan: bool,
|
||
/// Immediate local pan offset maintained during active panning to eliminate 1-frame Elm update latency.
|
||
pub local_pan_offset: Option<Vector>,
|
||
/// Last canvas coordinate published to the status bar.
|
||
pub last_status_cursor: Option<(u32, u32)>,
|
||
/// Time of the last status-bar coordinate publication.
|
||
pub last_status_emit: Option<std::time::Instant>,
|
||
/// Last pane size sent to application state, stored as exact float bit patterns.
|
||
pub last_reported_pane_size: Option<(u32, u32)>,
|
||
}
|
||
|
||
/// Minimum interval between status-bar cursor messages during idle movement.
|
||
const CURSOR_STATUS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
|
||
|
||
/// Decides whether an idle cursor coordinate needs an Elm application message.
|
||
///
|
||
/// **Arguments:** `previous` and `current` are integer canvas coordinates;
|
||
/// `elapsed` is time since the previous publication, or `None` for the first.
|
||
/// **Returns:** `true` only for a changed coordinate whose interval is due.
|
||
/// **Side Effects / Dependencies:** None.
|
||
fn should_publish_cursor_status(
|
||
previous: Option<(u32, u32)>,
|
||
current: (u32, u32),
|
||
elapsed: Option<std::time::Duration>,
|
||
) -> bool {
|
||
previous != Some(current)
|
||
&& elapsed.map_or(true, |duration| duration >= CURSOR_STATUS_INTERVAL)
|
||
}
|
||
|
||
/// Canvas shader program — implements `iced::widget::shader::Program`.
|
||
///
|
||
/// Holds per-frame rendering data (zoom, pan, pixels) and handles mouse events
|
||
/// for drawing, panning, and zooming. The `draw()` method returns a
|
||
/// `CanvasShaderPrimitive` which carries the dirty region data to the GPU.
|
||
pub struct CanvasShaderProgram {
|
||
/// Engine canvas width in pixels.
|
||
pub engine_w: u32,
|
||
/// Engine canvas height in pixels.
|
||
pub engine_h: u32,
|
||
/// Current zoom level.
|
||
pub zoom: f32,
|
||
/// Pan offset in screen pixels (relative to centered position).
|
||
pub pan_offset: Vector,
|
||
/// Stable complete RGBA recovery image used only for full uploads.
|
||
pub composite_pixels: SharedCompositePixels,
|
||
/// Tightly packed dirty-region pixels for the next partial upload.
|
||
pub texture_update: Option<TextureUpdate>,
|
||
/// Whether a full texture upload is needed (first frame, resize, file load).
|
||
pub full_upload: bool,
|
||
/// Whether Space temporarily changes left-drag into panning.
|
||
pub space_pan: bool,
|
||
/// Encoded selection data (0 unselected, 128 interior, 255 border), if any.
|
||
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
|
||
/// Whether the selection mask has changed since last upload.
|
||
pub selection_dirty: bool,
|
||
/// Animation time in seconds for marching ants.
|
||
pub anim_time: f32,
|
||
/// Whether quick-mask mode is active (red tint vs blue).
|
||
pub quick_mask: bool,
|
||
}
|
||
|
||
/// Convert a viewport-local point to canvas-space coordinates.
|
||
///
|
||
/// `local_pos` is relative to the shader widget's top-left corner.
|
||
/// `viewport_size` is the widget's size. Returns `(Some(x), Some(y))`
|
||
/// when inside the canvas image bounds, otherwise `(None, None)`.
|
||
fn screen_to_canvas_local(
|
||
local_pos: Point,
|
||
viewport_w: f32,
|
||
viewport_h: f32,
|
||
engine_w: f32,
|
||
engine_h: f32,
|
||
zoom: f32,
|
||
pan_offset: Vector,
|
||
) -> (Option<f32>, Option<f32>) {
|
||
let display_w = engine_w * zoom;
|
||
let display_h = engine_h * zoom;
|
||
let raw_x = (viewport_w - display_w) / 2.0 + pan_offset.x;
|
||
let raw_y = (viewport_h - display_h) / 2.0 + pan_offset.y;
|
||
let min_vis_w = display_w * 0.25;
|
||
let min_vis_h = display_h * 0.25;
|
||
let origin_x = raw_x.clamp(
|
||
-(display_w - min_vis_w).max(0.0),
|
||
(viewport_w - min_vis_w).max(0.0),
|
||
);
|
||
let origin_y = raw_y.clamp(
|
||
-(display_h - min_vis_h).max(0.0),
|
||
(viewport_h - min_vis_h).max(0.0),
|
||
);
|
||
|
||
let canvas_x = (local_pos.x - origin_x) / zoom;
|
||
let canvas_y = (local_pos.y - origin_y) / zoom;
|
||
|
||
let cx = if canvas_x >= 0.0 && canvas_x < engine_w {
|
||
Some(canvas_x)
|
||
} else {
|
||
None
|
||
};
|
||
let cy = if canvas_y >= 0.0 && canvas_y < engine_h {
|
||
Some(canvas_y)
|
||
} else {
|
||
None
|
||
};
|
||
(cx, cy)
|
||
}
|
||
|
||
impl shader::Program<Message> for CanvasShaderProgram {
|
||
type State = CanvasShaderState;
|
||
type Primitive = CanvasShaderPrimitive;
|
||
|
||
/// Handle mouse events for drawing, panning, and zooming.
|
||
///
|
||
/// Event handling mirrors the old `canvas::Program::update()` logic exactly,
|
||
/// adapted for `shader::Event` (which wraps `mouse::Event` directly).
|
||
fn update(
|
||
&self,
|
||
state: &mut Self::State,
|
||
event: shader::Event,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
_shell: &mut Shell<'_, Message>,
|
||
) -> (iced::event::Status, Option<Message>) {
|
||
if !state.middle_pressed {
|
||
state.local_pan_offset = None;
|
||
}
|
||
|
||
match event {
|
||
shader::Event::Mouse(mouse_event) => {
|
||
match mouse_event {
|
||
mouse::Event::CursorMoved { position } => {
|
||
let local_pos = Point::new(position.x - bounds.x, position.y - bounds.y);
|
||
state.cursor_pos = Some(local_pos);
|
||
state.is_hovered = bounds.contains(position);
|
||
|
||
let (canvas_x, canvas_y) = screen_to_canvas_local(
|
||
local_pos,
|
||
bounds.width,
|
||
bounds.height,
|
||
self.engine_w as f32,
|
||
self.engine_h as f32,
|
||
self.zoom,
|
||
state.local_pan_offset.unwrap_or(self.pan_offset),
|
||
);
|
||
|
||
// Left mouse drag (drawing)
|
||
if state.left_pressed {
|
||
let cx = canvas_x.unwrap_or(0.0).clamp(0.0, self.engine_w as f32);
|
||
let cy = canvas_y.unwrap_or(0.0).clamp(0.0, self.engine_h as f32);
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasPointerMoved {
|
||
x: cx,
|
||
y: cy,
|
||
captured_at: std::time::Instant::now(),
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Middle mouse drag (panning)
|
||
if state.middle_pressed && state.pan_start.is_some() {
|
||
let start = state.pan_start.unwrap();
|
||
let delta = position - start;
|
||
state.pan_start = Some(position);
|
||
let current_pan = state.local_pan_offset.unwrap_or(self.pan_offset);
|
||
let mut new_pan = current_pan;
|
||
new_pan.x += delta.x;
|
||
new_pan.y += delta.y;
|
||
|
||
// Clamp pan so at least 25% of canvas stays visible
|
||
let engine_w = self.engine_w as f32;
|
||
let engine_h = self.engine_h as f32;
|
||
let display_w = engine_w * self.zoom;
|
||
let display_h = engine_h * self.zoom;
|
||
let min_vis_w = display_w * 0.25;
|
||
let min_vis_h = display_h * 0.25;
|
||
let default_ox = (bounds.width - display_w) / 2.0;
|
||
let default_oy = (bounds.height - display_h) / 2.0;
|
||
let raw_ox = default_ox + new_pan.x;
|
||
let raw_oy = default_oy + new_pan.y;
|
||
new_pan.x = raw_ox.clamp(
|
||
-(display_w - min_vis_w).max(0.0),
|
||
(bounds.width - min_vis_w).max(0.0),
|
||
) - default_ox;
|
||
new_pan.y = raw_oy.clamp(
|
||
-(display_h - min_vis_h).max(0.0),
|
||
(bounds.height - min_vis_h).max(0.0),
|
||
) - default_oy;
|
||
|
||
state.local_pan_offset = Some(new_pan);
|
||
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasPanZoom {
|
||
zoom: self.zoom,
|
||
pan_offset: new_pan,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Cursor over canvas → status bar coords
|
||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||
let current = (cx as u32, cy as u32);
|
||
let now = std::time::Instant::now();
|
||
let elapsed = state
|
||
.last_status_emit
|
||
.map(|previous| now.duration_since(previous));
|
||
if should_publish_cursor_status(
|
||
state.last_status_cursor,
|
||
current,
|
||
elapsed,
|
||
) {
|
||
state.last_status_cursor = Some(current);
|
||
state.last_status_emit = Some(now);
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasCursorPos {
|
||
x: current.0,
|
||
y: current.1,
|
||
}),
|
||
);
|
||
}
|
||
return (iced::event::Status::Captured, None);
|
||
}
|
||
|
||
let pane_size = (bounds.width.to_bits(), bounds.height.to_bits());
|
||
if state.last_reported_pane_size != Some(pane_size) {
|
||
state.last_reported_pane_size = Some(pane_size);
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasSize((bounds.width, bounds.height))),
|
||
);
|
||
}
|
||
return (iced::event::Status::Captured, None);
|
||
}
|
||
|
||
mouse::Event::ButtonPressed(button) => {
|
||
if button == mouse::Button::Left
|
||
&& bounds.contains(cursor.position().unwrap_or(Point::ORIGIN))
|
||
{
|
||
if self.space_pan {
|
||
state.middle_pressed = true;
|
||
state.space_left_pan = true;
|
||
state.pan_start = cursor.position();
|
||
state.local_pan_offset = Some(self.pan_offset);
|
||
return (iced::event::Status::Captured, None);
|
||
}
|
||
let local_pos = {
|
||
let p = cursor.position().unwrap_or(Point::ORIGIN);
|
||
Point::new(p.x - bounds.x, p.y - bounds.y)
|
||
};
|
||
let (cx_opt, cy_opt) = screen_to_canvas_local(
|
||
local_pos,
|
||
bounds.width,
|
||
bounds.height,
|
||
self.engine_w as f32,
|
||
self.engine_h as f32,
|
||
self.zoom,
|
||
self.pan_offset,
|
||
);
|
||
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
|
||
state.left_pressed = true;
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasPointerPressed {
|
||
x: cx,
|
||
y: cy,
|
||
captured_at: std::time::Instant::now(),
|
||
}),
|
||
);
|
||
}
|
||
} else if button == mouse::Button::Middle {
|
||
if let Some(pos) = cursor.position() {
|
||
state.middle_pressed = true;
|
||
state.pan_start = Some(pos);
|
||
state.local_pan_offset = Some(self.pan_offset);
|
||
return (iced::event::Status::Captured, None);
|
||
}
|
||
} else if button == mouse::Button::Right {
|
||
if let Some(pos) = cursor.position() {
|
||
if bounds.contains(pos) {
|
||
let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y);
|
||
let (cx_opt, cy_opt) = screen_to_canvas_local(
|
||
local_pos,
|
||
bounds.width,
|
||
bounds.height,
|
||
self.engine_w as f32,
|
||
self.engine_h as f32,
|
||
self.zoom,
|
||
self.pan_offset,
|
||
);
|
||
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasPointerRightClicked {
|
||
x: cx,
|
||
y: cy,
|
||
screen_x: pos.x,
|
||
screen_y: pos.y,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
mouse::Event::ButtonReleased(button) => {
|
||
if button == mouse::Button::Left {
|
||
if state.space_left_pan {
|
||
state.middle_pressed = false;
|
||
state.space_left_pan = false;
|
||
state.pan_start = None;
|
||
state.local_pan_offset = None;
|
||
return (iced::event::Status::Captured, None);
|
||
}
|
||
state.left_pressed = false;
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasPointerReleased),
|
||
);
|
||
} else if button == mouse::Button::Middle {
|
||
state.middle_pressed = false;
|
||
state.pan_start = None;
|
||
state.local_pan_offset = None;
|
||
return (iced::event::Status::Captured, None);
|
||
}
|
||
}
|
||
|
||
mouse::Event::WheelScrolled { delta } => {
|
||
if bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) {
|
||
let scroll_y = match delta {
|
||
mouse::ScrollDelta::Lines { y, .. } => y,
|
||
mouse::ScrollDelta::Pixels { y, .. } => y / 50.0,
|
||
};
|
||
if scroll_y != 0.0 {
|
||
let abs_cursor = cursor.position().unwrap_or(Point::ORIGIN);
|
||
let local_cursor =
|
||
Point::new(abs_cursor.x - bounds.x, abs_cursor.y - bounds.y);
|
||
|
||
let old_zoom = self.zoom;
|
||
let factor = if scroll_y > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||
let new_zoom = (old_zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
|
||
let engine_w = self.engine_w as f32;
|
||
let engine_h = self.engine_h as f32;
|
||
|
||
let old_display_w = engine_w * old_zoom;
|
||
let old_display_h = engine_h * old_zoom;
|
||
let old_origin_x =
|
||
(bounds.width - old_display_w) / 2.0 + self.pan_offset.x;
|
||
let old_origin_y =
|
||
(bounds.height - old_display_h) / 2.0 + self.pan_offset.y;
|
||
|
||
let canvas_x = (local_cursor.x - old_origin_x) / old_zoom;
|
||
let canvas_y = (local_cursor.y - old_origin_y) / old_zoom;
|
||
|
||
let new_display_w = engine_w * new_zoom;
|
||
let new_display_h = engine_h * new_zoom;
|
||
let new_default_ox = (bounds.width - new_display_w) / 2.0;
|
||
let new_default_oy = (bounds.height - new_display_h) / 2.0;
|
||
let desired_ox = local_cursor.x - canvas_x * new_zoom;
|
||
let desired_oy = local_cursor.y - canvas_y * new_zoom;
|
||
|
||
let mut new_pan = self.pan_offset;
|
||
new_pan.x = desired_ox - new_default_ox;
|
||
new_pan.y = desired_oy - new_default_oy;
|
||
|
||
// Clamp
|
||
let new_min_vis_w = new_display_w * 0.25;
|
||
let new_min_vis_h = new_display_h * 0.25;
|
||
let raw_ox = new_default_ox + new_pan.x;
|
||
let raw_oy = new_default_oy + new_pan.y;
|
||
new_pan.x = raw_ox.clamp(
|
||
-(new_display_w - new_min_vis_w).max(0.0),
|
||
(bounds.width - new_min_vis_w).max(0.0),
|
||
) - new_default_ox;
|
||
new_pan.y = raw_oy.clamp(
|
||
-(new_display_h - new_min_vis_h).max(0.0),
|
||
(bounds.height - new_min_vis_h).max(0.0),
|
||
) - new_default_oy;
|
||
|
||
return (
|
||
iced::event::Status::Captured,
|
||
Some(Message::CanvasPanZoom {
|
||
zoom: new_zoom,
|
||
pan_offset: new_pan,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
mouse::Event::CursorLeft => {
|
||
state.is_hovered = false;
|
||
state.cursor_pos = None;
|
||
// Don't reset left_pressed — user might be drawing and cursor left widget
|
||
}
|
||
|
||
_ => {}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
(iced::event::Status::Ignored, None)
|
||
}
|
||
|
||
/// Create the per-frame primitive with dirty region data.
|
||
///
|
||
/// This is called every frame. The primitive carries the dirty region
|
||
/// and pixel data so `prepare()` can upload only the changed sub-region.
|
||
fn draw(
|
||
&self,
|
||
state: &Self::State,
|
||
_cursor: mouse::Cursor,
|
||
bounds: Rectangle,
|
||
) -> Self::Primitive {
|
||
CanvasShaderPrimitive {
|
||
canvas_w: self.engine_w,
|
||
canvas_h: self.engine_h,
|
||
zoom: self.zoom,
|
||
pan_offset: state.local_pan_offset.unwrap_or(self.pan_offset),
|
||
texture_update: self.texture_update.clone(),
|
||
composite_pixels: self.composite_pixels.clone(),
|
||
full_upload: self.full_upload,
|
||
bounds,
|
||
selection_mask: self.selection_mask.clone(),
|
||
selection_dirty: self.selection_dirty,
|
||
anim_time: self.anim_time,
|
||
quick_mask: self.quick_mask,
|
||
}
|
||
}
|
||
|
||
/// Return crosshair cursor when over the canvas widget.
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Self::State,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> mouse::Interaction {
|
||
if state.is_hovered && cursor.is_over(bounds) {
|
||
mouse::Interaction::Crosshair
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod shader_regression_tests {
|
||
use super::{should_publish_cursor_status, CURSOR_STATUS_INTERVAL};
|
||
|
||
/// Prevents restoration of the nine-sample per-fragment selection border detector.
|
||
#[test]
|
||
fn selection_overlay_uses_one_texture_sample() {
|
||
let shader = include_str!("canvas.wgsl");
|
||
assert_eq!(shader.matches("textureSample(selection_texture").count(), 1);
|
||
}
|
||
|
||
/// Confirms status-bar messages are bounded without suppressing the first update.
|
||
///
|
||
/// **Purpose:** Protects the retained overlay from accidental per-pointer Elm
|
||
/// rebuilds. **Logic & Workflow:** Exercises first, early, due, and unchanged
|
||
/// cursor publications. **Arguments & Returns:** None.
|
||
/// **Side Effects / Dependencies:** None.
|
||
#[test]
|
||
fn idle_cursor_status_updates_are_rate_limited() {
|
||
assert!(should_publish_cursor_status(None, (10, 20), None));
|
||
assert!(!should_publish_cursor_status(
|
||
Some((10, 20)),
|
||
(11, 20),
|
||
Some(CURSOR_STATUS_INTERVAL / 2),
|
||
));
|
||
assert!(should_publish_cursor_status(
|
||
Some((10, 20)),
|
||
(11, 20),
|
||
Some(CURSOR_STATUS_INTERVAL),
|
||
));
|
||
assert!(!should_publish_cursor_status(
|
||
Some((11, 20)),
|
||
(11, 20),
|
||
Some(CURSOR_STATUS_INTERVAL * 2),
|
||
));
|
||
}
|
||
}
|