feat(iced): add selection state types for transform, crop

This commit is contained in:
2026-07-15 03:01:25 +03:00
parent 4dccf18743
commit f30858c65d
5 changed files with 463 additions and 146 deletions
+239 -146
View File
@@ -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<bool>,
/// 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,
}
/// 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
_ => {}
}
}
@@ -9,6 +9,7 @@ mod dialogs;
mod dock;
mod io;
mod panels;
mod selection;
mod settings;
mod sidebar;
mod theme;
@@ -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;
}
}
@@ -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};
@@ -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<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: &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",
}
}
}