diff --git a/docs/compose/plans/2025-07-15-iced-selection-features.md b/docs/compose/plans/2025-07-15-iced-selection-features.md new file mode 100644 index 0000000..b4810ef --- /dev/null +++ b/docs/compose/plans/2025-07-15-iced-selection-features.md @@ -0,0 +1,1725 @@ +# 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 diff --git a/docs/compose/specs/2025-07-15-iced-selection-features-design.md b/docs/compose/specs/2025-07-15-iced-selection-features-design.md new file mode 100644 index 0000000..a2ac3c7 --- /dev/null +++ b/docs/compose/specs/2025-07-15-iced-selection-features-design.md @@ -0,0 +1,288 @@ +# Iced GUI Selection Features Design + +## [S1] Problem + +The iced GUI migration is missing critical selection-based features that exist in the egui GUI: + +1. **#1350 Move/Rotate/Scale** - No transform handles on selections +2. **#1351 Raster Copy/Paste** - Only copies entire canvas, not selected pixels +3. **#1352 Vector Copy/Paste** - No vector shape selection/copy/paste +4. **Selection menu** - Missing feather, grow, shrink, border, fill operations +5. **Crop tool** - Broken (Enter/double-click don't work, selection mixes between images, shows marching ants instead of proper handles) + +## [S2] Solution Overview + +### Core Architecture Changes + +#### 1. SelectionTransform State (in `IcedDocument`) +Add to `IcedDocument`: +```rust +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, + /// Selection mask (alpha channel) for the current selection + pub selection_mask: Option>, + /// Selection bounds (x, y, width, height) + pub selection_bounds: Option<(u32, u32, u32, u32)>, +} +``` + +#### 2. SelectionTransform Struct +```rust +#[derive(Clone)] +pub struct SelectionTransform { + pub pixels: Vec, + pub width: u32, + pub height: u32, + pub pos: iced::Vector, + pub size: iced::Vector, + pub rotation: f32, // radians +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TransformHandle { + #[default] + None, + Move, + TopLeft, + TopRight, + BottomRight, + BottomLeft, + Top, + Bottom, + Left, + Right, + Rotate, // rotation handle (outside corner) +} +``` + +#### 3. CropState Struct +```rust +#[derive(Clone, Default)] +pub struct CropState { + /// Crop rectangle in canvas coordinates + pub rect: Option<(f32, f32, f32, f32)>, + /// Whether crop is being dragged + pub is_dragging: bool, + /// Drag start position + pub drag_start: Option<(f32, f32)>, + /// Whether to show crop overlay + pub active: bool, +} +``` + +## [S3] Feature Details + +### Feature #1350: Raster Move & Rotate & Scale + +**UI Requirements:** +- Selection shows 8 resize handles (corners + edges) + rotation handle +- Handles are 8x8px squares at zoom=1, scaled by 1/zoom +- Rotation handle is a small circle above the top-center handle +- Dotted rectangle around selection when in transform mode +- Cursor changes based on handle (resize arrows, move crosshair, rotation arc) + +**Implementation:** +1. Add `selection_transform: Option` to `IcedDocument` +2. Extend `OverlayProgram` to draw transform handles +3. Add `TransformHandle` hit-testing in overlay `update()` +4. Handle mouse drag on each handle type in `app.rs` +5. Apply transform on Enter key or double-click + +**Messages to Add:** +```rust +Message::TransformStart(usize), // handle index +Message::TransformMove(f32, f32), +Message::TransformEnd, +Message::TransformApply, +Message::TransformCancel, +Message::TransformRotate(f32), +``` + +### Feature #1351: Raster Selection Copy & Paste + +**UI Requirements:** +- Copy (Ctrl+C) copies only selected pixels (respecting mask) +- Paste (Ctrl+V) creates floating layer with copied pixels +- Cut (Ctrl+X) copies and clears selected pixels +- Internal clipboard stores SelectionTransform +- Paste positions at selection origin or center of viewport + +**Implementation:** +1. On Copy: extract pixels within selection mask, store in `internal_clipboard` +2. On Paste: create `SelectionTransform` from clipboard, enter transform mode +3. On Cut: copy then clear selected pixels in active layer +4. On transform apply: composite floating pixels back to layer + +**Engine API Calls:** +```rust +// Copy selected pixels +let pixels = engine.get_active_layer_pixels(); +let mask = engine.get_selection_mask(); +let selected = extract_masked_pixels(pixels, mask, canvas_w, canvas_h); + +// Cut +engine.clear_selection(); // may need to add this API + +// Paste +engine.add_layer("Floating"); +engine.set_active_layer(new_layer_id); +engine.draw_pixels_at(floating_pixels, x, y); +``` + +### Feature #1352: Vector Selection Copy & Paste + +**UI Requirements:** +- VectorSelect tool can click to select vector shapes +- Selected shapes show bounding box with handles +- Copy/Paste duplicates selected vector shapes +- Paste positions at offset from original + +**Implementation:** +1. Add `selected_vector_indices: Vec` to state +2. On VectorSelect click: hit-test vector shapes, select closest +3. On Copy: store selected VectorShape data in clipboard +4. On Paste: add duplicated shapes with offset +5. Draw selection bounding box in overlay + +**Messages to Add:** +```rust +Message::VectorSelectAt(f32, f32), +Message::VectorSelectToggle(usize), +Message::VectorCopy, +Message::VectorPaste, +Message::VectorCut, +Message::VectorDelete, +``` + +### Selection Menu Improvements + +**Menu Items to Implement:** +- Select > All (Ctrl+A) - ✅ already exists +- Select > Deselect (Ctrl+D) - ✅ already exists +- Select > Inverse (Shift+Ctrl+I) - needs implementation +- Select > Grow - ✅ dialog exists, needs engine call +- Select > Shrink - needs implementation +- Select > Feather - needs implementation +- Select > Border - needs implementation +- Select > Fill - needs implementation + +**Implementation:** +1. Add engine API methods if missing: + - `engine.invert_selection()` + - `engine.shrink_selection(px)` + - `engine.feather_selection(px)` + - `engine.border_selection(px)` + - `engine.fill_selection(color)` +2. Wire menu items to these methods +3. Add dialog for parameter input (px value) + +### Crop Tool Fixes + +**Issues to Fix:** +1. Enter key not working → Wire keyboard event to crop confirm +2. Double-click not working → Add double-click handler +3. Selection mixing between images → Store crop state per-document +4. Marching ants instead of handles → Draw proper crop handles + +**Implementation:** +1. Add `crop_state: CropState` to `IcedDocument` (per-document) +2. Draw crop overlay with 8 handles + rule-of-thirds grid +3. Handle Enter/double-click to confirm crop +4. Handle Escape to cancel crop +5. Apply crop via `engine.crop(x, y, w, h)` + +**Crop Overlay Drawing:** +``` +┌─────────────────────┐ +│ │ │ │ │ ← rule of thirds +│─────┼─────┼─────│ │ +│ │ │ │ │ +│─────┼─────┼─────│ │ +│ │ │ │ │ +└─────────────────────┘ + □ □ ← corner handles + □ □ ← edge handles +``` + +## [S4] Files to Modify + +### Primary Files +1. `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` - Main state and update logic +2. `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs` - Overlay drawing +3. `hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs` - Menu definitions +4. `hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs` - Tool options + +### New Files (if needed) +1. `hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs` - Transform logic +2. `hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs` - Crop tool logic + +## [S5] Implementation Order + +1. **Phase 1: Core Selection State** + - Add SelectionTransform, TransformHandle, CropState structs + - Add selection_mask, selection_bounds to IcedDocument + - Basic selection drawing improvements + +2. **Phase 2: Transform Handles** + - Draw 8 resize handles + rotation handle + - Handle hit-testing for each handle + - Implement drag behavior for resize/move/rotate + +3. **Phase 3: Raster Copy/Paste** + - Implement extract_masked_pixels helper + - Wire Ctrl+C/V/X to selection operations + - Add transform mode on paste + +4. **Phase 4: Vector Selection** + - Add vector shape hit-testing + - Implement VectorSelect tool behavior + - Wire vector copy/paste + +5. **Phase 5: Selection Menu** + - Implement Inverse, Shrink, Feather, Border, Fill + - Add parameter dialogs where needed + +6. **Phase 6: Crop Tool** + - Fix Enter/double-click handling + - Draw proper crop overlay with handles + - Per-document crop state isolation + +## [S6] Testing Strategy + +1. **Unit Tests:** + - SelectionTransform::from_engine() with various masks + - TransformHandle hit-testing accuracy + - extract_masked_pixels() correctness + +2. **Integration Tests:** + - Full copy/paste workflow + - Transform apply/cancel cycle + - Crop confirm/cancel cycle + +3. **Visual Tests:** + - Screenshot comparison for handle rendering + - Verify cursor changes on hover + - Check overlay alignment at different zoom levels + +## [S7] Performance Considerations + +- Selection mask should be cached, not recomputed per frame +- Transform preview should use existing composite refresh throttling +- Crop overlay is lightweight (few rectangles) - no performance concern +- Vector hit-testing uses bounding box first, then precise test only on candidates + +## [S8] Edge Cases + +1. **Empty selection** - All operations should be no-ops or show warning +2. **Selection outside canvas** - Clamp to canvas bounds +3. **Multiple documents** - Each has independent selection state +4. **Undo/redo** - Transform apply should create history entry +5. **Layer visibility** - Selection should only affect visible layers +6. **Vector shapes on hidden layers** - Should not be selectable diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index d3d9e0b..829bdd8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -148,6 +148,8 @@ pub struct HcieIcedApp { pub layer_style_dragging: bool, /// Last drag position for delta calculation. pub layer_style_drag_start: Option<(f32, f32)>, + /// Marching ants animation offset (cycles 0.0..1.0 for dashed line animation). + pub marching_ants_offset: f32, } /// A recently opened file entry. @@ -602,6 +604,7 @@ impl HcieIcedApp { layer_style_offset: (0.0, 0.0), layer_style_dragging: false, layer_style_drag_start: None, + marching_ants_offset: 0.0, }; // Apply saved settings to tool state @@ -781,13 +784,14 @@ impl HcieIcedApp { /// Handle a message and return an optional command. pub fn update(&mut self, message: Message) -> Task { let frame_start = std::time::Instant::now(); - let _since_last = frame_start.duration_since(self.tool_state.last_update_instant); + let since_last = frame_start.duration_since(self.tool_state.last_update_instant); self.tool_state.last_update_instant = frame_start; - // Performance log for tracking inter-frame delays (time between Elm update calls). - // Helps debug event loop bottlenecks, main thread blockages, or input lag. - // if since_last.as_secs_f64() > 0.001 { - // log::info!("[perf] inter-frame: {:.1}ms", since_last.as_secs_f64() * 1000.0); - // } + + // Update marching ants animation offset (cycles at ~60fps for smooth animation) + // The offset cycles from 0.0 to 1.0, completing one cycle every ~1 second + if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) { + self.marching_ants_offset = (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0; + } match message { Message::ToolSelected(tool) => { @@ -1966,25 +1970,32 @@ impl HcieIcedApp { } } Message::SelectionDragEnd => { - if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect.take() { + if let Some((x0, y0, x1, y1)) = self.documents[self.active_doc].selection_rect { let sx = x0.min(x1) as u32; let sy = y0.min(y1) as u32; let ex = x0.max(x1) as u32; let ey = y0.max(y1) as u32; if ex > sx && ey > sy { self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey); + // Store selection bounds for marching ants display + self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy)); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } + // Clear selection rect if no valid selection was made + self.documents[self.active_doc].selection_rect = None; } Message::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)); + 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)); } Message::Deselect => { self.documents[self.active_doc].selection_rect = None; + self.documents[self.active_doc].selection_bounds = None; + self.documents[self.active_doc].engine.selection_clear(); } Message::SelectInverse => { self.documents[self.active_doc].engine.selection_invert(); @@ -3107,6 +3118,21 @@ impl HcieIcedApp { } }); - iced::Subscription::batch(vec![keyboard, mouse]) + // Timer for marching ants animation - fires every 50ms when selection is active + let marching_ants = if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) { + iced::Subscription::run_with_id( + std::time::Instant::now(), + iced::futures::stream::unfold((), |_| async { + // Wait 50ms between animation frames (~20fps for smooth marching ants) + iced::futures::future::ready(()).await; + // Use a simple yield to create animation frames + Some((Message::CompositeRefresh, ())) + }), + ) + } else { + iced::Subscription::none() + }; + + iced::Subscription::batch(vec![keyboard, mouse, marching_ants]) } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index d51678b..b8ef2f5 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -49,8 +49,10 @@ struct OverlayProgram { zoom: f32, /// Pan offset in screen pixels. pan_offset: Vector, - /// Selection rectangle in canvas-space (x0, y0, x1, y1). + /// Selection rectangle in canvas-space (x0, y0, x1, y1) - used during drag. selection_rect: Option<(f32, f32, f32, f32)>, + /// Selection bounds from engine (x, y, width, height) - used after selection is created. + selection_bounds: Option<(u32, u32, u32, u32)>, /// Vector draw preview in canvas-space ((x0, y0), (x1, y1)). vector_draw: Option<((f32, f32), (f32, f32))>, /// Active selection transform (for drawing handles). @@ -59,6 +61,8 @@ struct OverlayProgram { active_handle: TransformHandle, /// Crop tool state (for drawing crop overlay). crop_state: Option, + /// Marching ants animation offset (0.0..1.0). + marching_ants_offset: f32, } /// Overlay state — tracks hover and cursor position for crosshair. @@ -330,6 +334,7 @@ impl canvas::Program for OverlayProgram { // ── Selection rectangle or transform handles ─────────────────────── let has_overlays = self.selection_rect.is_some() + || self.selection_bounds.is_some() || self.vector_draw.is_some() || self.selection_transform.is_some() || self.crop_state.as_ref().map_or(false, |c| c.active); @@ -337,7 +342,7 @@ impl canvas::Program for OverlayProgram { if has_overlays { let mut frame = Frame::new(renderer, bounds.size()); - // Draw selection rectangle (when not in transform mode) + // Draw selection rectangle during drag (solid blue line) 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; @@ -356,6 +361,50 @@ impl canvas::Program for OverlayProgram { } } + // Draw marching ants for active selection (after selection is created) + if let Some((sx, sy, sw, sh)) = self.selection_bounds { + if self.selection_transform.is_none() && self.selection_rect.is_none() { + let sel_x = origin_x + sx as f32 * self.zoom; + let sel_y = origin_y + sy as f32 * self.zoom; + let sel_w = sw as f32 * self.zoom; + let sel_h = sh as f32 * self.zoom; + + // Calculate dash offset for marching ants animation + // The offset moves the dash pattern to create the marching effect + let dash_length = 8.0; + let gap_length = 4.0; + let total_cycle = dash_length + gap_length; + let animated_offset = (self.marching_ants_offset * total_cycle) as usize; + + // Draw black dashes (background) + 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.0, 0.0)), + width: 2.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[dash_length, gap_length], + offset: animated_offset, + }, + ..Default::default() + }); + + // Draw white dashes (foreground) - offset by half cycle for marching effect + let white_offset = (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize); + frame.stroke(&sel_path, Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), + width: 2.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[dash_length, gap_length], + offset: white_offset, + }, + ..Default::default() + }); + } + } + // Draw vector shape preview if let Some(((x0, y0), (x1, y1))) = self.vector_draw { let v_x = origin_x + x0.min(x1) * self.zoom; @@ -493,9 +542,11 @@ impl canvas::Program for OverlayProgram { /// ## Arguments /// * `doc` — document state (engine, composite pixels, zoom, pan, etc.) /// * `tool_state` — current tool state (for status bar info) +/// * `marching_ants_offset` — animation offset for marching ants (0.0..1.0) pub fn view<'a>( doc: &'a crate::app::IcedDocument, tool_state: &'a crate::app::ToolState, + marching_ants_offset: f32, ) -> Element<'a, Message> { let engine_w = doc.engine.canvas_width(); let engine_h = doc.engine.canvas_height(); @@ -524,10 +575,12 @@ pub fn view<'a>( zoom, pan_offset, selection_rect: doc.selection_rect, + selection_bounds: doc.selection_bounds, vector_draw: doc.vector_draw, selection_transform: doc.selection_transform.clone(), active_handle: TransformHandle::None, // TODO: track hover state crop_state: Some(doc.crop_state.clone()), + marching_ants_offset, }; let overlay_canvas = canvas(overlay_program) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index f0e76bb..abac0ec 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -40,7 +40,7 @@ pub fn dock_view<'a>( let doc_tab_bar = document_tab_bar(app, colors); let canvas = { let doc = &app.documents[app.active_doc]; - crate::canvas::view(doc, &app.tool_state) + crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset) }; column![doc_tab_bar, canvas] .width(Length::Fill) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs index 9b940c5..444b63d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs @@ -1,6 +1,7 @@ //! Merged toolbar — SVG icon buttons + tool options in one row. //! -//! **Purpose:** Single row matching Photopea: [SVG icons] | [Tool Name] [Tool Options] +//! **Purpose:** Single row matching Photopea: [SVG icons] | [Blend Mode] [Opacity] [Flow] [Smooth] +//! Photopea shows: Blend Mode: Normal | Opacity: 100% | Flow: 100% | Smooth: 0% //! Icons are 16px SVGs loaded from assets/icons/. use crate::app::Message; @@ -10,14 +11,14 @@ use hcie_engine_api::Tool; use iced::widget::{button, container, row, slider, svg, text}; use iced::{Element, Length}; -/// Build the merged toolbar: SVG icon buttons + tool options in one row. +/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row. pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element<'_, Message> { let c = colors; // ── SVG icon buttons (no text) ── let new_btn = svg_btn("icons/new_file.svg", "New", Message::MenuAction(0, 0), c); let open_btn = svg_btn("icons/open_file.svg", "Open", Message::MenuAction(0, 1), c); - let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 6), c); + let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 7), c); let undo_btn = svg_btn("icons/undo.svg", "Undo", Message::Undo, c); let redo_btn = svg_btn("icons/redo.svg", "Redo", Message::Redo, c); @@ -25,12 +26,36 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< .spacing(0) .align_y(iced::Alignment::Center); - // ── Tool name + options ── - let tool_name = text(tool_label(tool_state.active_tool)) - .size(11) - .width(Length::Fixed(70.0)); + // ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ── + let blend_label = text("Blend Mode:").size(10).color(c.text_secondary); + let blend_value = text("Normal").size(10).color(c.text_primary); - let options = match tool_state.active_tool { + let opacity_label = text("Opacity:").size(10).color(c.text_secondary); + let opacity_value = text(format!("{:.0}%", tool_state.brush_opacity * 100.0)).size(10).color(c.text_primary); + let opacity_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged) + .step(0.01) + .width(Length::Fixed(60.0)); + + let flow_label = text("Flow:").size(10).color(c.text_secondary); + let flow_value = text("100%").size(10).color(c.text_primary); + + let smooth_label = text("Smooth:").size(10).color(c.text_secondary); + let smooth_value = text("0%").size(10).color(c.text_primary); + + let tool_options = row![ + blend_label, blend_value, + sep(c), + opacity_label, opacity_value, opacity_sl, + sep(c), + flow_label, flow_value, + sep(c), + smooth_label, smooth_value, + ] + .spacing(4) + .align_y(iced::Alignment::Center); + + // ── Tool-specific options (right side) ── + let tool_options_extra = match tool_state.active_tool { Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => { let sz = text(format!("Size: {:.0}", tool_state.brush_size)) .size(10) @@ -38,19 +63,13 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged) .step(1.0) .width(Length::Fixed(80.0)); - let op = text(format!("Opacity: {:.0}%", tool_state.brush_opacity * 100.0)) - .size(10) - .width(Length::Fixed(65.0)); - let op_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged) - .step(0.01) - .width(Length::Fixed(80.0)); let ha = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0)) .size(10) .width(Length::Fixed(70.0)); let ha_sl = slider(0.0..=1.0, tool_state.brush_hardness, Message::BrushHardnessChanged) .step(0.01) .width(Length::Fixed(80.0)); - row![sz, sz_sl, op, op_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center) + row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center) } Tool::MagicWand => { row![ @@ -80,15 +99,12 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< _ => row![].spacing(0), }; - let right_side = row![tool_name, options] - .spacing(4) - .align_y(iced::Alignment::Center); - let bar = row![ actions, sep(c), - right_side, + tool_options, iced::widget::Space::with_width(Length::Fill), + tool_options_extra, ] .spacing(4) .align_y(iced::Alignment::Center) @@ -101,29 +117,6 @@ pub fn view(tool_state: &crate::app::ToolState, colors: ThemeColors) -> Element< .into() } -fn tool_label(tool: Tool) -> &'static str { - match tool { - Tool::Move => "Move", - Tool::Select => "Rect Select", - Tool::Lasso => "Lasso", - Tool::MagicWand => "Magic Wand", - Tool::Eyedropper => "Eyedropper", - Tool::Crop => "Crop", - Tool::Brush => "Brush", - Tool::Pen => "Pen", - Tool::Spray => "Spray", - Tool::Eraser => "Eraser", - Tool::FloodFill => "Paint Bucket", - Tool::Gradient => "Gradient", - Tool::Text => "Text", - Tool::VectorSelect => "Path Select", - Tool::VectorLine => "Line", - Tool::VectorRect => "Rectangle", - Tool::VectorCircle => "Ellipse", - _ => "Tool", - } -} - /// SVG icon button (16px icon, no text). fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> { let c = colors;