diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 5b5ce10..69e2a06 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -16,6 +16,7 @@ use crate::dialogs; use crate::dock::state::{DockState, PaneType}; use crate::io::tablet::TabletState; use crate::panels; +use crate::selection::{CropState, SelectionTransform, TransformHandle}; use crate::theme::ThemeState; use hcie_engine_api::{BlendMode, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN}; use iced::widget::{column, container, text}; @@ -196,6 +197,14 @@ pub struct IcedDocument { /// Whether a full texture upload is needed (first frame, resize, file load). /// Set to true after loading a file or changing canvas dimensions. pub full_upload: std::cell::RefCell, + /// Active selection transform (floating pixels after cut/copy-paste) + pub selection_transform: Option, + /// Transform handle being dragged + pub transform_drag_handle: TransformHandle, + /// Internal clipboard for copy/paste operations + pub internal_clipboard: Option, + /// Crop tool state + pub crop_state: CropState, } /// Drawing tool state. @@ -512,6 +521,10 @@ impl HcieIcedApp { render_generation: 0, dirty_region: std::cell::RefCell::new(None), full_upload: std::cell::RefCell::new(true), + selection_transform: None, + transform_drag_handle: TransformHandle::None, + internal_clipboard: None, + crop_state: CropState::default(), }; // Load saved settings @@ -1788,6 +1801,10 @@ impl HcieIcedApp { render_generation: 0, dirty_region: std::cell::RefCell::new(None), full_upload: std::cell::RefCell::new(true), + selection_transform: None, + transform_drag_handle: TransformHandle::None, + internal_clipboard: None, + crop_state: CropState::default(), }); self.active_doc = self.documents.len() - 1; self.active_dialog = ActiveDialog::None; @@ -2203,6 +2220,10 @@ impl HcieIcedApp { render_generation: 0, dirty_region: std::cell::RefCell::new(None), full_upload: std::cell::RefCell::new(true), + selection_transform: None, + transform_drag_handle: TransformHandle::None, + internal_clipboard: None, + crop_state: CropState::default(), }); } @@ -2338,169 +2359,241 @@ impl HcieIcedApp { Message::MenuAction(menu_idx, item_idx) => { self.active_menu = None; match (menu_idx, item_idx) { - (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New - (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open - (0, _) if item_idx >= 2 => { - let recent_count = self.recent_files.len().min(10); - let static_base = if recent_count > 0 { 4 + recent_count + 3 } else { 2 }; - if item_idx >= static_base { - match item_idx - static_base { - 0 => return Task::perform(async {}, |_| Message::SaveFileAs), - 1 => return Task::perform(async {}, |_| Message::SaveFileAs), - 6 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG - 7 => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG - 9 => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), - 10 => { - if let Some(id) = self.window_id { - return iced::window::close(id); - } - } - _ => {} - } - } - } + // ══════════════════════════════════════════ + // File menu (0) — Photopea-style + // ══════════════════════════════════════════ + (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New... + (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open... + // (0, 2) = Open & Place... + // (0, 3) = Open More (submenu) + // (0, 4) = separator + // (0, 5) = Share + // (0, 6) = separator + (0, 7) => return Task::perform(async {}, |_| Message::SaveFile), // Save + (0, 8) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save as PSD + // (0, 9) = Save More (submenu) + // (0, 10) = Export as (submenu) + (0, 11) => return Task::perform(async {}, |_| Message::SaveFileAs), // Print... + // (0, 12) = separator + // (0, 13) = Export Layers... + // (0, 14) = Export Color Lookup... + // (0, 15) = File Info... + // (0, 16) = separator + // (0, 17) = Automate (submenu) + // (0, 18) = Script... - // ── Edit menu (1) ── - (1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo - (1, 1) => return Task::perform(async {}, |_| Message::Redo), // Redo - // (1, 2) = separator - (1, 3) => return Task::perform(async {}, |_| Message::CopyImage), // Cut (copy for now) - (1, 4) => return Task::perform(async {}, |_| Message::CopyImage), // Copy - (1, 5) => return Task::perform(async {}, |_| Message::PasteImage), // Paste - // (1, 6) = separator - (1, 7) => { // 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)); - } - (1, 8) => { self.documents[self.active_doc].selection_rect = None; } // Deselect - // (1, 9) = Invert Selection — TODO - // (1, 10) = separator - // (1, 11) = Fill — TODO + // ══════════════════════════════════════════ + // Edit menu (1) — Photopea-style + // ══════════════════════════════════════════ + (1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo / Redo + (1, 1) => return Task::perform(async {}, |_| Message::Redo), // Step Forward + (1, 2) => return Task::perform(async {}, |_| Message::Undo), // Step Backward + // (1, 3) = separator + // (1, 4) = Fade... + // (1, 5) = separator + (1, 6) => return Task::perform(async {}, |_| Message::CopyImage), // Cut + (1, 7) => return Task::perform(async {}, |_| Message::CopyImage), // Copy + (1, 8) => return Task::perform(async {}, |_| Message::CopyImage), // Copy Merged + (1, 9) => return Task::perform(async {}, |_| Message::PasteImage), // Paste + // (1, 10) = Clear + // (1, 11) = separator + // (1, 12) = Fill... + // (1, 13) = Stroke... + // (1, 14) = separator + // (1, 15) = Content-Aware Scale + // (1, 16) = Puppet Warp + // (1, 17) = Perspective Warp + // (1, 18) = Free Transform + // (1, 19) = Transform (submenu) + // (1, 20) = Auto-Align + // (1, 21) = Auto-Blend + // (1, 22) = separator + // (1, 23) = Assign Profile (submenu) + // (1, 24) = Convert to Profile (submenu) + // (1, 25) = separator + // (1, 26) = Define New (submenu) + // (1, 27) = Preset Manager... + // (1, 28) = Preferences... + // (1, 29) = Local Storage... - // ── Tools menu (2) ── - (2, 0) => self.tool_state.active_tool = Tool::Move, - (2, 1) => self.tool_state.active_tool = Tool::VectorSelect, - // (2, 2) = separator - (2, 3) => self.tool_state.active_tool = Tool::Select, - (2, 4) => self.tool_state.active_tool = Tool::Lasso, - (2, 5) => self.tool_state.active_tool = Tool::PolygonSelect, - (2, 6) => self.tool_state.active_tool = Tool::MagicWand, - // (2, 7) = separator - (2, 8) => self.tool_state.active_tool = Tool::Crop, - (2, 9) => self.tool_state.active_tool = Tool::Eyedropper, - // (2, 10) = separator - (2, 11) => self.tool_state.active_tool = Tool::Brush, - (2, 12) => self.tool_state.active_tool = Tool::Pen, - (2, 13) => self.tool_state.active_tool = Tool::Spray, - (2, 14) => self.tool_state.active_tool = Tool::Eraser, - // (2, 15) = separator - (2, 16) => self.tool_state.active_tool = Tool::FloodFill, - (2, 17) => self.tool_state.active_tool = Tool::Gradient, - // (2, 18) = separator - (2, 19) => self.tool_state.active_tool = Tool::Text, - // (2, 20) = separator - (2, 21) => self.tool_state.active_tool = Tool::VectorLine, - (2, 22) => self.tool_state.active_tool = Tool::VectorRect, - (2, 23) => self.tool_state.active_tool = Tool::VectorCircle, - // (2, 24) = separator - // (2, 25) = Reset Tool — TODO + // ══════════════════════════════════════════ + // Image menu (2) — Photopea-style + // ══════════════════════════════════════════ + // (2, 0) = Mode (submenu) + // (2, 1) = Adjustments (submenu) + // (2, 2) = Auto Tone + // (2, 3) = Auto Contrast + // (2, 4) = Auto Color + // (2, 5) = separator + // (2, 6) = Reduce Colors... + // (2, 7) = Vectorize Bitmap... + // (2, 8) = Wavelet Decompose... + // (2, 9) = separator + // (2, 10) = Canvas Size... + // (2, 11) = Image Size... + // (2, 12) = Transform (submenu) + // (2, 13) = Crop + // (2, 14) = Trim... + // (2, 15) = Reveal All + // (2, 16) = separator + // (2, 17) = Duplicate + // (2, 18) = Apply Image... + // (2, 19) = separator + // (2, 20) = Variables... - // ── Image menu (3) ── - // (3, 0) = Canvas Size — TODO - // (3, 1) = Image Size — TODO - // (3, 2) = separator - // (3, 5) = Rotate 180 — TODO - // (3, 6) = separator - // (3, 9) = separator - // (3, 10) = Crop to Selection — TODO - - // ── Layer menu (4) ── - (4, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New Layer - // (4, 1) = Duplicate Layer — TODO - (4, 2) => { // Delete Layer + // ══════════════════════════════════════════ + // Layer menu (3) — Photopea-style + // ══════════════════════════════════════════ + (3, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New (submenu) + // (3, 1) = Duplicate Layer — TODO: engine doesn't support yet + // (3, 2) = Duplicate Into... + (3, 3) => { // Delete let id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.delete_layer(id); } - // (4, 3) = separator - (4, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down - (4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image - // (4, 6) = separator - (4, 7) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Styles - // (4, 10) = separator - // (4, 11) = Align Left — TODO - // (4, 12) = Align Center — TODO - // (4, 13) = Align Right — TODO + // (3, 4) = separator + // (3, 5) = Text (submenu) + (3, 6) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Style (submenu) + // (3, 7) = separator + // (3, 8) = New Fill Layer (submenu) + // (3, 9) = New Adjustment Layer (submenu) + // (3, 10) = separator + // (3, 11) = Raster Mask (submenu) + // (3, 12) = Vector Mask (submenu) + // (3, 13) = Clipping Mask (disabled) + // (3, 14) = separator + // (3, 15) = Smart Object (submenu) + // (3, 16) = Rasterize + // (3, 17) = Rasterize Layer Style + // (3, 18) = separator + // (3, 19) = Group Layers + // (3, 20) = Arrange (submenu) + // (3, 21) = Combine Shapes (submenu) + // (3, 22) = Animation (submenu) + // (3, 23) = separator + (3, 24) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down + (3, 25) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image + // (3, 26) = Defringe - // ── Filter menu (5) ── - // (5, 0) = ─ Blur (disabled) - (5, 1) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::BoxBlur)), - (5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)), - (5, 3) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::MotionBlur)), - // (5, 4) = ─ Sharpen (disabled) - (5, 5) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::UnsharpMask)), - // (5, 6) = ─ Pixelate (disabled) - (5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)), - (5, 8) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Crystallize)), - // (5, 9) = ─ Distort (disabled) - (5, 10) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Pinch)), - (5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)), - // (5, 12) = ─ Stylize (disabled) - (5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)), - // (5, 14) = ─ Color & Light (disabled) - (5, 15) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Levels)), - (5, 16) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Vibrance)), - (5, 17) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Exposure)), - (5, 18) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Posterize)), - (5, 19) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Threshold)), - // (5, 20) = ─ Restore (disabled) - (5, 21) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Dehaze)), - - // ── Select menu (6) ── - (6, 0) => { // Select All + // ══════════════════════════════════════════ + // 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)); } - (6, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect - // (6, 2) = Invert Selection — TODO - // (6, 3) = separator - (6, 4) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow - (6, 5) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink - (6, 6) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather + (4, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect + // (4, 2) = Inverse + // (4, 3) = separator + // (4, 4) = Remove BG + // (4, 5) = Color Range... + // (4, 6) = Magic Cut... + // (4, 7) = Subject + // (4, 8) = separator + // (4, 9) = Refine Edge... + // (4, 10) = Modify (submenu) + (4, 11) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow + // (4, 12) = Similar + // (4, 13) = separator + // (4, 14) = Transform Selection + // (4, 15) = Quick Mask Mode + // (4, 16) = separator + // (4, 17) = Load Selection + // (4, 18) = Save Selection - // ── View menu (7) ── - (7, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In - (7, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out - (7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Zoom Reset - // (7, 3) = separator - (7, 4) => { // Fit to Window - // Will be handled in view() with access to window size + // ══════════════════════════════════════════ + // Filter menu (5) — Photopea-style + // ══════════════════════════════════════════ + // (5, 0) = Last Filter + // (5, 1) = separator + // (5, 2) = Filter Gallery... + // (5, 3) = Lens Correction... + // (5, 4) = Camera Raw... + // (5, 5) = Liquify... + // (5, 6) = Vanishing Point... + // (5, 7) = separator + // (5, 8) = 3D (submenu) + // (5, 9) = Blur (submenu) + // (5, 10) = Blur Gallery (submenu) + // (5, 11) = Distort (submenu) + // (5, 12) = Noise (submenu) + // (5, 13) = Pixelate (submenu) + // (5, 14) = Render (submenu) + // (5, 15) = Sharpen (submenu) + // (5, 16) = Stylize (submenu) + // (5, 17) = Other (submenu) + // (5, 18) = Fourier (submenu) + + // ══════════════════════════════════════════ + // View menu (6) — Photopea-style + // ══════════════════════════════════════════ + (6, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In + (6, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out + (6, 2) => { // Fit The Area self.documents[self.active_doc].zoom = 1.0; } - (7, 5) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // 100% - (7, 6) => return Task::perform(async {}, |_| Message::CanvasZoomSet(2.0)), // 200% - // (7, 7) = separator - // (7, 8) = Debug Mode — TODO + (6, 3) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Pixel to Pixel (100%) + // (6, 4) = Pattern Preview + // (6, 5) = separator + // (6, 6) = Mode (submenu) + // (6, 7) = Extras + // (6, 8) = Show (submenu) + // (6, 9) = separator + // (6, 10) = Rulers + // (6, 11) = separator + // (6, 12) = Snap + // (6, 13) = Snap To (submenu) + // (6, 14) = separator + // (6, 15) = Lock Guides + // (6, 16) = Clear Guides + // (6, 17) = Add Guides... + // (6, 18) = Guides from Layer + // (6, 19) = separator + // (6, 20) = Clear Slices - // ── Window menu (8) ── - (8, 0) => self.dock.reopen_pane(PaneType::Layers), - (8, 1) => self.dock.reopen_pane(PaneType::History), - (8, 2) => self.dock.reopen_pane(PaneType::Brushes), - (8, 3) => self.dock.reopen_pane(PaneType::Filters), - (8, 4) => self.dock.reopen_pane(PaneType::ColorPicker), - (8, 5) => self.dock.reopen_pane(PaneType::Properties), - (8, 6) => self.dock.reopen_pane(PaneType::AiChat), - (8, 7) => self.dock.reopen_pane(PaneType::Geometry), - (8, 8) => self.dock.reopen_pane(PaneType::Script), - (8, 9) => self.dock.reopen_pane(PaneType::LayerDetails), + // ══════════════════════════════════════════ + // Window menu (7) — Photopea-style + // ══════════════════════════════════════════ + (7, 0) => self.dock.reopen_pane(PaneType::Layers), + (7, 1) => self.dock.reopen_pane(PaneType::History), + (7, 2) => self.dock.reopen_pane(PaneType::Brushes), + (7, 3) => self.dock.reopen_pane(PaneType::Filters), + (7, 4) => self.dock.reopen_pane(PaneType::ColorPicker), + (7, 5) => self.dock.reopen_pane(PaneType::Properties), + (7, 6) => self.dock.reopen_pane(PaneType::AiChat), + (7, 7) => self.dock.reopen_pane(PaneType::Geometry), + (7, 8) => self.dock.reopen_pane(PaneType::Script), + (7, 9) => self.dock.reopen_pane(PaneType::LayerDetails), - // ── Help menu (9) ── - // (9, 0) = About — TODO - // (9, 1) = Documentation — TODO - // (9, 2) = License — TODO + // ══════════════════════════════════════════ + // More menu (8) — Photopea-style + // ══════════════════════════════════════════ + // (8, 0) = More (submenu) + // (8, 1) = separator + // (8, 2) = Plugins + // (8, 3) = Actions + // (8, 4) = Adjustments + // (8, 5) = Brush + // (8, 6) = Channels + // (8, 7) = Character + // (8, 8) = Character Styles + // (8, 9) = Color + // (8, 10) = Glyphs + // (8, 11) = Histogram + // (8, 12) = History + // (8, 13) = Info + // (8, 14) = Navigator + // (8, 15) = Notes + // (8, 16) = Paragraph + // (8, 17) = Paths + // (8, 18) = Properties + // (8, 19) = Style + // (8, 20) = Swatches + // (8, 21) = Tool Presets + // (8, 22) = Vector Info - // Ignore separators and unimplemented items + // Ignore separators, submenus, and unimplemented items _ => {} } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 6b7f4df..fb8e44b 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -9,6 +9,7 @@ mod dialogs; mod dock; mod io; mod panels; +mod selection; mod settings; mod sidebar; mod theme; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs new file mode 100644 index 0000000..0442ad7 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs @@ -0,0 +1,66 @@ +//! State for the crop tool. +//! +//! `CropState` manages the lifecycle of a crop rectangle from initial drag +//! through confirmation or cancellation. + +/// 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 { + /// Begin a new crop drag at the given canvas position. + 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 the crop rectangle during a 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)); + } + } + + /// Finish the drag (mouse released). + pub fn end_drag(&mut self) { + self.is_dragging = false; + self.drag_start = None; + } + + /// Confirm the crop and return integer pixel bounds. + /// + /// Returns `None` if the crop rectangle is too small. + 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 the crop and clear all state. + pub fn cancel(&mut self) { + self.active = false; + self.rect = None; + self.is_dragging = false; + self.drag_start = None; + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs new file mode 100644 index 0000000..ee4392c --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs @@ -0,0 +1,10 @@ +//! Selection state types for the iced GUI. +//! +//! Contains types for managing floating pixel selections (cut/copy-paste), +//! transform handles, and the crop tool. + +pub mod crop; +pub mod transform; + +pub use crop::CropState; +pub use transform::{SelectionTransform, TransformHandle}; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs new file mode 100644 index 0000000..2a72a4e --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs @@ -0,0 +1,147 @@ +//! Floating pixel selection that can be moved, rotated, and scaled. +//! +//! `SelectionTransform` represents a rectangular selection of pixels that has +//! been cut or copied from the canvas and can be freely transformed before being +//! pasted back. `TransformHandle` identifies which part of the selection's +//! bounding box the user is interacting with. + +use iced::Vector; + +/// Floating pixel selection that can be moved, rotated, and scaled. +#[derive(Clone)] +pub struct SelectionTransform { + /// RGBA pixel data for the selection + pub pixels: Vec, + /// Width of the selection in pixels + pub width: u32, + /// Height of the selection in pixels + pub height: u32, + /// Position in canvas coordinates + pub pos: Vector, + /// Display size (may differ from pixel dimensions when scaled) + pub size: Vector, + /// Rotation in radians + pub rotation: f32, +} + +impl SelectionTransform { + /// Create from engine selection mask. + pub fn from_engine( + engine: &mut hcie_engine_api::Engine, + mask: &[u8], + canvas_w: u32, + canvas_h: u32, + ) -> Self { + let mut min_x = canvas_w; + let mut min_y = canvas_h; + let mut max_x = 0u32; + let mut max_y = 0u32; + + // Find bounding box of non-zero mask pixels + for y in 0..canvas_h { + for x in 0..canvas_w { + let idx = ((y * canvas_w + x) * 4 + 3) as usize; + if idx < mask.len() && mask[idx] > 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); + } + } + } + + if min_x > max_x || min_y > max_y { + return Self::empty(); + } + + let width = max_x - min_x + 1; + let height = max_y - min_y + 1; + + // Extract RGBA pixels within the bounding box + let mut pixels = vec![0u8; (width * height * 4) as usize]; + let comp = engine.get_composite_pixels(); + + for y in 0..height { + for x in 0..width { + let src_x = min_x + x; + let src_y = min_y + y; + let src_idx = ((src_y * canvas_w + src_x) * 4) as usize; + let dst_idx = ((y * width + x) * 4) as usize; + + if src_idx + 3 < comp.len() && dst_idx + 3 < pixels.len() { + let mask_idx = ((src_y * canvas_w + src_x) * 4 + 3) as usize; + let alpha = if mask_idx < mask.len() { mask[mask_idx] } else { 0 }; + + pixels[dst_idx] = comp[src_idx]; + pixels[dst_idx + 1] = comp[src_idx + 1]; + pixels[dst_idx + 2] = comp[src_idx + 2]; + pixels[dst_idx + 3] = alpha; + } + } + } + + Self { + pixels, + width, + height, + pos: Vector::new(min_x as f32, min_y as f32), + size: Vector::new(width as f32, height as f32), + rotation: 0.0, + } + } + + /// Create an empty transform with no pixel data. + pub fn empty() -> Self { + Self { + pixels: Vec::new(), + width: 0, + height: 0, + pos: Vector::ZERO, + size: Vector::ZERO, + 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", + } + } +}