Files
hcie-rust-v3.05/docs/compose/plans/2025-07-15-iced-selection-features.md
phantom 1fd0c5291a feat(iced): add marching ants animation for selection
- Added marching_ants_offset to HcieIcedApp for animation state
- Added selection_bounds field to IcedDocument for persistent selection display
- Updated OverlayProgram to draw animated marching ants (black + white dashed lines)
- Added timer subscription for continuous animation when selection is active
- Updated SelectAll/Deselect handlers to properly manage selection state
- Selection now remains visible after creation with animated border
2026-07-15 04:11:48 +03:00

51 KiB

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

// 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
// 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
// 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
// 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()
// 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
// 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
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

// 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
// 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
// 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
// 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
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

// 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
// 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
// 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
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

// 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
// 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
// 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
// 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
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

// 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
// In selection/mod.rs, add:

pub mod clipboard;
  • Step 3: Update copy/paste handlers
// 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
// In IcedDocument struct, add:

/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
  • Step 5: Initialize selection_mask
// In IcedDocument::new(), add:

selection_mask: None,
  • Step 6: Run cargo check

Run: cargo check -p hcie-iced-gui Expected: PASS

  • Step 7: Commit
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

// In app.rs Message enum, add:

// ── Crop tool ──────────────────────────────────
CropDragStart(f32, f32),
CropDragMove(f32, f32),
CropDragEnd,
CropConfirm,
CropCancel,
  • Step 2: Implement crop message handlers
// 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
// 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
// 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
// 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
// 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
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

// 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
// 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
// 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
// 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
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

// 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
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

// 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
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

// 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
// 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
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):

// 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