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:
2026-07-22 02:49:22 +03:00
parent 7b4073e55b
commit 2d5b7a37e8
11 changed files with 994 additions and 399 deletions
+190 -159
View File
@@ -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