feat: Enhance canvas texture management and selection encoding
- Introduced `SharedCompositePixels` for stable full-canvas pixel storage, enabling efficient pipeline creation and recovery. - Added `TextureUpdate` struct for tightly packed dirty rectangle uploads, reducing unnecessary data copying. - Refactored `upload_dirty_region` to utilize `TextureUpdate`, improving performance by eliminating full buffer scans. - Implemented `encode_selection_texture` to generate an encoded R8 selection mask, optimizing selection rendering. - Updated shader to sample selection texture only once, reducing GPU workload. - Added comprehensive tests for texture updates, selection encoding, and performance diagnostics. - Documented design decisions and validation sequences to ensure future performance stability.
This commit is contained in:
@@ -404,13 +404,11 @@ enum PendingFileOperation {
|
||||
/// Per-document state wrapping an engine instance.
|
||||
pub struct IcedDocument {
|
||||
pub engine: Engine,
|
||||
/// Composite RGBA pixel data shared with the shader pipeline via Arc.
|
||||
/// The shader's `prepare()` reads this and uploads only the dirty region
|
||||
/// via `queue.write_texture()`. Updated in `refresh_composite_if_needed()`.
|
||||
pub composite_pixels: Arc<Vec<u8>>,
|
||||
/// Mutable composite buffer for partial-copy from engine.
|
||||
/// After updating, this is wrapped in a new Arc for the shader.
|
||||
composite_raw: Vec<u8>,
|
||||
/// Stable full RGBA recovery image shared with the persistent shader pipeline.
|
||||
/// Normal frames use packed dirty payloads; this is read only for full texture recovery.
|
||||
pub composite_pixels: crate::canvas::texture_update::SharedCompositePixels,
|
||||
/// Mutable composite buffer for partial copies and packed dirty-update construction.
|
||||
pub(crate) composite_raw: Vec<u8>,
|
||||
/// Document display name.
|
||||
pub name: String,
|
||||
/// Original file path, if loaded from disk.
|
||||
@@ -466,9 +464,13 @@ pub struct IcedDocument {
|
||||
/// When true, the pending_paste pixels should be placed as a new layer
|
||||
/// instead of entering transform mode (set by "Paste as New Layer").
|
||||
pub pending_paste_as_new_layer: bool,
|
||||
/// Selection mask (alpha channel) for the current selection
|
||||
pub selection_mask: Option<Vec<u8>>,
|
||||
/// Whether the selection mask has changed since last GPU upload
|
||||
/// Raw selection mask retained for transform and selection-history workflows.
|
||||
pub selection_mask: Option<Arc<Vec<u8>>>,
|
||||
/// Encoded R8 selection texture: zero unselected, 128 interior, 255 border.
|
||||
pub selection_texture: Option<Arc<Vec<u8>>>,
|
||||
/// Whether a selection engine/local mutation still needs cache synchronization.
|
||||
pub selection_model_dirty: bool,
|
||||
/// Whether the encoded selection texture 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)>,
|
||||
@@ -488,12 +490,6 @@ pub struct IcedDocument {
|
||||
pub vision_rect: Option<(f32, f32, f32, f32)>,
|
||||
/// Draft text state for the on-canvas text tool.
|
||||
pub text_draft: Option<TextDraft>,
|
||||
/// Cache for extracted selection edges (marching ants).
|
||||
pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache,
|
||||
/// Extracted selection edges for overlay rendering.
|
||||
pub marching_ants_edges: Option<Arc<Vec<(u32, u32, u32, u32)>>>,
|
||||
/// Horizontal runs of selected pixels used to fill irregular masks exactly.
|
||||
pub selection_fill_spans: Option<Arc<Vec<(u32, u32, u32)>>>,
|
||||
/// How newly generated selection masks modify the existing mask.
|
||||
pub selection_mode: selection::SelectionMode,
|
||||
/// In-memory saved selection for the document's Load/Save Selection commands.
|
||||
@@ -1563,8 +1559,7 @@ mod transform_transaction_tests {
|
||||
///
|
||||
/// **Logic & Workflow:** Restores only the previous transformed AABB from the stable base, invokes
|
||||
/// the shared inverse-affine compositor for the current AABB, unions both with any pending dirty
|
||||
/// region, and updates the shader Arc in place when uniquely owned. A full-buffer Arc clone is used
|
||||
/// only when Iced still owns the prior frame's immutable Arc.
|
||||
/// region, and copies only that union into the stable shader recovery image.
|
||||
///
|
||||
/// **Arguments:** `doc` contains transform/composite state and `previous_bounds` identifies stale
|
||||
/// preview pixels. **Returns:** Nothing. **Side Effects / Dependencies:** Mutates preview buffers,
|
||||
@@ -1593,15 +1588,13 @@ fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32
|
||||
let pending = doc.dirty_region.replace(None);
|
||||
let upload_bounds = union_regions(pending, bounds);
|
||||
doc.dirty_region.replace(Some(upload_bounds));
|
||||
if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) {
|
||||
restore_preview_region(
|
||||
shader_pixels,
|
||||
&doc.composite_raw,
|
||||
canvas_width,
|
||||
upload_bounds,
|
||||
);
|
||||
} else {
|
||||
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
|
||||
if let Err(error) =
|
||||
doc.composite_pixels
|
||||
.copy_region_from(&doc.composite_raw, canvas_width, upload_bounds)
|
||||
{
|
||||
log::error!("failed to stage transform preview: {error}");
|
||||
doc.composite_pixels.replace(&doc.composite_raw);
|
||||
doc.full_upload.replace(true);
|
||||
}
|
||||
doc.render_generation = doc.render_generation.wrapping_add(1);
|
||||
log::debug!(
|
||||
@@ -1611,6 +1604,60 @@ fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds all GUI selection-rendering state from one raw mask.
|
||||
///
|
||||
/// **Purpose:** Makes selection texture work proportional to selection mutations rather than brush
|
||||
/// refreshes. **Logic & Workflow:** Valid masks are encoded once into interior/border values and
|
||||
/// cached behind `Arc`; empty or invalid masks clear all overlay state. **Arguments:** `doc` is the
|
||||
/// target document and `mask` is an optional locally owned alpha mask. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Updates selection caches and schedules exactly one GPU upload.
|
||||
fn set_document_selection_mask(doc: &mut IcedDocument, mask: Option<Vec<u8>>) {
|
||||
let width = doc.engine.canvas_width();
|
||||
let height = doc.engine.canvas_height();
|
||||
let valid_mask = mask.filter(|mask| mask.len() == (width * height) as usize);
|
||||
if let Some(mask) = valid_mask {
|
||||
let encoded = selection::state::encode_selection_texture(&mask, width, height);
|
||||
if encoded.bounds.is_some() {
|
||||
doc.selection_mask = Some(Arc::new(mask));
|
||||
doc.selection_texture = Some(Arc::new(encoded.pixels));
|
||||
doc.selection_bounds = encoded.bounds;
|
||||
} else {
|
||||
doc.selection_mask = None;
|
||||
doc.selection_texture = None;
|
||||
doc.selection_bounds = None;
|
||||
}
|
||||
} else {
|
||||
doc.selection_mask = None;
|
||||
doc.selection_texture = None;
|
||||
doc.selection_bounds = None;
|
||||
}
|
||||
doc.selection_model_dirty = false;
|
||||
doc.selection_mask_dirty.set(true);
|
||||
}
|
||||
|
||||
/// Consumes one pending selection-cache synchronization request.
|
||||
///
|
||||
/// **Arguments:** `requested` is the document's mutation marker. **Returns:** Whether a rebuild must
|
||||
/// run now. **Side Effects / Dependencies:** Resets the marker so ordinary composite refreshes are
|
||||
/// constant-time until another selection operation explicitly marks it.
|
||||
fn consume_selection_sync_request(requested: &mut bool) -> bool {
|
||||
std::mem::take(requested)
|
||||
}
|
||||
|
||||
/// Synchronizes cached selection rendering state after an engine selection mutation.
|
||||
///
|
||||
/// **Arguments:** `doc` is the document whose engine owns the source mask. **Returns:** Nothing.
|
||||
/// **Logic & Workflow:** A clean marker is a constant-time no-op; a dirty marker clones and encodes
|
||||
/// the engine mask once. **Side Effects / Dependencies:** Reads `hcie-engine-api` selection state and
|
||||
/// may schedule a selection texture upload.
|
||||
fn sync_document_selection_if_needed(doc: &mut IcedDocument) {
|
||||
if !consume_selection_sync_request(&mut doc.selection_model_dirty) {
|
||||
return;
|
||||
}
|
||||
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
|
||||
set_document_selection_mask(doc, mask);
|
||||
}
|
||||
|
||||
impl HcieIcedApp {
|
||||
/// Combines the engine's newly generated selection with the previous mask.
|
||||
///
|
||||
@@ -1628,11 +1675,13 @@ impl HcieIcedApp {
|
||||
doc.selection_history.push(previous.clone());
|
||||
|
||||
let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else {
|
||||
set_document_selection_mask(doc, None);
|
||||
return;
|
||||
};
|
||||
let combined =
|
||||
selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode);
|
||||
doc.engine.set_selection_mask(combined);
|
||||
doc.selection_model_dirty = true;
|
||||
}
|
||||
|
||||
/// Create the initial application state.
|
||||
@@ -1656,7 +1705,9 @@ impl HcieIcedApp {
|
||||
|
||||
let doc = IcedDocument {
|
||||
engine,
|
||||
composite_pixels: Arc::new(composite_raw.clone()),
|
||||
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
|
||||
composite_raw.clone(),
|
||||
),
|
||||
composite_raw,
|
||||
name: "Untitled".to_string(),
|
||||
source_path: None,
|
||||
@@ -1684,6 +1735,8 @@ impl HcieIcedApp {
|
||||
pending_paste: None,
|
||||
pending_paste_as_new_layer: false,
|
||||
selection_mask: None,
|
||||
selection_texture: None,
|
||||
selection_model_dirty: false,
|
||||
selection_mask_dirty: std::cell::Cell::new(false),
|
||||
selection_bounds: None,
|
||||
selection_history: Vec::new(),
|
||||
@@ -1694,9 +1747,6 @@ impl HcieIcedApp {
|
||||
gradient_drag: None,
|
||||
vision_rect: None,
|
||||
text_draft: None,
|
||||
selection_edge_cache: Default::default(),
|
||||
marching_ants_edges: None,
|
||||
selection_fill_spans: None,
|
||||
selection_mode: selection::SelectionMode::Replace,
|
||||
saved_selection_mask: None,
|
||||
quick_mask: false,
|
||||
@@ -1955,8 +2005,14 @@ impl HcieIcedApp {
|
||||
app.documents[0].engine.canvas_height()
|
||||
);
|
||||
app.documents[0].composite_raw = composite;
|
||||
app.documents[0].composite_pixels =
|
||||
std::sync::Arc::new(app.documents[0].composite_raw.clone());
|
||||
app.documents[0]
|
||||
.composite_pixels
|
||||
.replace(&app.documents[0].composite_raw);
|
||||
let selection = app.documents[0]
|
||||
.engine
|
||||
.get_selection_mask()
|
||||
.map(ToOwned::to_owned);
|
||||
set_document_selection_mask(&mut app.documents[0], selection);
|
||||
app.documents[0].full_upload.replace(true);
|
||||
app.documents[0].render_generation =
|
||||
app.documents[0].render_generation.wrapping_add(1);
|
||||
@@ -2183,6 +2239,8 @@ impl HcieIcedApp {
|
||||
}
|
||||
if let Some(next) = self.documents.iter().position(|document| document.modified) {
|
||||
self.active_doc = next;
|
||||
self.documents[next].full_upload.replace(true);
|
||||
self.documents[next].selection_mask_dirty.set(true);
|
||||
self.active_dialog = ActiveDialog::CloseConfirm;
|
||||
Task::none()
|
||||
} else {
|
||||
@@ -2214,6 +2272,8 @@ impl HcieIcedApp {
|
||||
self.vector_drag_last_angle = None;
|
||||
if let Some(doc) = self.documents.get_mut(self.active_doc) {
|
||||
doc.vector_drag_preview = None;
|
||||
doc.full_upload.replace(true);
|
||||
doc.selection_mask_dirty.set(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2228,6 +2288,8 @@ impl HcieIcedApp {
|
||||
self.vector_drag_last = None;
|
||||
if let Some(doc) = self.documents.get_mut(self.active_doc) {
|
||||
doc.vector_drag_preview = None;
|
||||
doc.full_upload.replace(true);
|
||||
doc.selection_mask_dirty.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2270,7 +2332,6 @@ impl HcieIcedApp {
|
||||
// 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;
|
||||
@@ -2280,42 +2341,25 @@ impl HcieIcedApp {
|
||||
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),
|
||||
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());
|
||||
}
|
||||
}
|
||||
if let Err(error) = doc.composite_pixels.copy_region_from(
|
||||
&doc.composite_raw,
|
||||
w as u32,
|
||||
rect,
|
||||
) {
|
||||
log::error!("failed to stage dirty composite region: {error}");
|
||||
doc.composite_pixels.replace(&doc.composite_raw);
|
||||
doc.full_upload.replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
let current = doc.dirty_region.replace(None);
|
||||
@@ -2325,16 +2369,7 @@ impl HcieIcedApp {
|
||||
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.composite_pixels.replace(&doc.composite_raw);
|
||||
doc.full_upload.replace(true);
|
||||
doc.dirty_region.replace(None);
|
||||
}
|
||||
@@ -2345,37 +2380,7 @@ impl HcieIcedApp {
|
||||
doc.engine.clear_dirty_flags();
|
||||
}
|
||||
|
||||
// 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(),
|
||||
doc.engine.canvas_height(),
|
||||
);
|
||||
doc.selection_fill_spans = Some(Arc::new(selection::state::selected_spans(
|
||||
mask,
|
||||
doc.engine.canvas_width(),
|
||||
doc.engine.canvas_height(),
|
||||
)));
|
||||
let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| {
|
||||
doc.selection_edge_cache.rebuild(
|
||||
mask,
|
||||
doc.engine.canvas_width(),
|
||||
doc.engine.canvas_height(),
|
||||
)
|
||||
});
|
||||
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;
|
||||
// Clear cache if selection is dropped so next time it won't mistakenly hit
|
||||
doc.selection_edge_cache = Default::default();
|
||||
}
|
||||
sync_document_selection_if_needed(doc);
|
||||
|
||||
// Always refresh cached panel data (cheap operation)
|
||||
doc.cached_layers = doc.engine.layer_infos();
|
||||
@@ -2896,11 +2901,7 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.create_selection_magic_wand(cx, cy, tol);
|
||||
self.documents[self.active_doc].selection_bounds = {
|
||||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||
Some((0, 0, w, h))
|
||||
};
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
Tool::Lasso => {
|
||||
@@ -2937,6 +2938,7 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.create_selection_polygon(&pts_ref);
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
} else {
|
||||
@@ -2984,8 +2986,7 @@ impl HcieIcedApp {
|
||||
);
|
||||
doc.engine.set_active_layer_pixels(cut_layer);
|
||||
doc.engine.selection_clear();
|
||||
doc.selection_mask = Some(mask_data.clone());
|
||||
doc.selection_mask_dirty.set(true);
|
||||
set_document_selection_mask(doc, Some(mask_data.clone()));
|
||||
doc.transform_source = Some(tr.clone());
|
||||
doc.transform_layer_baseline = Some(original_layer);
|
||||
doc.transform_selection_baseline = Some(mask_data);
|
||||
@@ -3307,8 +3308,6 @@ impl HcieIcedApp {
|
||||
.engine
|
||||
.create_selection_rect(sx, sy, ex, ey);
|
||||
self.combine_current_selection(None);
|
||||
self.documents[self.active_doc].selection_bounds =
|
||||
Some((sx, sy, ex - sx, ey - sy));
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
@@ -3455,7 +3454,7 @@ impl HcieIcedApp {
|
||||
} else {
|
||||
doc.engine.selection_clear();
|
||||
}
|
||||
doc.selection_mask_dirty.set(true);
|
||||
doc.selection_model_dirty = true;
|
||||
}
|
||||
} else {
|
||||
doc.engine.undo();
|
||||
@@ -5211,6 +5210,8 @@ impl HcieIcedApp {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let as_new_layer = doc.pending_paste_as_new_layer;
|
||||
doc.engine.resize_canvas(paste_w, paste_h);
|
||||
doc.engine.selection_clear();
|
||||
set_document_selection_mask(doc, None);
|
||||
let canvas_w = doc.engine.canvas_width();
|
||||
let canvas_h = doc.engine.canvas_height();
|
||||
if let Some((pixels, w, h)) = doc.pending_paste.take() {
|
||||
@@ -5328,7 +5329,9 @@ impl HcieIcedApp {
|
||||
let history_current = engine.history_current();
|
||||
let doc = IcedDocument {
|
||||
engine,
|
||||
composite_pixels: Arc::new(composite_raw.clone()),
|
||||
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
|
||||
composite_raw.clone(),
|
||||
),
|
||||
composite_raw,
|
||||
name,
|
||||
source_path: None,
|
||||
@@ -5356,6 +5359,8 @@ impl HcieIcedApp {
|
||||
pending_paste: None,
|
||||
pending_paste_as_new_layer: false,
|
||||
selection_mask: None,
|
||||
selection_texture: None,
|
||||
selection_model_dirty: false,
|
||||
selection_mask_dirty: std::cell::Cell::new(false),
|
||||
selection_bounds: None,
|
||||
selection_history: Vec::new(),
|
||||
@@ -5366,9 +5371,6 @@ impl HcieIcedApp {
|
||||
gradient_drag: None,
|
||||
vision_rect: None,
|
||||
text_draft: None,
|
||||
selection_edge_cache: Default::default(),
|
||||
marching_ants_edges: None,
|
||||
selection_fill_spans: None,
|
||||
selection_mode: selection::SelectionMode::Replace,
|
||||
saved_selection_mask: None,
|
||||
quick_mask: false,
|
||||
@@ -5505,6 +5507,7 @@ impl HcieIcedApp {
|
||||
}
|
||||
// Selection operations mutate the existing mask in place, so force overlay cache
|
||||
// synchronization before the next marching-ants frame.
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
@@ -5532,6 +5535,8 @@ impl HcieIcedApp {
|
||||
let w = self.dialog_image_size_width.max(1);
|
||||
let h = self.dialog_image_size_height.max(1);
|
||||
self.documents[self.active_doc].engine.resize_canvas(w, h);
|
||||
self.documents[self.active_doc].engine.selection_clear();
|
||||
set_document_selection_mask(&mut self.documents[self.active_doc], None);
|
||||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||||
self.refresh_composite_if_needed();
|
||||
self.documents[self.active_doc].full_upload.replace(true);
|
||||
@@ -5555,6 +5560,8 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.resize_canvas(new_w, new_h);
|
||||
self.documents[self.active_doc].engine.selection_clear();
|
||||
set_document_selection_mask(&mut self.documents[self.active_doc], None);
|
||||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||||
self.refresh_composite_if_needed();
|
||||
self.documents[self.active_doc].full_upload.replace(true);
|
||||
@@ -5586,9 +5593,6 @@ impl HcieIcedApp {
|
||||
.engine
|
||||
.create_selection_rect(sx, sy, ex, ey);
|
||||
self.combine_current_selection(previous);
|
||||
// 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;
|
||||
@@ -5613,10 +5617,9 @@ impl HcieIcedApp {
|
||||
.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));
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
Message::Deselect => {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
@@ -5633,14 +5636,12 @@ impl HcieIcedApp {
|
||||
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;
|
||||
set_document_selection_mask(doc, None);
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
Message::SelectInverse => {
|
||||
self.documents[self.active_doc].engine.selection_invert();
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -5659,6 +5660,7 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.set_selection_mask(mask);
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
@@ -5762,9 +5764,7 @@ impl HcieIcedApp {
|
||||
},
|
||||
);
|
||||
doc.engine.selection_clear();
|
||||
doc.selection_bounds = None;
|
||||
doc.selection_mask = None;
|
||||
doc.selection_mask_dirty.set(true);
|
||||
set_document_selection_mask(doc, None);
|
||||
doc.transform_drag_handle = TransformHandle::None;
|
||||
doc.transform_drag_start = None;
|
||||
doc.transform_original = None;
|
||||
@@ -5791,8 +5791,7 @@ impl HcieIcedApp {
|
||||
}
|
||||
if let Some(mask) = doc.transform_selection_baseline.take() {
|
||||
doc.engine.set_selection_mask(mask.clone());
|
||||
doc.selection_mask = Some(mask);
|
||||
doc.selection_mask_dirty.set(true);
|
||||
set_document_selection_mask(doc, Some(mask));
|
||||
}
|
||||
doc.selection_transform = None;
|
||||
doc.transform_source = None;
|
||||
@@ -7060,11 +7059,6 @@ impl HcieIcedApp {
|
||||
.engine
|
||||
.create_selection_lasso(&pts_ref);
|
||||
self.combine_current_selection(previous);
|
||||
self.documents[self.active_doc].selection_bounds = {
|
||||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||
Some((0, 0, w, h))
|
||||
};
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
@@ -7087,11 +7081,6 @@ impl HcieIcedApp {
|
||||
.engine
|
||||
.create_selection_polygon(&pts_ref);
|
||||
self.combine_current_selection(previous);
|
||||
self.documents[self.active_doc].selection_bounds = {
|
||||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||
Some((0, 0, w, h))
|
||||
};
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
@@ -7157,6 +7146,8 @@ impl HcieIcedApp {
|
||||
Message::CropConfirm => {
|
||||
if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() {
|
||||
self.documents[self.active_doc].engine.crop(x, y, w, h);
|
||||
self.documents[self.active_doc].engine.selection_clear();
|
||||
set_document_selection_mask(&mut self.documents[self.active_doc], None);
|
||||
self.documents[self.active_doc].crop_state.cancel();
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
@@ -7589,8 +7580,7 @@ impl HcieIcedApp {
|
||||
}
|
||||
Message::PasteImage => {
|
||||
// Check internal clipboard first
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
|
||||
{
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
|
||||
// Enter transform mode with the clipboard content
|
||||
let mut placed = transform;
|
||||
placed.pos.x += 10.0;
|
||||
@@ -7615,7 +7605,8 @@ impl HcieIcedApp {
|
||||
}
|
||||
Message::PasteAsNewLayer => {
|
||||
// Check internal clipboard first
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
|
||||
{
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let canvas_w = doc.engine.canvas_width();
|
||||
let canvas_h = doc.engine.canvas_height();
|
||||
@@ -7828,6 +7819,7 @@ impl HcieIcedApp {
|
||||
Ok(()) => {
|
||||
self.show_welcome = false;
|
||||
self.documents[self.active_doc].engine.pre_tile_all_layers();
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.documents[self.active_doc].name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
@@ -8203,7 +8195,9 @@ impl HcieIcedApp {
|
||||
let history_current = engine.history_current();
|
||||
self.documents.push(IcedDocument {
|
||||
engine,
|
||||
composite_pixels: Arc::new(composite_raw.clone()),
|
||||
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
|
||||
composite_raw.clone(),
|
||||
),
|
||||
composite_raw,
|
||||
name: "Untitled".to_string(),
|
||||
source_path: None,
|
||||
@@ -8231,6 +8225,8 @@ impl HcieIcedApp {
|
||||
pending_paste: None,
|
||||
pending_paste_as_new_layer: false,
|
||||
selection_mask: None,
|
||||
selection_texture: None,
|
||||
selection_model_dirty: false,
|
||||
selection_mask_dirty: std::cell::Cell::new(false),
|
||||
selection_bounds: None,
|
||||
selection_history: Vec::new(),
|
||||
@@ -8241,9 +8237,6 @@ impl HcieIcedApp {
|
||||
gradient_drag: None,
|
||||
vision_rect: None,
|
||||
text_draft: None,
|
||||
selection_edge_cache: Default::default(),
|
||||
marching_ants_edges: None,
|
||||
selection_fill_spans: None,
|
||||
selection_mode: selection::SelectionMode::Replace,
|
||||
saved_selection_mask: None,
|
||||
quick_mask: false,
|
||||
@@ -8268,6 +8261,8 @@ impl HcieIcedApp {
|
||||
self.filter_params = serde_json::json!({});
|
||||
}
|
||||
self.active_doc = idx;
|
||||
self.documents[idx].full_upload.replace(true);
|
||||
self.documents[idx].selection_mask_dirty.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8289,6 +8284,8 @@ impl HcieIcedApp {
|
||||
}
|
||||
if modified {
|
||||
self.active_doc = idx;
|
||||
self.documents[idx].full_upload.replace(true);
|
||||
self.documents[idx].selection_mask_dirty.set(true);
|
||||
self.pending_document_close = Some(idx);
|
||||
self.active_dialog = ActiveDialog::CloseConfirm;
|
||||
} else {
|
||||
@@ -8384,8 +8381,16 @@ impl HcieIcedApp {
|
||||
.engine
|
||||
.get_composite_pixels();
|
||||
self.documents[self.active_doc].composite_raw = composite;
|
||||
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(
|
||||
self.documents[self.active_doc].composite_raw.clone(),
|
||||
self.documents[self.active_doc]
|
||||
.composite_pixels
|
||||
.replace(&self.documents[self.active_doc].composite_raw);
|
||||
let selection = self.documents[self.active_doc]
|
||||
.engine
|
||||
.get_selection_mask()
|
||||
.map(ToOwned::to_owned);
|
||||
set_document_selection_mask(
|
||||
&mut self.documents[self.active_doc],
|
||||
selection,
|
||||
);
|
||||
self.documents[self.active_doc].full_upload.replace(true);
|
||||
self.documents[self.active_doc].render_generation = self.documents
|
||||
@@ -8459,15 +8464,14 @@ impl HcieIcedApp {
|
||||
return Task::perform(async {}, |_| Message::LayerFlatten)
|
||||
}
|
||||
MenuCommand::SelectAll => {
|
||||
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
|
||||
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
|
||||
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
|
||||
return self.update(Message::SelectAll);
|
||||
}
|
||||
MenuCommand::Deselect => {
|
||||
self.documents[self.active_doc].selection_rect = None;
|
||||
return self.update(Message::Deselect);
|
||||
}
|
||||
MenuCommand::InvertSelection => {
|
||||
self.documents[self.active_doc].engine.selection_invert();
|
||||
self.documents[self.active_doc].selection_model_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -8669,6 +8673,7 @@ impl HcieIcedApp {
|
||||
doc.engine.set_active_layer_pixels(layer_pixels);
|
||||
}
|
||||
doc.engine.selection_clear();
|
||||
set_document_selection_mask(doc, Some(mask_data));
|
||||
doc.selection_transform = Some(tr);
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
@@ -8976,9 +8981,14 @@ impl HcieIcedApp {
|
||||
let composite =
|
||||
self.documents[document_index].engine.get_composite_pixels();
|
||||
self.documents[document_index].composite_raw = composite;
|
||||
self.documents[document_index].composite_pixels = std::sync::Arc::new(
|
||||
self.documents[document_index].composite_raw.clone(),
|
||||
);
|
||||
self.documents[document_index]
|
||||
.composite_pixels
|
||||
.replace(&self.documents[document_index].composite_raw);
|
||||
let selection = self.documents[document_index]
|
||||
.engine
|
||||
.get_selection_mask()
|
||||
.map(ToOwned::to_owned);
|
||||
set_document_selection_mask(&mut self.documents[document_index], selection);
|
||||
self.documents[document_index].full_upload.replace(true);
|
||||
self.documents[document_index].render_generation = self.documents
|
||||
[document_index]
|
||||
@@ -10014,8 +10024,9 @@ fn active_index_after_document_close(
|
||||
#[cfg(test)]
|
||||
mod cycle_one_ux_tests {
|
||||
use super::{
|
||||
active_index_after_document_close, document_close_disposition, overlay_escape_target,
|
||||
DocumentCloseDisposition, OverlayEscapeTarget,
|
||||
active_index_after_document_close, consume_selection_sync_request,
|
||||
document_close_disposition, overlay_escape_target, union_regions, DocumentCloseDisposition,
|
||||
OverlayEscapeTarget,
|
||||
};
|
||||
|
||||
/// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched.
|
||||
@@ -10062,6 +10073,26 @@ mod cycle_one_ux_tests {
|
||||
assert_eq!(active_index_after_document_close(2, 2, 2), 1);
|
||||
assert_eq!(active_index_after_document_close(0, 2, 2), 0);
|
||||
}
|
||||
|
||||
/// Ensures an unchanged selection cannot trigger work on every drawing refresh.
|
||||
#[test]
|
||||
fn selection_sync_request_is_consumed_once() {
|
||||
let mut requested = true;
|
||||
assert!(consume_selection_sync_request(&mut requested));
|
||||
for _ in 0..120 {
|
||||
assert!(!consume_selection_sync_request(&mut requested));
|
||||
}
|
||||
requested = true;
|
||||
assert!(consume_selection_sync_request(&mut requested));
|
||||
}
|
||||
|
||||
/// Protects the multi-event dirty accumulator from dropping an earlier update.
|
||||
#[test]
|
||||
fn dirty_region_union_retains_all_updates_before_view() {
|
||||
let first = union_regions(None, [10, 20, 30, 40]);
|
||||
let union = union_regions(Some(first), [25, 5, 50, 35]);
|
||||
assert_eq!(union, [10, 5, 50, 40]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the topmost text layer whose rasterized pixels contain the point
|
||||
|
||||
Reference in New Issue
Block a user