# 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, /// 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, /// Transform handle being dragged pub transform_drag_handle: TransformHandle, /// Internal clipboard for copy/paste operations pub internal_clipboard: Option, /// 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, /// 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 { 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, 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) { 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, ``` - [ ] **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 { 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>, ``` - [ ] **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