Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs
T

1447 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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),
));
}
}