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