feat(canvas): enhance composite shader with selection overlay and marching ants

- Updated the canvas composite shader to include a selection mask fill and animated marching ants border.
- Added new uniforms for animation time, selection mask presence, and quick mask mode.
- Modified the fragment shader to handle selection rendering, including color tinting for selected areas and a dash pattern for the marching ants effect.
- Refactored the OverlayProgram to remove the old marching ants rendering logic, now handled by the GPU shader.
- Introduced new data structures in the shader pipeline for managing selection mask textures and samplers.
- Implemented functionality to upload selection mask data to the GPU.
- Updated selection state handling to ensure correct bounds calculations.
- Enhanced sidebar tool button rendering with fallback for missing icons.
This commit is contained in:
2026-07-17 23:15:27 +03:00
parent b09795ccff
commit b2c88e5471
8 changed files with 634 additions and 209 deletions
+195 -34
View File
@@ -386,8 +386,14 @@ pub struct IcedDocument {
pub internal_clipboard: Option<SelectionTransform>,
/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
/// Whether the selection mask has changed since last GPU upload
pub selection_mask_dirty: std::cell::Cell<bool>,
/// Selection bounds (x, y, width, height)
pub selection_bounds: Option<(u32, u32, u32, u32)>,
/// History of selection masks for undo support.
pub selection_history: Vec<Option<Vec<u8>>>,
/// Engine history index when the selection was last modified.
pub selection_history_marker: i32,
/// Crop tool state
pub crop_state: CropState,
/// Accumulated lasso points (freehand selection) in canvas coordinates.
@@ -1142,6 +1148,14 @@ impl HcieIcedApp {
/// through `hcie-engine-api` when add/subtract/intersect mode is active.
fn combine_current_selection(&mut self, previous: Option<Vec<u8>>) {
let doc = &mut self.documents[self.active_doc];
let current_marker = doc.engine.history_current();
if current_marker != doc.selection_history_marker {
doc.selection_history.clear();
doc.selection_history_marker = current_marker;
}
doc.selection_history.push(previous.clone());
let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else {
return;
};
@@ -1194,7 +1208,10 @@ impl HcieIcedApp {
transform_source: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
@@ -1673,22 +1690,89 @@ impl HcieIcedApp {
let doc = &mut self.documents[self.active_doc];
if doc.engine.is_composite_dirty() {
// Use get_composite_pixels() instead of render_composite_region()
// to avoid segfaults when canvas dimensions change (e.g. after PSD import).
// get_composite_pixels() returns a safe Vec<u8> via composite_layers().
let composite = doc.engine.get_composite_pixels();
let size = composite.len();
if size > 0 {
doc.composite_raw = composite;
// Update the shared Arc — if no other reference exists, mutate in-place
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
pixels.resize(doc.composite_raw.len(), 0);
pixels.copy_from_slice(&doc.composite_raw);
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
let (dirty_rect, ptr, len) = doc.engine.render_composite_region();
if len > 0 && !ptr.is_null() {
let mut is_full_copy = false;
// Resize composite_raw if canvas dimensions changed (e.g. after PSD import)
if doc.composite_raw.len() != len {
doc.composite_raw.resize(len, 0);
doc.full_upload.replace(true);
is_full_copy = true;
}
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
// If the canvas changed size, we MUST force a full upload because the old memory
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
let w = doc.engine.canvas_width() as usize;
let h = doc.engine.canvas_height() as usize;
let [x0, y0, x1, y1] = rect;
let x0 = x0 as usize;
let y0 = y0 as usize;
let x1 = x1 as usize;
let y1 = y1 as usize;
let row_bytes = (x1 - x0) * 4;
if row_bytes > 0 {
unsafe {
let src_ptr = ptr;
let dst_raw_ptr = doc.composite_raw.as_mut_ptr();
let mut pixels_ptr = std::ptr::null_mut();
let mut need_new_arc = false;
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
// Important: resize arc if needed before taking mutable pointer
if pixels.len() != len {
pixels.resize(len, 0);
}
pixels_ptr = pixels.as_mut_ptr();
} else {
need_new_arc = true;
}
for y in y0..y1 {
let offset = (y * w + x0) * 4;
std::ptr::copy_nonoverlapping(
src_ptr.add(offset),
dst_raw_ptr.add(offset),
row_bytes
);
if !pixels_ptr.is_null() {
std::ptr::copy_nonoverlapping(
src_ptr.add(offset),
pixels_ptr.add(offset),
row_bytes
);
}
}
if need_new_arc {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
}
}
let current = doc.dirty_region.replace(None);
doc.dirty_region.replace(Some(union_regions(current, rect)));
} else {
// Full update
unsafe {
std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len);
}
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
if pixels.len() != len {
pixels.resize(len, 0);
}
unsafe {
std::ptr::copy_nonoverlapping(ptr, pixels.as_mut_ptr(), len);
}
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
}
doc.render_generation = doc.render_generation.wrapping_add(1);
}
@@ -1698,6 +1782,7 @@ impl HcieIcedApp {
// Update selection edge cache if mask changed
if let Some(mask) = doc.engine.get_selection_mask() {
doc.selection_mask = Some(mask.to_vec());
doc.selection_mask_dirty.set(true);
doc.selection_bounds = selection::state::mask_bounds(
mask,
doc.engine.canvas_width(),
@@ -1718,6 +1803,7 @@ impl HcieIcedApp {
doc.marching_ants_edges = Some(edges);
} else {
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.selection_bounds = None;
doc.selection_fill_spans = None;
doc.marching_ants_edges = None;
@@ -2202,6 +2288,7 @@ impl HcieIcedApp {
}
doc.engine.selection_clear();
doc.selection_mask = Some(mask_data);
doc.selection_mask_dirty.set(true);
doc.transform_source = Some(tr.clone());
doc.selection_transform = Some(tr);
}
@@ -2288,6 +2375,20 @@ impl HcieIcedApp {
}
if self.tool_state.is_drawing {
// Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing
// to track drag state but must not enter the brush code path.
let is_selection_tool = matches!(
self.tool_state.active_tool,
Tool::Lasso
| Tool::PolygonSelect
| Tool::Select
| Tool::VisionSelect
| Tool::MagicWand
| Tool::SmartSelect
);
if is_selection_tool {
// Fall through to the dedicated selection drag handlers below.
} else {
let is_ctrl = self.modifiers.control() || self.modifiers.logo();
if is_ctrl {
// Pick color
@@ -2388,6 +2489,7 @@ impl HcieIcedApp {
self.tool_state.last_stroke_pos = Some((x, y));
}
}
} // end else (non-selection tool)
}
// Handle selection drag
@@ -2503,6 +2605,19 @@ impl HcieIcedApp {
}
if self.tool_state.is_drawing {
// Selection tools use is_drawing to track drag state but never
// start a brush stroke — skip end_stroke / commit_pending_history
// so they don't push a bogus undo entry that empties the canvas.
let is_selection_tool = matches!(
self.tool_state.active_tool,
Tool::Lasso
| Tool::PolygonSelect
| Tool::Select
| Tool::VisionSelect
| Tool::MagicWand
| Tool::SmartSelect
);
if !is_selection_tool {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.documents[self.active_doc].engine.end_stroke(layer_id);
self.documents[self.active_doc]
@@ -2514,6 +2629,7 @@ impl HcieIcedApp {
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
} // end if !is_selection_tool
}
// End selection drag
@@ -2599,10 +2715,26 @@ impl HcieIcedApp {
Message::CompositeRefresh | Message::Undo | Message::Redo => {
match message {
Message::Undo => {
self.documents[self.active_doc].engine.undo();
let doc = &mut self.documents[self.active_doc];
let current_marker = doc.engine.history_current();
if current_marker == doc.selection_history_marker && !doc.selection_history.is_empty() {
// Undo selection
if let Some(prev_mask) = doc.selection_history.pop() {
if let Some(mask) = prev_mask {
doc.engine.set_selection_mask(mask);
} else {
doc.engine.selection_clear();
}
doc.selection_mask_dirty.set(true);
}
} else {
doc.engine.undo();
doc.selection_history.clear();
}
}
Message::Redo => {
self.documents[self.active_doc].engine.redo();
self.documents[self.active_doc].selection_history.clear();
}
_ => {}
}
@@ -4254,7 +4386,10 @@ impl HcieIcedApp {
transform_source: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
@@ -4465,6 +4600,9 @@ impl HcieIcedApp {
// Store selection bounds for marching ants display
self.documents[self.active_doc].selection_bounds =
Some((sx, sy, ex - sx, ey - sy));
// Clear the drag-preview rect so the blue rectangle
// disappears and the marching ants overlay takes over.
self.documents[self.active_doc].selection_rect = None;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
@@ -4473,19 +4611,39 @@ impl HcieIcedApp {
self.documents[self.active_doc].selection_rect = None;
}
Message::SelectAll => {
let previous = self.documents[self.active_doc].engine.get_selection_mask().map(ToOwned::to_owned);
let current_marker = self.documents[self.active_doc].engine.history_current();
if current_marker != self.documents[self.active_doc].selection_history_marker {
self.documents[self.active_doc].selection_history.clear();
self.documents[self.active_doc].selection_history_marker = current_marker;
}
self.documents[self.active_doc].selection_history.push(previous);
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
self.documents[self.active_doc].engine.selection_all();
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
}
Message::Deselect => {
self.documents[self.active_doc].selection_rect = None;
self.documents[self.active_doc].selection_bounds = None;
self.documents[self.active_doc].selection_transform = None;
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
self.documents[self.active_doc].engine.selection_clear();
self.documents[self.active_doc].selection_fill_spans = None;
self.documents[self.active_doc].marching_ants_edges = None;
let doc = &mut self.documents[self.active_doc];
let previous = doc.engine.get_selection_mask().map(ToOwned::to_owned);
let current_marker = doc.engine.history_current();
if current_marker != doc.selection_history_marker {
doc.selection_history.clear();
doc.selection_history_marker = current_marker;
}
doc.selection_history.push(previous);
doc.selection_rect = None;
doc.selection_bounds = None;
doc.selection_transform = None;
doc.transform_drag_handle = TransformHandle::None;
doc.engine.selection_clear();
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.selection_fill_spans = None;
doc.marching_ants_edges = None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::SelectInverse => {
self.documents[self.active_doc].engine.selection_invert();
@@ -4632,6 +4790,7 @@ impl HcieIcedApp {
self.documents[self.active_doc].engine.selection_clear();
self.documents[self.active_doc].selection_bounds = None;
self.documents[self.active_doc].selection_mask = None;
self.documents[self.active_doc].selection_mask_dirty.set(true);
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
self.documents[self.active_doc].transform_drag_start = None;
self.documents[self.active_doc].transform_original = None;
@@ -6604,7 +6763,10 @@ impl HcieIcedApp {
transform_source: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
@@ -7544,7 +7706,9 @@ impl HcieIcedApp {
Some(Message::SelectAll)
}
// Ctrl+D = Deselect
iced::keyboard::Key::Character(ref c) if c.as_str() == "d" && ctrl && !shift => {
iced::keyboard::Key::Character(ref c)
if c.as_str().eq_ignore_ascii_case("d") && ctrl && !shift =>
{
Some(Message::Deselect)
}
// Ctrl+Shift+I = Inverse Selection
@@ -7749,21 +7913,18 @@ impl HcieIcedApp {
}
});
// Timer for marching ants animation - fires every 50ms when selection is active
// The marching ants are rendered in the persistent GPU canvas shader.
// Use a real timer rather than an immediately-ready async loop: the old
// loop generated an unbounded stream of CompositeRefresh messages and
// starved pointer events while drawing.
let marching_ants = if self
.documents
.iter()
.any(|doc| doc.selection_bounds.is_some())
&& !self.tool_state.is_drawing
{
iced::Subscription::run_with_id(
std::time::Instant::now(),
iced::futures::stream::unfold((), |_| async {
// Wait 50ms between animation frames (~20fps for smooth marching ants)
iced::futures::future::ready(()).await;
// Use a simple yield to create animation frames
Some((Message::CompositeRefresh, ()))
}),
)
iced::time::every(std::time::Duration::from_millis(50))
.map(|_| Message::CompositeRefresh)
} else {
iced::Subscription::none()
};
@@ -0,0 +1 @@
{"pid":293615,"startedAt":1784244897004}
@@ -1,22 +1,32 @@
// Canvas composite shader — draws the composite texture with zoom/pan/checkerboard.
// Canvas composite shader — draws composite texture with zoom/pan/checkerboard
// and GPU-accelerated marching ants selection overlay.
//
// ## Purpose
// Single-pass fragment shader that renders:
// 1. Procedural checkerboard background (no CPU geometry)
// 2. Composite texture from the engine (partial-updated via queue.write_texture)
// 3. Selection mask fill (purple/red tint over selected pixels)
// 4. Marching ants border (animated black+white dashes along selection edge)
//
// ## Uniforms
// - transform: 2D affine transform (zoom + pan NDC)
// - transform: 2D affine transform (zoom + pan -> NDC)
// - canvas_size: engine canvas dimensions in pixels
// - viewport_size: widget viewport dimensions in pixels
// - checker_size: checkerboard square size in pixels
// - anim_time: marching ants animation time in seconds
// - has_selection: 1.0 if selection mask is bound, 0.0 otherwise
// - quick_mask: 1.0 for red quick-mask, 0.0 for normal blue tint
// - _pad: padding for 16-byte alignment
//
// ## Vertex Layout
// Two triangles forming a fullscreen quad. Positions are in NDC [-1,1].
// UV coordinates map to the composite texture.
// ## Bindings
// - @binding(0): Uniforms
// - @binding(1): Composite texture (RGBA)
// - @binding(2): Sampler
// - @binding(3): Selection mask (R8: >0.5 = selected)
// - @binding(4): Selection mask sampler
struct Uniforms {
// Column-major 2×2 scale/rotation (we only use scale)
// Column-major 2x2 scale/rotation (we only use scale)
scale_x: f32,
scale_y: f32,
// Translation in NDC
@@ -30,10 +40,12 @@ struct Uniforms {
viewport_h: f32,
// Checkerboard square size (pixels)
checker_size: f32,
// Padding for 16-byte alignment
_pad1: f32,
_pad2: f32,
_pad3: f32,
// Animation time (seconds) for marching ants
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,
}
@group(0) @binding(0)
@@ -45,6 +57,12 @@ var composite_texture: texture_2d<f32>;
@group(0) @binding(2)
var composite_sampler: sampler;
@group(0) @binding(3)
var selection_texture: texture_2d<f32>;
@group(0) @binding(4)
var selection_sampler: sampler;
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@@ -75,21 +93,87 @@ fn vs_main(in: VertexInput) -> VertexOutput {
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Checkerboard pattern (screen-space, fixed pixel size)
// ── 1. Checkerboard pattern (screen-space, fixed pixel size) ──────────
let checker_col = floor(in.screen_pos.x / uniforms.checker_size);
let checker_row = floor(in.screen_pos.y / uniforms.checker_size);
let is_light = ((i32(checker_col) + i32(checker_row)) % 2) == 0;
let light_color = vec4<f32>(0.70, 0.70, 0.70, 1.0);
let dark_color = vec4<f32>(0.50, 0.50, 0.50, 1.0);
let checker = select(dark_color, light_color, is_light);
var color = select(dark_color, light_color, is_light);
// Sample composite texture
// ── 2. Composite texture ─────────────────────────────────────────────
let tex_color = textureSample(composite_texture, composite_sampler, in.uv);
color = vec4<f32>(mix(color.rgb, tex_color.rgb, tex_color.a), 1.0);
// Alpha-blend composite over checkerboard
let blended = vec4<f32>(
mix(checker.rgb, tex_color.rgb, tex_color.a),
1.0,
);
return blended;
// ── 3. Selection overlay (if selection mask is bound) ─────────────────
if uniforms.has_selection > 0.5 {
let sel_val = textureSample(selection_texture, selection_sampler, in.uv).r;
if sel_val > 0.5 {
// This pixel is selected — compute distance to nearest border
// by sampling 8 neighbors at 1-pixel offset in canvas space
let texel_w = 1.0 / uniforms.canvas_w;
let texel_h = 1.0 / uniforms.canvas_h;
// Sample 8 neighbors to detect border (unrolled — WGSL forbids variable-indexed textureSample)
var is_border = false;
let n0 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(-texel_w, 0.0)).r;
let n1 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>( texel_w, 0.0)).r;
let n2 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(0.0, -texel_h)).r;
let n3 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(0.0, texel_h)).r;
let n4 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(-texel_w, -texel_h)).r;
let n5 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>( texel_w, -texel_h)).r;
let n6 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(-texel_w, texel_h)).r;
let n7 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>( texel_w, texel_h)).r;
if n0 <= 0.5 || n1 <= 0.5 || n2 <= 0.5 || n3 <= 0.5 || n4 <= 0.5 || n5 <= 0.5 || n6 <= 0.5 || n7 <= 0.5 {
is_border = true;
}
if is_border {
// ── Marching ants border ──────────────────────────────────
// Use canvas-space position for the dash pattern
let canvas_x = in.uv.x * uniforms.canvas_w;
let canvas_y = in.uv.y * uniforms.canvas_h;
// Diagonal dash pattern (combines X and Y for diagonal movement)
let dash_length = 8.0;
let gap_length = 4.0;
let cycle = dash_length + gap_length;
let speed = 22.0; // pixels per second
// Diagonal offset: moves dashes diagonally for the marching effect
let pattern_pos = (canvas_x + canvas_y + uniforms.anim_time * speed) % cycle;
if pattern_pos < dash_length {
// White dash
color = vec4<f32>(1.0, 1.0, 1.0, 0.9);
} else {
// Black gap
color = vec4<f32>(0.0, 0.0, 0.0, 0.8);
}
} else {
// ── Selected area fill ────────────────────────────────────
if uniforms.quick_mask > 0.5 {
// Quick mask: red tint
color = vec4<f32>(
mix(color.r, 1.0, 0.35),
mix(color.g, 0.1, 0.35),
mix(color.b, 0.1, 0.35),
1.0
);
} else {
// Normal selection: blue/purple tint
color = vec4<f32>(
mix(color.r, 0.4, 0.12),
mix(color.g, 0.5, 0.12),
mix(color.b, 1.0, 0.12),
1.0
);
}
}
}
}
return color;
}
@@ -77,6 +77,10 @@ struct OverlayProgram {
rect_radius: f32,
/// Gradient drag preview endpoints in canvas-space.
gradient_drag: Option<((f32, f32), (f32, f32))>,
/// Lasso freehand path points accumulated during drag (canvas-space).
lasso_points: Vec<(u32, u32)>,
/// Polygon selection points accumulated during click-by-click (canvas-space).
polygon_points: Vec<(u32, u32)>,
/// Extracted edges for marching ants rendering.
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
/// Exact horizontal selected-pixel spans for irregular selection fill.
@@ -578,7 +582,9 @@ impl canvas::Program<Message> for OverlayProgram {
|| self.vector_draw.is_some()
|| self.selected_vector_bounds.is_some()
|| self.selection_transform.is_some()
|| self.crop_state.as_ref().map_or(false, |c| c.active);
|| self.crop_state.as_ref().map_or(false, |c| c.active)
|| !self.lasso_points.is_empty()
|| !self.polygon_points.is_empty();
if has_overlays {
let mut frame = Frame::new(renderer, bounds.size());
@@ -605,142 +611,75 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
// Draw marching ants for active selection (after selection is created)
if let Some((sx, sy, sw, sh)) = self.selection_bounds {
if self.selection_transform.is_none() && self.selection_rect.is_none() {
let sel_x = origin_x + sx as f32 * self.zoom;
let sel_y = origin_y + sy as f32 * self.zoom;
let sel_w = sw as f32 * self.zoom;
let sel_h = sh as f32 * self.zoom;
// Selection overlay (fill + marching ants) is now rendered by the GPU shader.
// Fill exact mask runs are handled in the fragment shader via selection mask texture.
// The shader computes:
// - Purple/red fill for selected pixels
// - Animated black+white dashes along the selection border
// Fill exact mask runs rather than the irregular selection's bounding box.
let fill_color = if self.quick_mask {
iced::Color::from_rgba(1.0, 0.1, 0.1, 0.35)
} else {
iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12)
};
if let Some(spans) = &self.selection_fill_spans {
for &(start_x, y, end_x) in spans.iter() {
frame.fill(
&Path::rectangle(
Point::new(
origin_x + start_x as f32 * self.zoom,
origin_y + y as f32 * self.zoom,
),
Size::new(
(end_x - start_x) as f32 * self.zoom,
self.zoom.max(1.0),
),
),
fill_color,
);
}
}
// Calculate dash offset for marching ants animation
// The offset moves the dash pattern to create the marching effect
let dash_length = 8.0;
let gap_length = 4.0;
let total_cycle = dash_length + gap_length;
let animated_offset = (self.marching_ants_offset * total_cycle) as usize;
// Draw white dashes (foreground) - offset by half cycle for marching effect
let white_offset =
(animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize);
if let Some(edges) = &self.marching_ants_edges {
// Irregular selection bounds (marching ants along extracted edges)
for &(x1, y1, x2, y2) in edges.iter() {
let s1 = Point::new(
origin_x + x1 as f32 * self.zoom,
origin_y + y1 as f32 * self.zoom,
);
let s2 = Point::new(
origin_x + x2 as f32 * self.zoom,
origin_y + y2 as f32 * self.zoom,
);
// Optimization: we could clip, but `Path::line` handles it generally.
let dist = ((s1.x - s2.x).powi(2) + (s1.y - s2.y).powi(2)).sqrt();
if dist < 1e-6 {
continue;
}
let line_path = Path::line(s1, s2);
frame.stroke(
&line_path,
Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(
0.0, 0.0, 0.0,
)),
width: 1.5,
..Default::default()
},
);
let dir_x = (s2.x - s1.x) / dist;
let dir_y = (s2.y - s1.y) / dist;
let mut curr = -(self.marching_ants_offset * total_cycle);
let mut iter = 0u32;
while curr < dist && iter < 1000 {
iter += 1;
let start_d = curr.max(0.0);
let end_d = (curr + dash_length).min(dist);
if end_d > start_d {
let p_start =
Point::new(s1.x + dir_x * start_d, s1.y + dir_y * start_d);
let p_end =
Point::new(s1.x + dir_x * end_d, s1.y + dir_y * end_d);
let dash_path = Path::line(p_start, p_end);
frame.stroke(
&dash_path,
Stroke {
style: canvas::stroke::Style::Solid(
iced::Color::from_rgb(1.0, 1.0, 1.0),
),
width: 1.5,
..Default::default()
},
);
}
curr += total_cycle;
}
}
} else {
// Fallback to bounding box marching ants
let sel_path =
Path::rectangle(Point::new(sel_x, sel_y), Size::new(sel_w, sel_h));
frame.stroke(
&sel_path,
Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(
0.0, 0.0, 0.0,
)),
width: 2.0 / self.zoom.max(1.0),
line_dash: canvas::LineDash {
segments: &[dash_length, gap_length],
offset: animated_offset,
},
..Default::default()
},
);
frame.stroke(
&sel_path,
Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(
1.0, 1.0, 1.0,
)),
width: 2.0 / self.zoom.max(1.0),
line_dash: canvas::LineDash {
segments: &[dash_length, gap_length],
offset: white_offset,
},
..Default::default()
},
// Draw lasso freehand path while dragging
if self.lasso_points.len() > 1 {
let lasso_path = Path::new(|b| {
let first = &self.lasso_points[0];
let start = Point::new(
origin_x + first.0 as f32 * self.zoom,
origin_y + first.1 as f32 * self.zoom,
);
b.move_to(start);
for pt in &self.lasso_points[1..] {
let p = Point::new(
origin_x + pt.0 as f32 * self.zoom,
origin_y + pt.1 as f32 * self.zoom,
);
b.line_to(p);
}
b.close();
});
frame.stroke(
&lasso_path,
Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)),
width: 1.5 / self.zoom.max(1.0),
..Default::default()
},
);
}
// Draw polygon selection points while placing vertices
if self.polygon_points.len() > 1 {
let poly_path = Path::new(|b| {
let first = &self.polygon_points[0];
let start = Point::new(
origin_x + first.0 as f32 * self.zoom,
origin_y + first.1 as f32 * self.zoom,
);
b.move_to(start);
for pt in &self.polygon_points[1..] {
let p = Point::new(
origin_x + pt.0 as f32 * self.zoom,
origin_y + pt.1 as f32 * self.zoom,
);
b.line_to(p);
}
});
frame.stroke(
&poly_path,
Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)),
width: 1.5 / self.zoom.max(1.0),
..Default::default()
},
);
// Draw vertex dots
for pt in &self.polygon_points {
let p = Point::new(
origin_x + pt.0 as f32 * self.zoom,
origin_y + pt.1 as f32 * self.zoom,
);
frame.fill(
&Path::circle(p, 3.0 / self.zoom.max(1.0)),
iced::Color::from_rgb(0.0, 0.6, 1.0),
);
}
}
@@ -1465,7 +1404,12 @@ pub fn view<'a>(
dirty_region: doc.dirty_region.take(),
full_upload: doc.full_upload.replace(false),
space_pan: tool_state.space_pan,
selection_mask: doc.selection_mask.clone(),
selection_dirty: doc.selection_mask_dirty.get(),
anim_time: marching_ants_offset * 2.0, // Convert 0..1 to seconds (2s cycle)
quick_mask: doc.quick_mask,
};
doc.selection_mask_dirty.set(false);
let shader_canvas = Shader::new(shader_program)
.width(Length::Fill)
@@ -1508,6 +1452,8 @@ pub fn view<'a>(
polygon_sides,
rect_radius,
gradient_drag: doc.gradient_drag,
lasso_points: doc.lasso_points.clone(),
polygon_points: doc.polygon_points.clone(),
marching_ants_edges: doc.marching_ants_edges.clone(),
selection_fill_spans: doc.selection_fill_spans.clone(),
quick_mask: doc.quick_mask,
@@ -56,10 +56,12 @@ struct CanvasUniforms {
viewport_h: f32,
/// Checkerboard square size in pixels
checker_size: f32,
/// Padding for 16-byte alignment
_pad1: f32,
_pad2: f32,
_pad3: 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 ───────────────────────────────────────────────────────────
@@ -149,6 +151,12 @@ struct CanvasShaderPipeline {
/// 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 {
@@ -236,6 +244,33 @@ impl CanvasShaderPipeline {
..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,
@@ -247,9 +282,9 @@ impl CanvasShaderPipeline {
viewport_w: 800.0,
viewport_h: 600.0,
checker_size: CHECKER_SQUARE,
_pad1: 0.0,
_pad2: 0.0,
_pad3: 0.0,
anim_time: 0.0,
has_selection: 0.0,
quick_mask: 0.0,
};
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -284,13 +319,31 @@ impl CanvasShaderPipeline {
},
count: None,
},
// @binding(2): Sampler
// @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,
},
],
});
@@ -311,6 +364,14 @@ impl CanvasShaderPipeline {
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),
},
],
});
@@ -385,6 +446,9 @@ impl CanvasShaderPipeline {
sampler,
texture_w: canvas_w,
texture_h: canvas_h,
sel_texture,
sel_texture_view,
sel_sampler,
}
}
@@ -454,7 +518,26 @@ impl CanvasShaderPipeline {
);
}
// Recreate bind group with new texture view
// 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,
@@ -471,6 +554,14 @@ impl CanvasShaderPipeline {
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),
},
],
});
@@ -540,6 +631,45 @@ impl CanvasShaderPipeline {
);
}
/// 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
@@ -574,6 +704,14 @@ pub struct CanvasShaderPrimitive {
pub full_upload: bool,
/// Absolute widget bounds in the window
pub bounds: Rectangle,
/// Selection mask data (1 byte per pixel, >128 = selected), if any
pub selection_mask: Option<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 {
@@ -634,6 +772,13 @@ impl shader::Primitive for CanvasShaderPrimitive {
pipeline.upload_dirty_region(queue, region, &self.composite_pixels, self.canvas_w);
}
// ── 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;
@@ -675,9 +820,9 @@ impl shader::Primitive for CanvasShaderPrimitive {
viewport_w,
viewport_h,
checker_size: CHECKER_SQUARE,
_pad1: 0.0,
_pad2: 0.0,
_pad3: 0.0,
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);
@@ -792,6 +937,14 @@ pub struct CanvasShaderProgram {
pub full_upload: bool,
/// Whether Space temporarily changes left-drag into panning.
pub space_pan: bool,
/// Selection mask data (1 byte per pixel, >128 = selected), if any.
pub selection_mask: Option<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.
@@ -1120,6 +1273,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
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,
}
}
@@ -65,7 +65,12 @@ pub fn mask_bounds(mask: &[u8], width: u32, height: u32) -> Option<(u32, u32, u3
}
}
}
found.then_some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
found.then_some((
min_x,
min_y,
max_x.saturating_sub(min_x).saturating_add(1),
max_y.saturating_sub(min_y).saturating_add(1),
))
}
/// Compresses selected pixels into horizontal spans for efficient irregular overlay filling.
@@ -582,18 +582,34 @@ fn tool_button<'a>(
let is_tool_active = tool == tool_state.active_tool;
let c = colors;
let tool_icon_path = tool_icon(tool);
let resolved_path = resolve_icon_path(tool_icon_path);
let popup_icon: Element<'_, Message> =
svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool))))
.width(18)
.height(18)
.style(move |_theme, _status| svg::Style {
color: Some(if is_tool_active {
if resolved_path.exists() {
let handle = svg::Handle::from_path(&resolved_path);
svg(handle)
.width(18)
.height(18)
.style(move |_theme, _status| svg::Style {
color: Some(if is_tool_active {
iced::Color::WHITE
} else {
c.text_primary
}),
})
.into()
} else {
// Fallback: show first letter of tool name when icon is missing
text(tool_name.chars().next().unwrap_or('?').to_string())
.size(14)
.color(if is_tool_active {
iced::Color::WHITE
} else {
c.text_primary
}),
})
.into();
})
.into()
};
let item = button(container(popup_icon).width(32).height(28).center(18))
.on_press(Message::ToolSelected(tool))
@@ -658,7 +674,7 @@ fn tool_button<'a>(
.padding(1)
.width(68)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_high).width(1),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),