feat(iced): implement selection-aware copy/paste

This commit is contained in:
2026-07-15 03:15:20 +03:00
parent f232615991
commit 80e0637271
3 changed files with 159 additions and 0 deletions
@@ -16,6 +16,7 @@ use crate::dialogs;
use crate::dock::state::{DockState, PaneType};
use crate::io::tablet::TabletState;
use crate::panels;
use crate::selection;
use crate::selection::{CropState, SelectionTransform, TransformHandle};
use crate::theme::ThemeState;
use hcie_engine_api::{BlendMode, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN};
@@ -2126,6 +2127,18 @@ impl HcieIcedApp {
let doc = &self.documents[self.active_doc];
let w = doc.engine.canvas_width();
let h = doc.engine.canvas_height();
// Check if there's a selection
if let Some(ref mask) = doc.selection_mask {
// Copy selected pixels only
let pixels = doc.composite_raw.clone();
if let Some(transform) = selection::clipboard::extract_masked_pixels(&pixels, mask, w, h) {
self.documents[self.active_doc].internal_clipboard = Some(transform);
return Task::perform(async {}, |_| Message::NoOp);
}
}
// No selection - copy entire canvas
let pixels = doc.composite_raw.clone();
return Task::perform(
async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) },
@@ -2133,6 +2146,17 @@ impl HcieIcedApp {
);
}
Message::PasteImage => {
let doc = &self.documents[self.active_doc];
// Check internal clipboard first
if let Some(ref transform) = doc.internal_clipboard {
// Enter transform mode with the clipboard content
self.documents[self.active_doc].selection_transform = Some(transform.clone());
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// Fall back to system clipboard
return Task::perform(
async { crate::io::clipboard::paste_image_from_clipboard() },
Message::ImagePasted,
@@ -0,0 +1,134 @@
//! Selection-aware clipboard operations.
//!
//! Provides functions for extracting pixels within a selection mask
//! and compositing floating pixels back onto a layer with alpha blending.
use super::transform::SelectionTransform;
/// Extract pixels from a layer using a selection mask.
///
/// # Arguments
/// * `layer_pixels` - RGBA pixel data of the source layer (row-major, 4 bytes per pixel)
/// * `mask` - Selection mask as alpha channel values (1 byte per pixel, same dimensions as layer)
/// * `canvas_w` - Width of the canvas in pixels
/// * `canvas_h` - Height of the canvas in pixels
///
/// # Returns
/// `Some(SelectionTransform)` containing the extracted pixels and their position,
/// or `None` if the mask is empty or dimensions are invalid.
pub fn extract_masked_pixels(
layer_pixels: &[u8],
mask: &[u8],
canvas_w: u32,
canvas_h: u32,
) -> Option<SelectionTransform> {
if mask.is_empty() || canvas_w == 0 || canvas_h == 0 {
return None;
}
// Find bounding box of non-zero mask pixels
let mut min_x = canvas_w;
let mut min_y = canvas_h;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut has_selection = false;
for y in 0..canvas_h {
for x in 0..canvas_w {
if mask[(y * canvas_w + x) as usize] > 0 {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
has_selection = true;
}
}
}
if !has_selection {
return None;
}
let bw = max_x - min_x + 1;
let bh = max_y - min_y + 1;
let mut pixels = vec![0u8; (bw * bh * 4) as usize];
let layer_w = canvas_w as usize;
for y in 0..bh as usize {
for x in 0..bw as usize {
let src_x = min_x as usize + x;
let src_y = min_y as usize + y;
let mask_idx = src_y * canvas_w as usize + src_x;
if mask_idx < mask.len() && mask[mask_idx] > 0 {
let px_idx = (src_y * layer_w + src_x) * 4;
if px_idx + 3 < layer_pixels.len() {
let dst_idx = (y * bw as usize + x) * 4;
pixels[dst_idx] = layer_pixels[px_idx];
pixels[dst_idx + 1] = layer_pixels[px_idx + 1];
pixels[dst_idx + 2] = layer_pixels[px_idx + 2];
pixels[dst_idx + 3] = layer_pixels[px_idx + 3];
}
}
}
}
Some(SelectionTransform {
pixels,
width: bw,
height: bh,
pos: iced::Vector::new(min_x as f32, min_y as f32),
size: iced::Vector::new(bw as f32, bh as f32),
rotation: 0.0,
})
}
/// Composite floating pixels back onto a layer.
///
/// Uses alpha blending to combine the floating pixels with the existing layer data.
/// Pixels outside the canvas bounds are clipped.
///
/// # Arguments
/// * `layer_pixels` - Mutable RGBA pixel data of the target layer (row-major, 4 bytes per pixel)
/// * `floating` - The floating selection transform containing pixels to composite
/// * `canvas_w` - Width of the canvas in pixels
/// * `canvas_h` - Height of the canvas in pixels
pub fn composite_floating_to_layer(
layer_pixels: &mut [u8],
floating: &SelectionTransform,
canvas_w: u32,
canvas_h: u32,
) {
if floating.is_empty() {
return;
}
let x = floating.pos.x as i32;
let y = floating.pos.y as i32;
let w = floating.width as i32;
let h = floating.height as i32;
for fy in 0..h {
for fx in 0..w {
let dst_x = x + fx;
let dst_y = y + fy;
if dst_x < 0 || dst_y < 0 || dst_x >= canvas_w as i32 || dst_y >= canvas_h as i32 {
continue;
}
let src_idx = ((fy * w + fx) * 4) as usize;
let dst_idx = ((dst_y * canvas_w as i32 + dst_x) * 4) as usize;
if src_idx + 3 < floating.pixels.len() && dst_idx + 3 < layer_pixels.len() {
let src_a = floating.pixels[src_idx + 3] as f32 / 255.0;
let inv_a = 1.0 - src_a;
layer_pixels[dst_idx] = (floating.pixels[src_idx] as f32 * src_a + layer_pixels[dst_idx] as f32 * inv_a) as u8;
layer_pixels[dst_idx + 1] = (floating.pixels[src_idx + 1] as f32 * src_a + layer_pixels[dst_idx + 1] as f32 * inv_a) as u8;
layer_pixels[dst_idx + 2] = (floating.pixels[src_idx + 2] as f32 * src_a + layer_pixels[dst_idx + 2] as f32 * inv_a) as u8;
layer_pixels[dst_idx + 3] = ((src_a * 255.0 + layer_pixels[dst_idx + 3] as f32 * inv_a).min(255.0)) as u8;
}
}
}
}
@@ -3,6 +3,7 @@
//! Contains types for managing floating pixel selections (cut/copy-paste),
//! transform handles, and the crop tool.
pub mod clipboard;
pub mod crop;
pub mod transform;