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:
@@ -751,6 +751,9 @@ impl Engine {
|
|||||||
// ── Filter Ops ───────────────────────────────────────────────────────
|
// ── Filter Ops ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn apply_filter(&mut self, filter_id: &str, params: serde_json::Value) {
|
pub fn apply_filter(&mut self, filter_id: &str, params: serde_json::Value) {
|
||||||
|
// Clone the selection mask before borrowing the layer mutably, to avoid
|
||||||
|
// borrow-checker conflict between `active_layer_mut()` and `selection_mask`.
|
||||||
|
let mask = self.document.selection_mask.clone();
|
||||||
if let Some(layer) = self.document.active_layer_mut() {
|
if let Some(layer) = self.document.active_layer_mut() {
|
||||||
let before_pixels = layer.pixels.clone();
|
let before_pixels = layer.pixels.clone();
|
||||||
let _ = apply_filter(
|
let _ = apply_filter(
|
||||||
@@ -760,6 +763,30 @@ impl Engine {
|
|||||||
layer.width,
|
layer.width,
|
||||||
layer.height,
|
layer.height,
|
||||||
);
|
);
|
||||||
|
// When a selection is active, restore original pixels for unselected
|
||||||
|
// areas so the filter is confined to the selected region.
|
||||||
|
if let Some(ref mask) = mask {
|
||||||
|
let w = layer.width as usize;
|
||||||
|
let h = layer.height as usize;
|
||||||
|
if mask.len() == w * h {
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let idx = y * w + x;
|
||||||
|
if mask[idx] == 0 {
|
||||||
|
let pidx = idx * 4;
|
||||||
|
if pidx + 3 < layer.pixels.len()
|
||||||
|
&& pidx + 3 < before_pixels.len()
|
||||||
|
{
|
||||||
|
layer.pixels[pidx] = before_pixels[pidx];
|
||||||
|
layer.pixels[pidx + 1] = before_pixels[pidx + 1];
|
||||||
|
layer.pixels[pidx + 2] = before_pixels[pidx + 2];
|
||||||
|
layer.pixels[pidx + 3] = before_pixels[pidx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
layer.dirty = true;
|
layer.dirty = true;
|
||||||
self.document.push_draw_snapshot(
|
self.document.push_draw_snapshot(
|
||||||
self.document.active_layer,
|
self.document.active_layer,
|
||||||
@@ -773,7 +800,11 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_filter_preview(&mut self, filter_id: &str, params: serde_json::Value) {
|
pub fn apply_filter_preview(&mut self, filter_id: &str, params: serde_json::Value) {
|
||||||
|
let mask = self.document.selection_mask.clone();
|
||||||
if let Some(layer) = self.document.active_layer_mut() {
|
if let Some(layer) = self.document.active_layer_mut() {
|
||||||
|
// Save the current pixels before applying the filter, so we can
|
||||||
|
// restore unselected areas afterwards.
|
||||||
|
let saved_before = layer.pixels.clone();
|
||||||
let _ = apply_filter(
|
let _ = apply_filter(
|
||||||
filter_id,
|
filter_id,
|
||||||
¶ms,
|
¶ms,
|
||||||
@@ -781,6 +812,30 @@ impl Engine {
|
|||||||
layer.width,
|
layer.width,
|
||||||
layer.height,
|
layer.height,
|
||||||
);
|
);
|
||||||
|
// When a selection is active, restore original pixels for unselected
|
||||||
|
// areas so the filter preview is confined to the selected region.
|
||||||
|
if let Some(ref mask) = mask {
|
||||||
|
let w = layer.width as usize;
|
||||||
|
let h = layer.height as usize;
|
||||||
|
if mask.len() == w * h {
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let idx = y * w + x;
|
||||||
|
if mask[idx] == 0 {
|
||||||
|
let pidx = idx * 4;
|
||||||
|
if pidx + 3 < layer.pixels.len()
|
||||||
|
&& pidx + 3 < saved_before.len()
|
||||||
|
{
|
||||||
|
layer.pixels[pidx] = saved_before[pidx];
|
||||||
|
layer.pixels[pidx + 1] = saved_before[pidx + 1];
|
||||||
|
layer.pixels[pidx + 2] = saved_before[pidx + 2];
|
||||||
|
layer.pixels[pidx + 3] = saved_before[pidx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
layer.dirty = true;
|
layer.dirty = true;
|
||||||
self.document.composite_dirty = true;
|
self.document.composite_dirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,8 +386,14 @@ pub struct IcedDocument {
|
|||||||
pub internal_clipboard: Option<SelectionTransform>,
|
pub internal_clipboard: Option<SelectionTransform>,
|
||||||
/// Selection mask (alpha channel) for the current selection
|
/// Selection mask (alpha channel) for the current selection
|
||||||
pub selection_mask: Option<Vec<u8>>,
|
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)
|
/// Selection bounds (x, y, width, height)
|
||||||
pub selection_bounds: Option<(u32, u32, u32, u32)>,
|
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
|
/// Crop tool state
|
||||||
pub crop_state: CropState,
|
pub crop_state: CropState,
|
||||||
/// Accumulated lasso points (freehand selection) in canvas coordinates.
|
/// 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.
|
/// through `hcie-engine-api` when add/subtract/intersect mode is active.
|
||||||
fn combine_current_selection(&mut self, previous: Option<Vec<u8>>) {
|
fn combine_current_selection(&mut self, previous: Option<Vec<u8>>) {
|
||||||
let doc = &mut self.documents[self.active_doc];
|
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 {
|
let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1194,7 +1208,10 @@ impl HcieIcedApp {
|
|||||||
transform_source: None,
|
transform_source: None,
|
||||||
internal_clipboard: None,
|
internal_clipboard: None,
|
||||||
selection_mask: None,
|
selection_mask: None,
|
||||||
|
selection_mask_dirty: std::cell::Cell::new(false),
|
||||||
selection_bounds: None,
|
selection_bounds: None,
|
||||||
|
selection_history: Vec::new(),
|
||||||
|
selection_history_marker: 0,
|
||||||
crop_state: CropState::default(),
|
crop_state: CropState::default(),
|
||||||
lasso_points: Vec::new(),
|
lasso_points: Vec::new(),
|
||||||
polygon_points: Vec::new(),
|
polygon_points: Vec::new(),
|
||||||
@@ -1673,22 +1690,89 @@ impl HcieIcedApp {
|
|||||||
let doc = &mut self.documents[self.active_doc];
|
let doc = &mut self.documents[self.active_doc];
|
||||||
|
|
||||||
if doc.engine.is_composite_dirty() {
|
if doc.engine.is_composite_dirty() {
|
||||||
// Use get_composite_pixels() instead of render_composite_region()
|
let (dirty_rect, ptr, len) = doc.engine.render_composite_region();
|
||||||
// to avoid segfaults when canvas dimensions change (e.g. after PSD import).
|
if len > 0 && !ptr.is_null() {
|
||||||
// get_composite_pixels() returns a safe Vec<u8> via composite_layers().
|
let mut is_full_copy = false;
|
||||||
let composite = doc.engine.get_composite_pixels();
|
// Resize composite_raw if canvas dimensions changed (e.g. after PSD import)
|
||||||
let size = composite.len();
|
if doc.composite_raw.len() != len {
|
||||||
if size > 0 {
|
doc.composite_raw.resize(len, 0);
|
||||||
doc.composite_raw = composite;
|
doc.full_upload.replace(true);
|
||||||
// Update the shared Arc — if no other reference exists, mutate in-place
|
is_full_copy = true;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
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);
|
doc.render_generation = doc.render_generation.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1698,6 +1782,7 @@ impl HcieIcedApp {
|
|||||||
// Update selection edge cache if mask changed
|
// Update selection edge cache if mask changed
|
||||||
if let Some(mask) = doc.engine.get_selection_mask() {
|
if let Some(mask) = doc.engine.get_selection_mask() {
|
||||||
doc.selection_mask = Some(mask.to_vec());
|
doc.selection_mask = Some(mask.to_vec());
|
||||||
|
doc.selection_mask_dirty.set(true);
|
||||||
doc.selection_bounds = selection::state::mask_bounds(
|
doc.selection_bounds = selection::state::mask_bounds(
|
||||||
mask,
|
mask,
|
||||||
doc.engine.canvas_width(),
|
doc.engine.canvas_width(),
|
||||||
@@ -1718,6 +1803,7 @@ impl HcieIcedApp {
|
|||||||
doc.marching_ants_edges = Some(edges);
|
doc.marching_ants_edges = Some(edges);
|
||||||
} else {
|
} else {
|
||||||
doc.selection_mask = None;
|
doc.selection_mask = None;
|
||||||
|
doc.selection_mask_dirty.set(true);
|
||||||
doc.selection_bounds = None;
|
doc.selection_bounds = None;
|
||||||
doc.selection_fill_spans = None;
|
doc.selection_fill_spans = None;
|
||||||
doc.marching_ants_edges = None;
|
doc.marching_ants_edges = None;
|
||||||
@@ -2202,6 +2288,7 @@ impl HcieIcedApp {
|
|||||||
}
|
}
|
||||||
doc.engine.selection_clear();
|
doc.engine.selection_clear();
|
||||||
doc.selection_mask = Some(mask_data);
|
doc.selection_mask = Some(mask_data);
|
||||||
|
doc.selection_mask_dirty.set(true);
|
||||||
doc.transform_source = Some(tr.clone());
|
doc.transform_source = Some(tr.clone());
|
||||||
doc.selection_transform = Some(tr);
|
doc.selection_transform = Some(tr);
|
||||||
}
|
}
|
||||||
@@ -2288,6 +2375,20 @@ impl HcieIcedApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.tool_state.is_drawing {
|
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();
|
let is_ctrl = self.modifiers.control() || self.modifiers.logo();
|
||||||
if is_ctrl {
|
if is_ctrl {
|
||||||
// Pick color
|
// Pick color
|
||||||
@@ -2388,6 +2489,7 @@ impl HcieIcedApp {
|
|||||||
self.tool_state.last_stroke_pos = Some((x, y));
|
self.tool_state.last_stroke_pos = Some((x, y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end else (non-selection tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle selection drag
|
// Handle selection drag
|
||||||
@@ -2503,6 +2605,19 @@ impl HcieIcedApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.tool_state.is_drawing {
|
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();
|
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].engine.end_stroke(layer_id);
|
||||||
self.documents[self.active_doc]
|
self.documents[self.active_doc]
|
||||||
@@ -2514,6 +2629,7 @@ impl HcieIcedApp {
|
|||||||
self.documents[self.active_doc].modified = true;
|
self.documents[self.active_doc].modified = true;
|
||||||
|
|
||||||
self.refresh_composite_if_needed();
|
self.refresh_composite_if_needed();
|
||||||
|
} // end if !is_selection_tool
|
||||||
}
|
}
|
||||||
|
|
||||||
// End selection drag
|
// End selection drag
|
||||||
@@ -2599,10 +2715,26 @@ impl HcieIcedApp {
|
|||||||
Message::CompositeRefresh | Message::Undo | Message::Redo => {
|
Message::CompositeRefresh | Message::Undo | Message::Redo => {
|
||||||
match message {
|
match message {
|
||||||
Message::Undo => {
|
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 => {
|
Message::Redo => {
|
||||||
self.documents[self.active_doc].engine.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,
|
transform_source: None,
|
||||||
internal_clipboard: None,
|
internal_clipboard: None,
|
||||||
selection_mask: None,
|
selection_mask: None,
|
||||||
|
selection_mask_dirty: std::cell::Cell::new(false),
|
||||||
selection_bounds: None,
|
selection_bounds: None,
|
||||||
|
selection_history: Vec::new(),
|
||||||
|
selection_history_marker: 0,
|
||||||
crop_state: CropState::default(),
|
crop_state: CropState::default(),
|
||||||
lasso_points: Vec::new(),
|
lasso_points: Vec::new(),
|
||||||
polygon_points: Vec::new(),
|
polygon_points: Vec::new(),
|
||||||
@@ -4465,6 +4600,9 @@ impl HcieIcedApp {
|
|||||||
// Store selection bounds for marching ants display
|
// Store selection bounds for marching ants display
|
||||||
self.documents[self.active_doc].selection_bounds =
|
self.documents[self.active_doc].selection_bounds =
|
||||||
Some((sx, sy, ex - sx, ey - sy));
|
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();
|
self.refresh_composite_if_needed();
|
||||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||||
}
|
}
|
||||||
@@ -4473,19 +4611,39 @@ impl HcieIcedApp {
|
|||||||
self.documents[self.active_doc].selection_rect = None;
|
self.documents[self.active_doc].selection_rect = None;
|
||||||
}
|
}
|
||||||
Message::SelectAll => {
|
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 w = self.documents[self.active_doc].engine.canvas_width();
|
||||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||||
self.documents[self.active_doc].engine.selection_all();
|
self.documents[self.active_doc].engine.selection_all();
|
||||||
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
|
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
|
||||||
}
|
}
|
||||||
Message::Deselect => {
|
Message::Deselect => {
|
||||||
self.documents[self.active_doc].selection_rect = None;
|
let doc = &mut self.documents[self.active_doc];
|
||||||
self.documents[self.active_doc].selection_bounds = None;
|
let previous = doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||||||
self.documents[self.active_doc].selection_transform = None;
|
let current_marker = doc.engine.history_current();
|
||||||
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
|
if current_marker != doc.selection_history_marker {
|
||||||
self.documents[self.active_doc].engine.selection_clear();
|
doc.selection_history.clear();
|
||||||
self.documents[self.active_doc].selection_fill_spans = None;
|
doc.selection_history_marker = current_marker;
|
||||||
self.documents[self.active_doc].marching_ants_edges = None;
|
}
|
||||||
|
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 => {
|
Message::SelectInverse => {
|
||||||
self.documents[self.active_doc].engine.selection_invert();
|
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].engine.selection_clear();
|
||||||
self.documents[self.active_doc].selection_bounds = None;
|
self.documents[self.active_doc].selection_bounds = None;
|
||||||
self.documents[self.active_doc].selection_mask = 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_handle = TransformHandle::None;
|
||||||
self.documents[self.active_doc].transform_drag_start = None;
|
self.documents[self.active_doc].transform_drag_start = None;
|
||||||
self.documents[self.active_doc].transform_original = None;
|
self.documents[self.active_doc].transform_original = None;
|
||||||
@@ -6604,7 +6763,10 @@ impl HcieIcedApp {
|
|||||||
transform_source: None,
|
transform_source: None,
|
||||||
internal_clipboard: None,
|
internal_clipboard: None,
|
||||||
selection_mask: None,
|
selection_mask: None,
|
||||||
|
selection_mask_dirty: std::cell::Cell::new(false),
|
||||||
selection_bounds: None,
|
selection_bounds: None,
|
||||||
|
selection_history: Vec::new(),
|
||||||
|
selection_history_marker: 0,
|
||||||
crop_state: CropState::default(),
|
crop_state: CropState::default(),
|
||||||
lasso_points: Vec::new(),
|
lasso_points: Vec::new(),
|
||||||
polygon_points: Vec::new(),
|
polygon_points: Vec::new(),
|
||||||
@@ -7544,7 +7706,9 @@ impl HcieIcedApp {
|
|||||||
Some(Message::SelectAll)
|
Some(Message::SelectAll)
|
||||||
}
|
}
|
||||||
// Ctrl+D = Deselect
|
// 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)
|
Some(Message::Deselect)
|
||||||
}
|
}
|
||||||
// Ctrl+Shift+I = Inverse Selection
|
// 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
|
let marching_ants = if self
|
||||||
.documents
|
.documents
|
||||||
.iter()
|
.iter()
|
||||||
.any(|doc| doc.selection_bounds.is_some())
|
.any(|doc| doc.selection_bounds.is_some())
|
||||||
|
&& !self.tool_state.is_drawing
|
||||||
{
|
{
|
||||||
iced::Subscription::run_with_id(
|
iced::time::every(std::time::Duration::from_millis(50))
|
||||||
std::time::Instant::now(),
|
.map(|_| Message::CompositeRefresh)
|
||||||
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, ()))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
iced::Subscription::none()
|
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
|
// ## Purpose
|
||||||
// Single-pass fragment shader that renders:
|
// Single-pass fragment shader that renders:
|
||||||
// 1. Procedural checkerboard background (no CPU geometry)
|
// 1. Procedural checkerboard background (no CPU geometry)
|
||||||
// 2. Composite texture from the engine (partial-updated via queue.write_texture)
|
// 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
|
// ## Uniforms
|
||||||
// - transform: 2D affine transform (zoom + pan → NDC)
|
// - transform: 2D affine transform (zoom + pan -> NDC)
|
||||||
// - canvas_size: engine canvas dimensions in pixels
|
// - canvas_size: engine canvas dimensions in pixels
|
||||||
// - viewport_size: widget viewport dimensions in pixels
|
// - viewport_size: widget viewport dimensions in pixels
|
||||||
// - checker_size: checkerboard square size 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
|
// ## Bindings
|
||||||
// Two triangles forming a fullscreen quad. Positions are in NDC [-1,1].
|
// - @binding(0): Uniforms
|
||||||
// UV coordinates map to the composite texture.
|
// - @binding(1): Composite texture (RGBA)
|
||||||
|
// - @binding(2): Sampler
|
||||||
|
// - @binding(3): Selection mask (R8: >0.5 = selected)
|
||||||
|
// - @binding(4): Selection mask sampler
|
||||||
|
|
||||||
struct Uniforms {
|
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_x: f32,
|
||||||
scale_y: f32,
|
scale_y: f32,
|
||||||
// Translation in NDC
|
// Translation in NDC
|
||||||
@@ -30,10 +40,12 @@ struct Uniforms {
|
|||||||
viewport_h: f32,
|
viewport_h: f32,
|
||||||
// Checkerboard square size (pixels)
|
// Checkerboard square size (pixels)
|
||||||
checker_size: f32,
|
checker_size: f32,
|
||||||
// Padding for 16-byte alignment
|
// Animation time (seconds) for marching ants
|
||||||
_pad1: f32,
|
anim_time: f32,
|
||||||
_pad2: f32,
|
// 1.0 if selection mask is bound, 0.0 otherwise
|
||||||
_pad3: f32,
|
has_selection: f32,
|
||||||
|
// 1.0 for red quick-mask tint, 0.0 for normal blue tint
|
||||||
|
quick_mask: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@group(0) @binding(0)
|
@group(0) @binding(0)
|
||||||
@@ -45,6 +57,12 @@ var composite_texture: texture_2d<f32>;
|
|||||||
@group(0) @binding(2)
|
@group(0) @binding(2)
|
||||||
var composite_sampler: sampler;
|
var composite_sampler: sampler;
|
||||||
|
|
||||||
|
@group(0) @binding(3)
|
||||||
|
var selection_texture: texture_2d<f32>;
|
||||||
|
|
||||||
|
@group(0) @binding(4)
|
||||||
|
var selection_sampler: sampler;
|
||||||
|
|
||||||
struct VertexInput {
|
struct VertexInput {
|
||||||
@location(0) position: vec2<f32>,
|
@location(0) position: vec2<f32>,
|
||||||
@location(1) uv: vec2<f32>,
|
@location(1) uv: vec2<f32>,
|
||||||
@@ -75,21 +93,87 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
|||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
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_col = floor(in.screen_pos.x / uniforms.checker_size);
|
||||||
let checker_row = floor(in.screen_pos.y / 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 is_light = ((i32(checker_col) + i32(checker_row)) % 2) == 0;
|
||||||
let light_color = vec4<f32>(0.70, 0.70, 0.70, 1.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 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);
|
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
|
// ── 3. Selection overlay (if selection mask is bound) ─────────────────
|
||||||
let blended = vec4<f32>(
|
if uniforms.has_selection > 0.5 {
|
||||||
mix(checker.rgb, tex_color.rgb, tex_color.a),
|
let sel_val = textureSample(selection_texture, selection_sampler, in.uv).r;
|
||||||
1.0,
|
|
||||||
);
|
if sel_val > 0.5 {
|
||||||
return blended;
|
// 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,
|
rect_radius: f32,
|
||||||
/// Gradient drag preview endpoints in canvas-space.
|
/// Gradient drag preview endpoints in canvas-space.
|
||||||
gradient_drag: Option<((f32, f32), (f32, f32))>,
|
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.
|
/// Extracted edges for marching ants rendering.
|
||||||
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
|
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
|
||||||
/// Exact horizontal selected-pixel spans for irregular selection fill.
|
/// 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.vector_draw.is_some()
|
||||||
|| self.selected_vector_bounds.is_some()
|
|| self.selected_vector_bounds.is_some()
|
||||||
|| self.selection_transform.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 {
|
if has_overlays {
|
||||||
let mut frame = Frame::new(renderer, bounds.size());
|
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)
|
// Selection overlay (fill + marching ants) is now rendered by the GPU shader.
|
||||||
if let Some((sx, sy, sw, sh)) = self.selection_bounds {
|
// Fill exact mask runs are handled in the fragment shader via selection mask texture.
|
||||||
if self.selection_transform.is_none() && self.selection_rect.is_none() {
|
// The shader computes:
|
||||||
let sel_x = origin_x + sx as f32 * self.zoom;
|
// - Purple/red fill for selected pixels
|
||||||
let sel_y = origin_y + sy as f32 * self.zoom;
|
// - Animated black+white dashes along the selection border
|
||||||
let sel_w = sw as f32 * self.zoom;
|
|
||||||
let sel_h = sh as f32 * self.zoom;
|
|
||||||
|
|
||||||
// Fill exact mask runs rather than the irregular selection's bounding box.
|
// Draw lasso freehand path while dragging
|
||||||
let fill_color = if self.quick_mask {
|
if self.lasso_points.len() > 1 {
|
||||||
iced::Color::from_rgba(1.0, 0.1, 0.1, 0.35)
|
let lasso_path = Path::new(|b| {
|
||||||
} else {
|
let first = &self.lasso_points[0];
|
||||||
iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12)
|
let start = Point::new(
|
||||||
};
|
origin_x + first.0 as f32 * self.zoom,
|
||||||
if let Some(spans) = &self.selection_fill_spans {
|
origin_y + first.1 as f32 * self.zoom,
|
||||||
for &(start_x, y, end_x) in spans.iter() {
|
);
|
||||||
frame.fill(
|
b.move_to(start);
|
||||||
&Path::rectangle(
|
for pt in &self.lasso_points[1..] {
|
||||||
Point::new(
|
let p = Point::new(
|
||||||
origin_x + start_x as f32 * self.zoom,
|
origin_x + pt.0 as f32 * self.zoom,
|
||||||
origin_y + y as f32 * self.zoom,
|
origin_y + pt.1 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()
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
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(),
|
dirty_region: doc.dirty_region.take(),
|
||||||
full_upload: doc.full_upload.replace(false),
|
full_upload: doc.full_upload.replace(false),
|
||||||
space_pan: tool_state.space_pan,
|
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)
|
let shader_canvas = Shader::new(shader_program)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
@@ -1508,6 +1452,8 @@ pub fn view<'a>(
|
|||||||
polygon_sides,
|
polygon_sides,
|
||||||
rect_radius,
|
rect_radius,
|
||||||
gradient_drag: doc.gradient_drag,
|
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(),
|
marching_ants_edges: doc.marching_ants_edges.clone(),
|
||||||
selection_fill_spans: doc.selection_fill_spans.clone(),
|
selection_fill_spans: doc.selection_fill_spans.clone(),
|
||||||
quick_mask: doc.quick_mask,
|
quick_mask: doc.quick_mask,
|
||||||
|
|||||||
@@ -56,10 +56,12 @@ struct CanvasUniforms {
|
|||||||
viewport_h: f32,
|
viewport_h: f32,
|
||||||
/// Checkerboard square size in pixels
|
/// Checkerboard square size in pixels
|
||||||
checker_size: f32,
|
checker_size: f32,
|
||||||
/// Padding for 16-byte alignment
|
/// Marching ants animation time in seconds
|
||||||
_pad1: f32,
|
anim_time: f32,
|
||||||
_pad2: f32,
|
/// 1.0 if selection mask is bound, 0.0 otherwise
|
||||||
_pad3: f32,
|
has_selection: f32,
|
||||||
|
/// 1.0 for red quick-mask tint, 0.0 for normal blue tint
|
||||||
|
quick_mask: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Vertex struct ───────────────────────────────────────────────────────────
|
// ─── Vertex struct ───────────────────────────────────────────────────────────
|
||||||
@@ -149,6 +151,12 @@ struct CanvasShaderPipeline {
|
|||||||
/// Current texture dimensions (to detect resize)
|
/// Current texture dimensions (to detect resize)
|
||||||
texture_w: u32,
|
texture_w: u32,
|
||||||
texture_h: 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 {
|
impl CanvasShaderPipeline {
|
||||||
@@ -236,6 +244,33 @@ impl CanvasShaderPipeline {
|
|||||||
..Default::default()
|
..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 ───────────────────────────────────────────────
|
// ── Uniform buffer ───────────────────────────────────────────────
|
||||||
let uniform_data = CanvasUniforms {
|
let uniform_data = CanvasUniforms {
|
||||||
scale_x: 1.0,
|
scale_x: 1.0,
|
||||||
@@ -247,9 +282,9 @@ impl CanvasShaderPipeline {
|
|||||||
viewport_w: 800.0,
|
viewport_w: 800.0,
|
||||||
viewport_h: 600.0,
|
viewport_h: 600.0,
|
||||||
checker_size: CHECKER_SQUARE,
|
checker_size: CHECKER_SQUARE,
|
||||||
_pad1: 0.0,
|
anim_time: 0.0,
|
||||||
_pad2: 0.0,
|
has_selection: 0.0,
|
||||||
_pad3: 0.0,
|
quick_mask: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
@@ -284,13 +319,31 @@ impl CanvasShaderPipeline {
|
|||||||
},
|
},
|
||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
// @binding(2): Sampler
|
// @binding(2): Composite sampler
|
||||||
wgpu::BindGroupLayoutEntry {
|
wgpu::BindGroupLayoutEntry {
|
||||||
binding: 2,
|
binding: 2,
|
||||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
count: None,
|
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,
|
binding: 2,
|
||||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
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,
|
sampler,
|
||||||
texture_w: canvas_w,
|
texture_w: canvas_w,
|
||||||
texture_h: canvas_h,
|
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 {
|
self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("hcie-canvas-bind-group"),
|
label: Some("hcie-canvas-bind-group"),
|
||||||
layout: &self.bind_group_layout,
|
layout: &self.bind_group_layout,
|
||||||
@@ -471,6 +554,14 @@ impl CanvasShaderPipeline {
|
|||||||
binding: 2,
|
binding: 2,
|
||||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
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.
|
/// Update the uniform buffer with current zoom/pan/viewport values.
|
||||||
///
|
///
|
||||||
/// ## Arguments
|
/// ## Arguments
|
||||||
@@ -574,6 +704,14 @@ pub struct CanvasShaderPrimitive {
|
|||||||
pub full_upload: bool,
|
pub full_upload: bool,
|
||||||
/// Absolute widget bounds in the window
|
/// Absolute widget bounds in the window
|
||||||
pub bounds: Rectangle,
|
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 {
|
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);
|
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 ─────────────────────────────────────────────
|
// ── Compute uniforms ─────────────────────────────────────────────
|
||||||
let viewport_w = bounds.width;
|
let viewport_w = bounds.width;
|
||||||
let viewport_h = bounds.height;
|
let viewport_h = bounds.height;
|
||||||
@@ -675,9 +820,9 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
|||||||
viewport_w,
|
viewport_w,
|
||||||
viewport_h,
|
viewport_h,
|
||||||
checker_size: CHECKER_SQUARE,
|
checker_size: CHECKER_SQUARE,
|
||||||
_pad1: 0.0,
|
anim_time: self.anim_time,
|
||||||
_pad2: 0.0,
|
has_selection: if self.selection_mask.is_some() { 1.0 } else { 0.0 },
|
||||||
_pad3: 0.0,
|
quick_mask: if self.quick_mask { 1.0 } else { 0.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
pipeline.update_uniforms(queue, &uniforms);
|
pipeline.update_uniforms(queue, &uniforms);
|
||||||
@@ -792,6 +937,14 @@ pub struct CanvasShaderProgram {
|
|||||||
pub full_upload: bool,
|
pub full_upload: bool,
|
||||||
/// Whether Space temporarily changes left-drag into panning.
|
/// Whether Space temporarily changes left-drag into panning.
|
||||||
pub space_pan: bool,
|
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.
|
/// 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(),
|
composite_pixels: self.composite_pixels.clone(),
|
||||||
full_upload: self.full_upload,
|
full_upload: self.full_upload,
|
||||||
bounds,
|
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.
|
/// 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 is_tool_active = tool == tool_state.active_tool;
|
||||||
let c = colors;
|
let c = colors;
|
||||||
|
|
||||||
|
let tool_icon_path = tool_icon(tool);
|
||||||
|
let resolved_path = resolve_icon_path(tool_icon_path);
|
||||||
|
|
||||||
let popup_icon: Element<'_, Message> =
|
let popup_icon: Element<'_, Message> =
|
||||||
svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool))))
|
if resolved_path.exists() {
|
||||||
.width(18)
|
let handle = svg::Handle::from_path(&resolved_path);
|
||||||
.height(18)
|
svg(handle)
|
||||||
.style(move |_theme, _status| svg::Style {
|
.width(18)
|
||||||
color: Some(if is_tool_active {
|
.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
|
iced::Color::WHITE
|
||||||
} else {
|
} else {
|
||||||
c.text_primary
|
c.text_primary
|
||||||
}),
|
})
|
||||||
})
|
.into()
|
||||||
.into();
|
};
|
||||||
|
|
||||||
let item = button(container(popup_icon).width(32).height(28).center(18))
|
let item = button(container(popup_icon).width(32).height(28).center(18))
|
||||||
.on_press(Message::ToolSelected(tool))
|
.on_press(Message::ToolSelected(tool))
|
||||||
@@ -658,7 +674,7 @@ fn tool_button<'a>(
|
|||||||
.padding(1)
|
.padding(1)
|
||||||
.width(68)
|
.width(68)
|
||||||
.style(move |_theme| iced::widget::container::Style {
|
.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),
|
border: iced::Border::default().color(colors.border_high).width(1),
|
||||||
shadow: iced::Shadow {
|
shadow: iced::Shadow {
|
||||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||||
|
|||||||
Reference in New Issue
Block a user