Files
hcie-rust-v3.05/docs/compose/plans/2025-07-15-iced-selection-features.md
T

1726 lines
51 KiB
Markdown
Raw Normal View History

# Iced GUI Selection Features Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement raster move/rotate/scale, copy/paste, vector selection, selection menu improvements, and fix the crop tool in the iced GUI.
**Architecture:** Extend the existing iced GUI overlay system with transform handles, add per-document selection state, and wire clipboard operations to selection-aware pixel extraction.
**Tech Stack:** Rust, iced 0.13, wgpu, hcie-engine-api
## Global Constraints
- Engine crates are locked (chmod 444) — do not modify hcie-engine-api, hcie-protocol, etc.
- All GUI changes go in `hcie-iced-app/crates/hcie-iced-gui/src/`
- Use existing `OverlayProgram` for drawing selection overlays
- Performance: selection mask should be cached, not recomputed per frame
- Each task produces independently testable code
---
### Task 1: Add Selection State Types
**Covers:** [S2, S3]
**Files:**
- Create: `hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs`
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:158-200`
**Interfaces:**
- Consumes: `IcedDocument` struct definition
- Produces: `SelectionTransform`, `TransformHandle`, `CropState` types
- [ ] **Step 1: Create selection module**
```rust
// hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs
pub mod transform;
pub mod crop;
pub use transform::{SelectionTransform, TransformHandle};
pub use crop::CropState;
```
- [ ] **Step 2: Create transform types**
```rust
// hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs
use iced::Vector;
/// Floating pixel selection that can be moved, rotated, and scaled.
#[derive(Clone)]
pub struct SelectionTransform {
/// RGBA pixel data for the selection
pub pixels: Vec<u8>,
/// Width of the selection in pixels
pub width: u32,
/// Height of the selection in pixels
pub height: u32,
/// Position in canvas coordinates
pub pos: Vector,
/// Display size (may differ from pixel dimensions when scaled)
pub size: Vector,
/// Rotation in radians
pub rotation: f32,
}
impl SelectionTransform {
/// Create from engine selection mask.
pub fn from_engine(
engine: &hcie_engine_api::Engine,
mask: &[u8],
canvas_w: u32,
canvas_h: u32,
) -> Self {
if mask.is_empty() || canvas_w == 0 || canvas_h == 0 {
return Self {
pixels: vec![],
width: 0,
height: 0,
pos: Vector::ZERO,
size: Vector::ZERO,
rotation: 0.0,
};
}
// Find bounding box of selection
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 Self {
pixels: vec![],
width: 0,
height: 0,
pos: Vector::ZERO,
size: Vector::ZERO,
rotation: 0.0,
};
}
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_pixels = engine.get_active_layer_pixels().unwrap_or_default();
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];
}
}
}
}
Self {
pixels,
width: bw,
height: bh,
pos: Vector::new(min_x as f32, min_y as f32),
size: Vector::new(bw as f32, bh as f32),
rotation: 0.0,
}
}
/// Check if this transform has valid pixel data.
pub fn is_empty(&self) -> bool {
self.pixels.is_empty() || self.width == 0 || self.height == 0
}
}
/// Handle for interacting with a selection transform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransformHandle {
#[default]
None,
Move,
TopLeft,
TopRight,
BottomRight,
BottomLeft,
Top,
Bottom,
Left,
Right,
Rotate,
}
impl TransformHandle {
/// Size of handle hit area in canvas pixels (at zoom=1).
pub const HANDLE_SIZE: f32 = 8.0;
/// Size of rotation handle offset from top center.
pub const ROTATE_OFFSET: f32 = 20.0;
/// Get cursor icon hint for this handle.
pub fn cursor_hint(&self) -> &'static str {
match self {
TransformHandle::None => "default",
TransformHandle::Move => "move",
TransformHandle::TopLeft | TransformHandle::BottomRight => "nwse-resize",
TransformHandle::TopRight | TransformHandle::BottomLeft => "nesw-resize",
TransformHandle::Top | TransformHandle::Bottom => "ns-resize",
TransformHandle::Left | TransformHandle::Right => "ew-resize",
TransformHandle::Rotate => "crosshair",
}
}
}
```
- [ ] **Step 3: Create crop state**
```rust
// hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs
/// State for the crop tool.
#[derive(Clone, Default)]
pub struct CropState {
/// Crop rectangle in canvas coordinates (x, y, width, height)
pub rect: Option<(f32, f32, f32, f32)>,
/// Whether crop is being dragged
pub is_dragging: bool,
/// Drag start position in canvas coordinates
pub drag_start: Option<(f32, f32)>,
/// Whether crop overlay is active
pub active: bool,
}
impl CropState {
/// Start a new crop drag.
pub fn start_drag(&mut self, x: f32, y: f32) {
self.is_dragging = true;
self.drag_start = Some((x, y));
self.rect = Some((x, y, 0.0, 0.0));
self.active = true;
}
/// Update crop rectangle during drag.
pub fn update_drag(&mut self, x: f32, y: f32) {
if let Some((start_x, start_y)) = self.drag_start {
let min_x = start_x.min(x);
let min_y = start_y.min(y);
let w = (x - start_x).abs();
let h = (y - start_y).abs();
self.rect = Some((min_x, min_y, w, h));
}
}
/// End crop drag.
pub fn end_drag(&mut self) {
self.is_dragging = false;
self.drag_start = None;
}
/// Confirm and apply crop.
pub fn confirm(&mut self) -> Option<(u32, u32, u32, u32)> {
if let Some((x, y, w, h)) = self.rect {
if w > 1.0 && h > 1.0 {
self.active = false;
self.rect = None;
return Some((x as u32, y as u32, w as u32, h as u32));
}
}
None
}
/// Cancel crop.
pub fn cancel(&mut self) {
self.active = false;
self.rect = None;
self.is_dragging = false;
self.drag_start = None;
}
}
```
- [ ] **Step 4: Add state to IcedDocument**
```rust
// In hcie-iced-app/crates/hcie-iced-gui/src/app.rs, add to IcedDocument struct:
pub struct IcedDocument {
// ... existing fields ...
/// Active selection transform (floating pixels after cut/copy-paste)
pub selection_transform: Option<SelectionTransform>,
/// Transform handle being dragged
pub transform_drag_handle: TransformHandle,
/// Internal clipboard for copy/paste operations
pub internal_clipboard: Option<SelectionTransform>,
/// Crop tool state
pub crop_state: CropState,
}
```
- [ ] **Step 5: Initialize new fields in IcedDocument::new()**
```rust
// In the IcedDocument::new() function, add initialization:
selection_transform: None,
transform_drag_handle: TransformHandle::None,
internal_clipboard: None,
crop_state: CropState::default(),
```
- [ ] **Step 6: Add module declaration**
```rust
// In hcie-iced-app/crates/hcie-iced-gui/src/app.rs, add at top:
pub mod selection;
```
- [ ] **Step 7: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS (no errors)
- [ ] **Step 8: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/selection/
git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs
git commit -m "feat(iced): add selection state types for transform, crop"
```
---
### Task 2: Draw Transform Handles in Overlay
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs:42-200`
**Interfaces:**
- Consumes: `SelectionTransform`, `TransformHandle` from Task 1
- Produces: Visual overlay drawing for transform handles
- [ ] **Step 1: Update OverlayProgram struct**
```rust
// In canvas/mod.rs, update OverlayProgram:
struct OverlayProgram {
// ... existing fields ...
/// Active selection transform (for drawing handles)
selection_transform: Option<SelectionTransform>,
/// Current transform handle being hovered/dragged
active_handle: TransformHandle,
}
```
- [ ] **Step 2: Add handle drawing method**
```rust
// In canvas/mod.rs, add to OverlayProgram impl:
impl OverlayProgram {
// ... existing methods ...
/// Draw transform handles for selection.
fn draw_transform_handles(
&self,
frame: &mut Frame,
tr: &SelectionTransform,
origin_x: f32,
origin_y: f32,
) {
let handle_size = TransformHandle::HANDLE_SIZE / self.zoom.max(1.0);
let half = handle_size / 2.0;
// Calculate handle positions in viewport coordinates
let x = origin_x + tr.pos.x * self.zoom;
let y = origin_y + tr.pos.y * self.zoom;
let w = tr.size.x * self.zoom;
let h = tr.size.y * self.zoom;
let handles = [
(TransformHandle::TopLeft, x, y),
(TransformHandle::TopRight, x + w, y),
(TransformHandle::BottomLeft, x, y + h),
(TransformHandle::BottomRight, x + w, y + h),
(TransformHandle::Top, x + w / 2.0, y),
(TransformHandle::Bottom, x + w / 2.0, y + h),
(TransformHandle::Left, x, y + h / 2.0),
(TransformHandle::Right, x + w, y + h / 2.0),
];
let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
let handle_stroke_color = iced::Color::from_rgb(0.0, 0.0, 0.0);
for (handle, hx, hy) in &handles {
let rect = Path::rectangle(
Point::new(hx - half, hy - half),
Size::new(handle_size, handle_size),
);
// White fill
frame.fill(&rect, handle_color);
// Black stroke
frame.stroke(&rect, Stroke {
style: canvas::stroke::Style::Solid(handle_stroke_color),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
}
// Draw rotation handle (circle above top center)
let rotate_y = y - TransformHandle::ROTATE_OFFSET / self.zoom.max(1.0);
let rotate_x = x + w / 2.0;
let rotate_radius = 4.0 / self.zoom.max(1.0);
let rotate_circle = Path::circle(Point::new(rotate_x, rotate_y), rotate_radius);
frame.fill(&rotate_circle, handle_color);
frame.stroke(&rotate_circle, Stroke {
style: canvas::stroke::Style::Solid(handle_stroke_color),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
// Draw line from top center to rotation handle
let line = Path::line(
Point::new(x + w / 2.0, y),
Point::new(rotate_x, rotate_y),
);
frame.stroke(&line, Stroke {
style: canvas::stroke::Style::Solid(handle_stroke_color),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
}
}
```
- [ ] **Step 3: Update draw() to use transform**
```rust
// In canvas/mod.rs, update the draw() method:
fn draw(
&self,
state: &Self::State,
renderer: &iced::Renderer,
_theme: &iced::Theme,
bounds: Rectangle,
_cursor: Cursor,
) -> Vec<canvas::Geometry> {
let (origin_x, origin_y, _display_w, _display_h) = self.canvas_origin(bounds.size());
let mut geometries = Vec::new();
// ── Selection rectangle or transform handles ──
let has_overlays = self.selection_rect.is_some()
|| self.vector_draw.is_some()
|| self.selection_transform.is_some();
if has_overlays {
let mut frame = Frame::new(renderer, bounds.size());
// Draw selection rectangle (when not in transform mode)
if let Some((x0, y0, x1, y1)) = self.selection_rect {
if self.selection_transform.is_none() {
let sel_x = origin_x + x0.min(x1) * self.zoom;
let sel_y = origin_y + y0.min(y1) * self.zoom;
let sel_w = (x1 - x0).abs() * self.zoom;
let sel_h = (y1 - y0).abs() * self.zoom;
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.6, 1.0)),
width: 2.0 / self.zoom.max(1.0),
..Default::default()
});
}
}
// Draw transform handles (when in transform mode)
if let Some(ref tr) = self.selection_transform {
if !tr.is_empty() {
// Draw dotted bounding box
let x = origin_x + tr.pos.x * self.zoom;
let y = origin_y + tr.pos.y * self.zoom;
let w = tr.size.x * self.zoom;
let h = tr.size.y * self.zoom;
let bbox = Path::rectangle(Point::new(x, y), Size::new(w, h));
frame.stroke(&bbox, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.5, 0.5, 1.0)),
width: 1.0 / self.zoom.max(1.0),
line_dash: canvas::LineDash {
segments: &[4.0, 4.0],
offset: 0,
},
..Default::default()
});
// Draw handles
self.draw_transform_handles(&mut frame, tr, origin_x, origin_y);
}
}
// ... vector draw code remains unchanged ...
geometries.push(frame.into_geometry());
}
// ... rest of draw() unchanged ...
}
```
- [ ] **Step 4: Update view() to pass transform state**
```rust
// In canvas/mod.rs, update the view() function:
pub fn view<'a>(
doc: &'a crate::app::IcedDocument,
tool_state: &'a crate::app::ToolState,
) -> Element<'a, Message> {
// ... existing code ...
let overlay_program = OverlayProgram {
engine_w,
engine_h,
zoom,
pan_offset,
selection_rect: doc.selection_rect,
vector_draw: doc.vector_draw,
selection_transform: doc.selection_transform.clone(),
active_handle: TransformHandle::None, // TODO: track hover state
};
// ... rest of view() unchanged ...
}
```
- [ ] **Step 5: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs
git commit -m "feat(iced): draw transform handles in selection overlay"
```
---
### Task 3: Implement Transform Handle Hit-Testing
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs:170-200`
**Interfaces:**
- Consumes: `SelectionTransform`, `TransformHandle` from Task 1
- Produces: Hit-testing function for handles
- [ ] **Step 1: Add hit-testing method**
```rust
// In canvas/mod.rs, add to OverlayProgram impl:
impl OverlayProgram {
// ... existing methods ...
/// Test which transform handle is at the given viewport position.
pub fn hit_test_handle(
&self,
viewport_x: f32,
viewport_y: f32,
) -> TransformHandle {
let Some(ref tr) = self.selection_transform else {
return TransformHandle::None;
};
if tr.is_empty() {
return TransformHandle::None;
}
let (origin_x, origin_y, _, _) = self.canvas_origin(iced::Size::new(0.0, 0.0));
// Convert viewport to canvas coordinates
let canvas_x = (viewport_x - origin_x) / self.zoom;
let canvas_y = (viewport_y - origin_y) / self.zoom;
let handle_size = TransformHandle::HANDLE_SIZE / self.zoom;
let half = handle_size / 2.0;
let x = tr.pos.x;
let y = tr.pos.y;
let w = tr.size.x;
let h = tr.size.y;
// Check rotation handle first (it's outside the box)
let rotate_y = y - TransformHandle::ROTATE_OFFSET / self.zoom;
let rotate_x = x + w / 2.0;
let rotate_radius = 4.0 / self.zoom;
let dx = canvas_x - rotate_x;
let dy = canvas_y - rotate_y;
if dx * dx + dy * dy <= rotate_radius * rotate_radius {
return TransformHandle::Rotate;
}
// Check corner and edge handles
let handles = [
(TransformHandle::TopLeft, x, y),
(TransformHandle::TopRight, x + w, y),
(TransformHandle::BottomLeft, x, y + h),
(TransformHandle::BottomRight, x + w, y + h),
(TransformHandle::Top, x + w / 2.0, y),
(TransformHandle::Bottom, x + w / 2.0, y + h),
(TransformHandle::Left, x, y + h / 2.0),
(TransformHandle::Right, x + w, y + h / 2.0),
];
for (handle, hx, hy) in &handles {
if (canvas_x - hx).abs() <= half && (canvas_y - hy).abs() <= half {
return *handle;
}
}
// Check if inside the selection box (for move)
if canvas_x >= x && canvas_x <= x + w && canvas_y >= y && canvas_y <= y + h {
return TransformHandle::Move;
}
TransformHandle::None
}
}
```
- [ ] **Step 2: Add handle hover tracking to OverlayState**
```rust
// In canvas/mod.rs, update OverlayState:
struct OverlayState {
cursor_pos: Option<Point>,
is_hovered: bool,
/// Current handle being hovered (for cursor changes)
hovered_handle: TransformHandle,
}
```
- [ ] **Step 3: Update update() to track hover**
```rust
// In canvas/mod.rs, update the update() method:
fn update(
&self,
state: &mut Self::State,
event: canvas::Event,
bounds: Rectangle,
cursor: Cursor,
) -> (canvas::event::Status, Option<Message>) {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
let local_pos = Point::new(
position.x - bounds.x,
position.y - bounds.y,
);
state.cursor_pos = Some(local_pos);
state.is_hovered = bounds.contains(position);
// Update hovered handle
if self.selection_transform.is_some() {
state.hovered_handle = self.hit_test_handle(position.x, position.y);
} else {
state.hovered_handle = TransformHandle::None;
}
}
_ => {
if state.is_hovered && !cursor.is_over(bounds) {
state.is_hovered = false;
state.cursor_pos = None;
state.hovered_handle = TransformHandle::None;
}
}
}
(canvas::event::Status::Ignored, None)
}
```
- [ ] **Step 4: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs
git commit -m "feat(iced): add transform handle hit-testing"
```
---
### Task 4: Implement Transform Drag Messages
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:245-400`
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:1880-1910`
**Interfaces:**
- Consumes: `SelectionTransform`, `TransformHandle` from Task 1
- Produces: Message handlers for transform operations
- [ ] **Step 1: Add transform messages**
```rust
// In app.rs, add to Message enum:
// ── Selection transform ─────────────────────────
TransformDragStart(f32, f32),
TransformDragMove(f32, f32),
TransformDragEnd,
TransformApply,
TransformCancel,
```
- [ ] **Step 2: Add transform state to IcedDocument**
```rust
// In IcedDocument struct, add:
/// Transform drag start position in canvas coordinates
pub transform_drag_start: Option<(f32, f32)>,
/// Original transform state before drag (for undo)
pub transform_original: Option<SelectionTransform>,
```
- [ ] **Step 3: Implement transform message handlers**
```rust
// In app.rs update() method, add handlers:
// ── Selection transform ──
Message::TransformDragStart(x, y) => {
if let Some(ref tr) = self.documents[self.active_doc].selection_transform {
self.documents[self.active_doc].transform_drag_start = Some((x, y));
self.documents[self.active_doc].transform_original = Some(tr.clone());
}
}
Message::TransformDragMove(x, y) => {
let doc = &mut self.documents[self.active_doc];
if let (Some(start), Some(ref mut tr)) = (doc.transform_drag_start, &mut doc.selection_transform) {
let dx = x - start.0;
let dy = y - start.1;
match doc.transform_drag_handle {
TransformHandle::Move => {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::TopLeft => {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y - dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::TopRight => {
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y - dy);
tr.pos = iced::Vector::new(tr.pos.x, tr.pos.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::BottomLeft => {
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y + dy);
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::BottomRight => {
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Top => {
tr.pos = iced::Vector::new(tr.pos.x, tr.pos.y + dy);
tr.size = iced::Vector::new(tr.size.x, tr.size.y - dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Bottom => {
tr.size = iced::Vector::new(tr.size.x, tr.size.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Left => {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y);
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Right => {
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Rotate => {
// Calculate rotation from center
let center_x = tr.pos.x + tr.size.x / 2.0;
let center_y = tr.pos.y + tr.size.y / 2.0;
let angle = (y - center_y).atan2(x - center_x);
tr.rotation = angle;
}
TransformHandle::None => {}
}
}
}
Message::TransformDragEnd => {
let doc = &mut self.documents[self.active_doc];
doc.transform_drag_start = None;
doc.transform_original = None;
}
Message::TransformApply => {
let doc = &mut self.documents[self.active_doc];
if let Some(tr) = doc.selection_transform.take() {
// TODO: Apply transform to layer pixels
// For now, just clear the transform
doc.transform_drag_handle = TransformHandle::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::TransformCancel => {
let doc = &mut self.documents[self.active_doc];
// Restore original transform if we have it
if let Some(original) = doc.transform_original.take() {
doc.selection_transform = Some(original);
} else {
doc.selection_transform = None;
}
doc.transform_drag_start = None;
doc.transform_drag_handle = TransformHandle::None;
}
```
- [ ] **Step 4: Initialize new fields**
```rust
// In IcedDocument::new(), add:
transform_drag_start: None,
transform_original: None,
```
- [ ] **Step 5: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs
git commit -m "feat(iced): implement transform drag messages and handlers"
```
---
### Task 5: Implement Raster Copy/Paste
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:1990-2030`
- Create: `hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs`
**Interfaces:**
- Consumes: `SelectionTransform` from Task 1
- Produces: Selection-aware copy/paste operations
- [ ] **Step 1: Create clipboard helper**
```rust
// hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs
use super::transform::SelectionTransform;
/// Extract pixels from a layer using a selection mask.
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
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.
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;
}
}
}
}
```
- [ ] **Step 2: Update selection mod.rs**
```rust
// In selection/mod.rs, add:
pub mod clipboard;
```
- [ ] **Step 3: Update copy/paste handlers**
```rust
// In app.rs, update the clipboard message handlers:
Message::CopyImage => {
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) },
Message::ImageCopied,
);
}
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,
);
}
```
- [ ] **Step 4: Add selection_mask field**
```rust
// In IcedDocument struct, add:
/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
```
- [ ] **Step 5: Initialize selection_mask**
```rust
// In IcedDocument::new(), add:
selection_mask: None,
```
- [ ] **Step 6: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/selection/
git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs
git commit -m "feat(iced): implement selection-aware copy/paste"
```
---
### Task 6: Fix Crop Tool
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:1884-1905`
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs:97-140`
**Interfaces:**
- Consumes: `CropState` from Task 1
- Produces: Working crop tool with proper handles
- [ ] **Step 1: Add crop messages**
```rust
// In app.rs Message enum, add:
// ── Crop tool ──────────────────────────────────
CropDragStart(f32, f32),
CropDragMove(f32, f32),
CropDragEnd,
CropConfirm,
CropCancel,
```
- [ ] **Step 2: Implement crop message handlers**
```rust
// In app.rs update() method, add handlers:
Message::CropDragStart(x, y) => {
self.documents[self.active_doc].crop_state.start_drag(x, y);
}
Message::CropDragMove(x, y) => {
self.documents[self.active_doc].crop_state.update_drag(x, y);
}
Message::CropDragEnd => {
self.documents[self.active_doc].crop_state.end_drag();
}
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);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::CropCancel => {
self.documents[self.active_doc].crop_state.cancel();
}
```
- [ ] **Step 3: Draw crop overlay**
```rust
// In canvas/mod.rs, add to OverlayProgram:
impl OverlayProgram {
// ... existing methods ...
/// Draw crop overlay with handles and rule-of-thirds.
fn draw_crop_overlay(
&self,
frame: &mut Frame,
crop: &CropState,
origin_x: f32,
origin_y: f32,
) {
if let Some((x, y, w, h)) = crop.rect {
if w < 1.0 || h < 1.0 {
return;
}
let cx = origin_x + x * self.zoom;
let cy = origin_y + y * self.zoom;
let cw = w * self.zoom;
let ch = h * self.zoom;
// Draw semi-transparent overlay outside crop area
// (simplified: just draw the crop rectangle for now)
// Draw crop border
let border = Path::rectangle(Point::new(cx, cy), Size::new(cw, ch));
frame.stroke(&border, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
width: 2.0 / self.zoom.max(1.0),
..Default::default()
});
// Draw rule-of-thirds lines
let third_w = cw / 3.0;
let third_h = ch / 3.0;
for i in 1..3 {
// Vertical lines
let vx = cx + third_w * i as f32;
let vline = Path::line(
Point::new(vx, cy),
Point::new(vx, cy + ch),
);
frame.stroke(&vline, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.5)),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
// Horizontal lines
let hy = cy + third_h * i as f32;
let hline = Path::line(
Point::new(cx, hy),
Point::new(cx + cw, hy),
);
frame.stroke(&hline, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.5)),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
}
// Draw handles
let handle_size = 8.0 / self.zoom.max(1.0);
let half = handle_size / 2.0;
let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
let handle_stroke = iced::Color::from_rgb(0.0, 0.0, 0.0);
let handles = [
(cx, cy), // TopLeft
(cx + cw, cy), // TopRight
(cx, cy + ch), // BottomLeft
(cx + cw, cy + ch), // BottomRight
(cx + cw / 2.0, cy), // Top
(cx + cw / 2.0, cy + ch), // Bottom
(cx, cy + ch / 2.0), // Left
(cx + cw, cy + ch / 2.0), // Right
];
for (hx, hy) in &handles {
let rect = Path::rectangle(
Point::new(hx - half, hy - half),
Size::new(handle_size, handle_size),
);
frame.fill(&rect, handle_color);
frame.stroke(&rect, Stroke {
style: canvas::stroke::Style::Solid(handle_stroke),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
}
}
}
}
```
- [ ] **Step 4: Update draw() to include crop**
```rust
// In canvas/mod.rs draw() method, add:
// After transform handles drawing, add:
if let Some(ref crop) = self.crop_state {
if crop.active {
self.draw_crop_overlay(&mut frame, crop, origin_x, origin_y);
}
}
```
- [ ] **Step 5: Handle keyboard events for crop**
```rust
// In app.rs, in the keyboard event handler (around line 3000), add:
// Crop tool keyboard shortcuts
if self.tool_state.active_tool == Tool::Crop {
match key {
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => {
return Task::perform(async {}, |_| Message::CropConfirm);
}
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => {
return Task::perform(async {}, |_| Message::CropCancel);
}
_ => {}
}
}
```
- [ ] **Step 6: Wire crop tool mouse events**
```rust
// In app.rs, in the canvas mouse event handler, update Tool::Crop handling:
Tool::Crop => {
match event {
iced::event::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
if let Some((x, y)) = canvas_pos {
return Task::perform(async move { Message::CropDragStart(x, y) }, |m| m);
}
}
iced::event::Event::Mouse(iced::mouse::Event::CursorMoved { position }) => {
if let Some((x, y)) = canvas_pos {
return Task::perform(async move { Message::CropDragMove(x, y) }, |m| m);
}
}
iced::event::Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) => {
return Task::perform(async {}, |_| Message::CropDragEnd);
}
_ => {}
}
}
```
- [ ] **Step 7: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 8: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs
git add hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs
git commit -m "feat(iced): fix crop tool with proper handles and keyboard"
```
---
### Task 7: Implement Selection Menu Operations
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs:190-213`
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:2460-2483`
**Interfaces:**
- Consumes: `selection_mask` from Task 5
- Produces: Working selection menu operations
- [ ] **Step 1: Update Select menu items**
```rust
// In menus.rs, update the Select menu:
MenuDef {
label: "Select".to_string(),
items: vec![
MenuItem::with_shortcut("All", "Ctrl+A"),
MenuItem::with_shortcut("Deselect", "Ctrl+D"),
MenuItem::with_shortcut("Inverse", "Shift+Ctrl+I"),
MenuItem::separator(),
MenuItem::new("Remove BG"),
MenuItem::new("Color Range..."),
MenuItem::new("Magic Cut..."),
MenuItem::new("Subject"),
MenuItem::separator(),
MenuItem::new("Refine Edge..."),
MenuItem::submenu("Modify"),
MenuItem::with_shortcut("Grow...", "Alt+Ctrl+G"),
MenuItem::with_shortcut("Shrink...", "Alt+Ctrl+S"),
MenuItem::with_shortcut("Feather...", "Alt+Ctrl+F"),
MenuItem::new("Border..."),
MenuItem::new("Smooth..."),
MenuItem::separator(),
MenuItem::new("Similar"),
MenuItem::separator(),
MenuItem::new("Transform Selection"),
MenuItem::with_shortcut("Quick Mask Mode", "Q"),
MenuItem::separator(),
MenuItem::new("Load Selection"),
MenuItem::new("Save Selection"),
],
}
```
- [ ] **Step 2: Add menu message handlers**
```rust
// In app.rs, update the Select menu handlers:
// Select menu (4) — Photopea-style
(4, 0) => { // All
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));
}
(4, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect
(4, 2) => { // Inverse
// TODO: Implement selection inverse
log::info!("Selection Inverse not yet implemented");
}
(4, 11) => { // Grow
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Grow");
}
(4, 12) => { // Shrink
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Shrink");
}
(4, 13) => { // Feather
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Feather");
}
(4, 14) => { // Border
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Border");
}
(4, 15) => { // Smooth
self.dialog_selection_value = 5.0;
self.active_dialog = ActiveDialog::SelectionOp("Smooth");
}
```
- [ ] **Step 3: Implement selection operations dialog**
```rust
// In dialogs/confirm.rs, add selection operation handler:
pub fn selection_op_view(
operation: &str,
value: f32,
colors: ThemeColors,
) -> Element<'static, Message> {
let title = text(format!("{} Selection", operation))
.size(14)
.style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_primary),
});
let label = text("Radius (pixels):")
.size(11)
.style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
let input = text_input("5", &value.to_string())
.width(iced::Length::Fixed(100.0))
.size(11)
.on_input(Message::SelectionOpValue);
let ok_button = button(text("OK").size(11))
.on_press(Message::SelectionOpApply)
.padding([4, 12]);
let cancel_button = button(text("Cancel").size(11))
.on_press(Message::DialogClose)
.padding([4, 12]);
column![
title,
iced::widget::vertical_rule(1),
row![label, input].spacing(8).align_y(iced::Alignment::Center),
row![ok_button, cancel_button].spacing(8),
]
.spacing(12)
.padding(16)
.max_width(300)
.into()
}
```
- [ ] **Step 4: Add SelectionOpValue handler**
```rust
// In app.rs, update the SelectionOpValue handler:
Message::SelectionOpValue(val) => {
self.dialog_selection_value = val;
}
Message::SelectionOpApply => {
if let ActiveDialog::SelectionOp(op) = self.active_dialog {
let value = self.dialog_selection_value as u32;
let doc = &mut self.documents[self.active_doc];
match op {
"Grow" => {
doc.engine.grow_selection(value);
}
"Shrink" => {
doc.engine.shrink_selection(value);
}
"Feather" => {
doc.engine.feather_selection(value);
}
"Border" => {
doc.engine.border_selection(value);
}
"Smooth" => {
doc.engine.smooth_selection(value);
}
_ => {}
}
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
```
- [ ] **Step 5: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs
git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs
git commit -m "feat(iced): implement selection menu operations"
```
---
### Task 8: Add Keyboard Shortcuts
**Covers:** [S3]
**Files:**
- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:2980-3020`
**Interfaces:**
- Consumes: All previous tasks
- Produces: Working keyboard shortcuts
- [ ] **Step 1: Add selection keyboard shortcuts**
```rust
// In app.rs keyboard handler, add:
// Selection shortcuts
match (&key, &modifiers) {
(iced::keyboard::Key::Character("a"), modifiers) if modifiers.control() => {
// Select All
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 Task::perform(async {}, |_| Message::NoOp);
}
(iced::keyboard::Key::Character("d"), modifiers) if modifiers.control() => {
// Deselect
self.documents[self.active_doc].selection_rect = None;
return Task::perform(async {}, |_| Message::NoOp);
}
(iced::keyboard::Key::Character("c"), modifiers) if modifiers.control() => {
// Copy
return Task::perform(async {}, |_| Message::CopyImage);
}
(iced::keyboard::Key::Character("v"), modifiers) if modifiers.control() => {
// Paste
return Task::perform(async {}, |_| Message::PasteImage);
}
(iced::keyboard::Key::Character("x"), modifiers) if modifiers.control() => {
// Cut
// TODO: Implement cut
return Task::perform(async {}, |_| Message::CopyImage);
}
(iced::keyboard::Key::Character("z"), modifiers) if modifiers.control() => {
// Undo
return Task::perform(async {}, |_| Message::Undo);
}
(iced::keyboard::Key::Character("z"), modifiers) if modifiers.control() && modifiers.shift() => {
// Redo
return Task::perform(async {}, |_| Message::Redo);
}
_ => {}
}
```
- [ ] **Step 2: Run cargo check**
Run: `cargo check -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs
git commit -m "feat(iced): add keyboard shortcuts for selection operations"
```
---
### Task 9: Test and Verify
**Covers:** [S6]
**Files:**
- Test: `hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs`
**Interfaces:**
- Consumes: All previous tasks
- Produces: Passing tests
- [ ] **Step 1: Create test file**
```rust
// hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs
use hcie_iced_gui::selection::{SelectionTransform, TransformHandle, CropState};
#[test]
fn test_selection_transform_is_empty() {
let tr = SelectionTransform {
pixels: vec![],
width: 0,
height: 0,
pos: iced::Vector::ZERO,
size: iced::Vector::ZERO,
rotation: 0.0,
};
assert!(tr.is_empty());
}
#[test]
fn test_selection_transform_with_data() {
let tr = SelectionTransform {
pixels: vec![255; 100],
width: 5,
height: 5,
pos: iced::Vector::new(10.0, 20.0),
size: iced::Vector::new(5.0, 5.0),
rotation: 0.0,
};
assert!(!tr.is_empty());
}
#[test]
fn test_crop_state_lifecycle() {
let mut crop = CropState::default();
assert!(!crop.active);
assert!(crop.rect.is_none());
crop.start_drag(10.0, 10.0);
assert!(crop.active);
assert!(crop.is_dragging);
crop.update_drag(100.0, 100.0);
assert!(crop.rect.is_some());
let (x, y, w, h) = crop.rect.unwrap();
assert_eq!(x, 10.0);
assert_eq!(y, 10.0);
assert_eq!(w, 90.0);
assert_eq!(h, 90.0);
crop.end_drag();
assert!(!crop.is_dragging);
let result = crop.confirm();
assert!(result.is_some());
assert!(!crop.active);
}
#[test]
fn test_crop_cancel() {
let mut crop = CropState::default();
crop.start_drag(10.0, 10.0);
crop.update_drag(100.0, 100.0);
crop.cancel();
assert!(!crop.active);
assert!(crop.rect.is_none());
}
#[test]
fn test_transform_handle_cursor_hints() {
assert_eq!(TransformHandle::Move.cursor_hint(), "move");
assert_eq!(TransformHandle::TopLeft.cursor_hint(), "nwse-resize");
assert_eq!(TransformHandle::Rotate.cursor_hint(), "crosshair");
}
```
- [ ] **Step 2: Run tests**
Run: `cargo test -p hcie-iced-gui --test selection_test`
Expected: PASS
- [ ] **Step 3: Run all tests**
Run: `cargo test -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs
git commit -m "test(iced): add selection feature tests"
```
---
### Task 10: Documentation and Final Cleanup
**Covers:** [S8]
**Files:**
- Modify: All files created/modified in previous tasks
**Interfaces:**
- Consumes: All previous tasks
- Produces: Documented, clean code
- [ ] **Step 1: Add module documentation**
```rust
// In selection/mod.rs, add doc comment:
//! Selection tools for the iced GUI.
//!
//! This module provides:
//! - `SelectionTransform` - floating pixel selection with move/rotate/scale
//! - `TransformHandle` - handle types for transform interactions
//! - `CropState` - crop tool state management
//! - `clipboard` - selection-aware copy/paste operations
```
- [ ] **Step 2: Add doc comments to public items**
```rust
// Add doc comments to all public structs and functions
```
- [ ] **Step 3: Run final checks**
Run: `cargo check -p hcie-iced-gui && cargo test -p hcie-iced-gui`
Expected: PASS
- [ ] **Step 4: Final commit**
```bash
git add -A
git commit -m "docs(iced): add documentation for selection features"
```
---
## Implementation Notes
### Engine API Methods Required
The following methods may need to be added to `hcie-engine-api` (requires unlocking the crate):
```rust
// Selection operations
pub fn grow_selection(&mut self, pixels: u32);
pub fn shrink_selection(&mut self, pixels: u32);
pub fn feather_selection(&mut self, pixels: u32);
pub fn border_selection(&mut self, pixels: u32);
pub fn smooth_selection(&mut self, pixels: u32);
pub fn invert_selection(&mut self);
pub fn clear_selection(&mut self);
pub fn get_selection_mask(&self) -> Option<&[u8]>;
```
If these don't exist, they'll need to be implemented in `hcie-selection` and exposed via `hcie-engine-api`.
### Performance Considerations
- Selection mask should be cached in `IcedDocument`, not recomputed per frame
- Transform preview uses existing composite refresh throttling
- Crop overlay is lightweight (few rectangles)
### Future Enhancements
- Rotation snapping (hold Shift for 15-degree increments)
- Transform presets (flip horizontal/vertical)
- Selection from clipboard (paste in place)
- Multi-document clipboard