From 611a6655da0ee63a73ba4d5ebf4642d875cf98b8 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Wed, 15 Jul 2026 03:26:07 +0300 Subject: [PATCH] feat(iced): implement selection menu operations - Add Inverse selection (Shift+Ctrl+I) with engine.invert_selection() - Add Shrink, Feather, Border, Smooth menu items with parameter dialogs - Implement selection_border() and selection_smooth() in engine API - Implement border_mask() and smooth_mask() in hcie-selection crate - Update SelectionOpApply handler for all selection operations --- hcie-engine-api/src/lib.rs | 30 ++++ hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 25 ++- .../crates/hcie-iced-gui/src/panels/menus.rs | 160 +++++++++++------- hcie-selection/src/lib.rs | 83 +++++++++ 4 files changed, 226 insertions(+), 72 deletions(-) diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 4318933..459fada 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -412,6 +412,36 @@ impl Engine { } } + /// Create a border from the current selection. + /// + /// **Purpose:** Extracts only the edge pixels of the selection, keeping + /// pixels that are selected but have at least one unselected neighbor. + /// + /// **Arguments:** + /// - `px`: Border thickness in pixels. + pub fn selection_border(&mut self, px: u32) { + if let Some(ref mut mask) = self.document.selection_mask { + let w = self.document.canvas_width; + let h = self.document.canvas_height; + hcie_selection::border_mask(mask, w, h, px); + } + } + + /// Smooth the current selection edges. + /// + /// **Purpose:** Softens jagged selection edges by applying a box blur + /// within the specified radius. + /// + /// **Arguments:** + /// - `px`: Blur radius in pixels. + pub fn selection_smooth(&mut self, px: u32) { + if let Some(ref mut mask) = self.document.selection_mask { + let w = self.document.canvas_width; + let h = self.document.canvas_height; + hcie_selection::smooth_mask(mask, w, h, px); + } + } + pub fn selection_invert(&mut self) { if let Some(ref mut mask) = self.document.selection_mask { hcie_selection::invert_mask(mask); 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 d994ccd..11858de 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -1941,6 +1941,8 @@ impl HcieIcedApp { "Grow" => self.documents[self.active_doc].engine.selection_grow(v), "Shrink" => self.documents[self.active_doc].engine.selection_shrink(v), "Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32), + "Border" => self.documents[self.active_doc].engine.selection_border(v), + "Smooth" => self.documents[self.active_doc].engine.selection_smooth(v), _ => {} } } @@ -2685,7 +2687,11 @@ impl HcieIcedApp { 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 + (4, 2) => { // Inverse + self.documents[self.active_doc].engine.selection_invert(); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } // (4, 3) = separator // (4, 4) = Remove BG // (4, 5) = Color Range... @@ -2695,13 +2701,18 @@ impl HcieIcedApp { // (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, 12) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink + (4, 13) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather + (4, 14) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Border"); } // Border + (4, 15) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Smooth"); } // Smooth // (4, 16) = separator - // (4, 17) = Load Selection - // (4, 18) = Save Selection + // (4, 17) = Similar + // (4, 18) = separator + // (4, 19) = Transform Selection + // (4, 20) = Quick Mask Mode + // (4, 21) = separator + // (4, 22) = Load Selection + // (4, 23) = Save Selection // ══════════════════════════════════════════ // Filter menu (5) — Photopea-style diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index 58ea404..6e94299 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -2,7 +2,7 @@ //! //! **Purpose:** Renders menu items as a floating dropdown matching Photopea's design. //! Photopea uses light gray backgrounds (#e8e8e8) with dark text, keyboard shortcuts -//! on the right, separator lines, and blue hover highlights. +//! on the right, separator lines, and blue hover highlights. Items with ">" have sub-menus. //! //! **Design reference:** Photopea image editor (93 screenshots analyzed). //! - Menu background: #e8e8e8 (light gray) @@ -10,6 +10,7 @@ //! - Hover: #b3d9ff (light blue) //! - Shortcuts: #666666 (gray, right-aligned) //! - Separators: 1px #cccccc lines +//! - Sub-menus: indicated by ">" arrow on the right use crate::app::Message; use crate::theme::ThemeColors; @@ -22,27 +23,32 @@ struct MenuItem { label: String, shortcut: String, enabled: bool, + has_submenu: bool, } impl MenuItem { fn new(label: &str) -> Self { - Self { label: label.to_string(), shortcut: String::new(), enabled: true } + Self { label: label.to_string(), shortcut: String::new(), enabled: true, has_submenu: false } } fn with_shortcut(label: &str, shortcut: &str) -> Self { - Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true } + Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true, has_submenu: false } + } + + fn submenu(label: &str) -> Self { + Self { label: label.to_string(), shortcut: String::new(), enabled: true, has_submenu: true } } fn separator() -> Self { - Self { label: "─".to_string(), shortcut: String::new(), enabled: false } + Self { label: "─".to_string(), shortcut: String::new(), enabled: false, has_submenu: false } } fn disabled(label: &str) -> Self { - Self { label: label.to_string(), shortcut: String::new(), enabled: false } + Self { label: label.to_string(), shortcut: String::new(), enabled: false, has_submenu: false } } } -/// All menu definitions matching Photopea's menu structure. +/// All menu definitions matching Photopea's menu structure exactly. #[derive(Clone)] struct MenuDef { label: String, @@ -55,21 +61,21 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::with_shortcut("New...", "Alt+Ctrl+N"), MenuItem::with_shortcut("Open...", "Alt+Ctrl+O"), MenuItem::new("Open & Place..."), - MenuItem::new("Open More"), + MenuItem::submenu("Open More"), MenuItem::separator(), MenuItem::new("Share"), MenuItem::separator(), MenuItem::with_shortcut("Save", "Ctrl+S"), MenuItem::new("Save as PSD"), - MenuItem::new("Save More"), - MenuItem::new("Export as"), + MenuItem::submenu("Save More"), + MenuItem::submenu("Export as"), MenuItem::with_shortcut("Print...", "Ctrl+P"), MenuItem::separator(), MenuItem::new("Export Layers..."), MenuItem::new("Export Color Lookup..."), MenuItem::new("File Info..."), MenuItem::separator(), - MenuItem::new("Automate"), + MenuItem::submenu("Automate"), MenuItem::new("Script..."), ]; @@ -108,14 +114,14 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Puppet Warp"), MenuItem::new("Perspective Warp"), MenuItem::with_shortcut("Free Transform", "Alt+Ctrl+T"), - MenuItem::new("Transform"), + MenuItem::submenu("Transform"), MenuItem::new("Auto-Align"), MenuItem::new("Auto-Blend"), MenuItem::separator(), - MenuItem::new("Assign Profile"), - MenuItem::new("Convert to Profile"), + MenuItem::submenu("Assign Profile"), + MenuItem::submenu("Convert to Profile"), MenuItem::separator(), - MenuItem::new("Define New"), + MenuItem::submenu("Define New"), MenuItem::new("Preset Manager..."), MenuItem::with_shortcut("Preferences...", "Ctrl+K"), MenuItem::new("Local Storage..."), @@ -125,8 +131,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuDef { label: "Image".to_string(), items: vec![ - MenuItem::new("Mode"), - MenuItem::new("Adjustments"), + MenuItem::submenu("Mode"), + MenuItem::submenu("Adjustments"), MenuItem::new("Auto Tone"), MenuItem::new("Auto Contrast"), MenuItem::new("Auto Color"), @@ -137,9 +143,9 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::separator(), MenuItem::with_shortcut("Canvas Size...", "Alt+Ctrl+C"), MenuItem::with_shortcut("Image Size...", "Alt+Ctrl+I"), - MenuItem::new("Transform"), + MenuItem::submenu("Transform"), MenuItem::new("Crop"), - MenuItem::new("Trim..."), + MenuItem::with_shortcut("Trim...", "Ctrl+."), MenuItem::new("Reveal All"), MenuItem::separator(), MenuItem::new("Duplicate"), @@ -152,29 +158,29 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuDef { label: "Layer".to_string(), items: vec![ - MenuItem::new("New"), + MenuItem::submenu("New"), MenuItem::new("Duplicate Layer"), MenuItem::new("Duplicate Into ..."), MenuItem::new("Delete"), MenuItem::separator(), - MenuItem::new("Text"), - MenuItem::new("Layer Style"), + MenuItem::submenu("Text"), + MenuItem::submenu("Layer Style"), MenuItem::separator(), - MenuItem::new("New Fill Layer"), - MenuItem::new("New Adjustment Layer"), + MenuItem::submenu("New Fill Layer"), + MenuItem::submenu("New Adjustment Layer"), MenuItem::separator(), - MenuItem::new("Raster Mask"), - MenuItem::new("Vector Mask"), - MenuItem::new("Clipping Mask"), + MenuItem::submenu("Raster Mask"), + MenuItem::submenu("Vector Mask"), + MenuItem::disabled("Clipping Mask"), MenuItem::separator(), - MenuItem::new("Smart Object"), + MenuItem::submenu("Smart Object"), MenuItem::new("Rasterize"), MenuItem::new("Rasterize Layer Style"), MenuItem::separator(), MenuItem::with_shortcut("Group Layers", "Ctrl+G"), - MenuItem::new("Arrange"), - MenuItem::new("Combine Shapes"), - MenuItem::new("Animation"), + MenuItem::submenu("Arrange"), + MenuItem::submenu("Combine Shapes"), + MenuItem::submenu("Animation"), MenuItem::separator(), MenuItem::with_shortcut("Merge Down", "Ctrl+E"), MenuItem::new("Flatten Image"), @@ -195,12 +201,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Subject"), MenuItem::separator(), MenuItem::new("Refine Edge..."), - MenuItem::new("Modify"), - MenuItem::new("Grow"), + 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::new("Quick Mask Mode"), + MenuItem::with_shortcut("Quick Mask Mode", "Q"), MenuItem::separator(), MenuItem::new("Load Selection"), MenuItem::new("Save Selection"), @@ -218,34 +229,47 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Liquify..."), MenuItem::new("Vanishing Point..."), MenuItem::separator(), - MenuItem::new("3D"), - MenuItem::new("Blur"), - MenuItem::new("Blur Gallery"), - MenuItem::new("Distort"), - MenuItem::new("Noise"), - MenuItem::new("Pixelate"), - MenuItem::new("Render"), - MenuItem::new("Sharpen"), - MenuItem::new("Stylize"), - MenuItem::new("Other"), - MenuItem::new("Fourier"), + MenuItem::submenu("3D"), + MenuItem::submenu("Blur"), + MenuItem::submenu("Blur Gallery"), + MenuItem::submenu("Distort"), + MenuItem::submenu("Noise"), + MenuItem::submenu("Pixelate"), + MenuItem::submenu("Render"), + MenuItem::submenu("Sharpen"), + MenuItem::submenu("Stylize"), + MenuItem::submenu("Other"), + MenuItem::submenu("Fourier"), ], }, - // ── View menu ── + // ── View menu (Photopea-style) ── MenuDef { label: "View".to_string(), items: vec![ - MenuItem::new("Zoom In"), - MenuItem::new("Zoom Out"), - MenuItem::new("Fit on Screen"), - MenuItem::new("Actual Pixels"), + MenuItem::with_shortcut("Zoom In", "Ctrl++"), + MenuItem::with_shortcut("Zoom Out", "Ctrl+-"), + MenuItem::with_shortcut("Fit The Area", "Ctrl+0"), + MenuItem::with_shortcut("Pixel to Pixel", "Ctrl+1"), + MenuItem::new("Pattern Preview"), MenuItem::separator(), - MenuItem::new("Rulers"), - MenuItem::new("Grid"), - MenuItem::new("Guides"), + MenuItem::submenu("Mode"), + MenuItem::with_shortcut("Extras", "Ctrl+H"), + MenuItem::submenu("Show"), + MenuItem::separator(), + MenuItem::with_shortcut("Rulers", "Ctrl+R"), + MenuItem::separator(), + MenuItem::new("Snap"), + MenuItem::submenu("Snap To"), + MenuItem::separator(), + MenuItem::new("Lock Guides"), + MenuItem::new("Clear Guides"), + MenuItem::new("Add Guides..."), + MenuItem::new("Guides from Layer"), + MenuItem::separator(), + MenuItem::new("Clear Slices"), ], }, - // ── Window menu ── + // ── Window menu (Photopea-style) ── MenuDef { label: "Window".to_string(), items: vec![ @@ -261,10 +285,12 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Layer Details"), ], }, - // ── More menu ── + // ── More menu (Photopea-style) ── MenuDef { label: "More".to_string(), items: vec![ + MenuItem::submenu("More"), + MenuItem::separator(), MenuItem::new("Plugins"), MenuItem::new("Actions"), MenuItem::new("Adjustments"), @@ -381,16 +407,19 @@ pub fn dropdown_overlay( } else { let lbl = item.label.clone(); let sht = item.shortcut.clone(); - let label_row: Element<'static, Message> = if sht.is_empty() { - row![text(lbl).size(11)].into() - } else { - // Photopea-style: label left, shortcut right in gray - row![ - text(lbl).size(11).width(Length::Fill), - text(sht).size(10), - ] - .spacing(16) - .into() + let has_sub = item.has_submenu; + + // Photopea-style: label left, shortcut right in gray, submenu arrow ">" if needed + let label_row: Element<'static, Message> = { + let mut row_items = vec![]; + row_items.push(text(lbl).size(11).width(Length::Fill).into()); + if !sht.is_empty() { + row_items.push(text(sht).size(10).color(iced::Color::from_rgba(0.4, 0.4, 0.4, 1.0)).into()); + } + if has_sub { + row_items.push(text(">").size(10).color(iced::Color::from_rgba(0.4, 0.4, 0.4, 1.0)).into()); + } + row(row_items).spacing(16).into() }; let enabled = item.enabled; @@ -428,7 +457,8 @@ pub fn dropdown_overlay( .width(Length::Fill) .padding([4, 16]) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(c.menu_bg)), + background: Some(iced::Background::Color(colors.menu_bg)), + text_color: Some(iced::Color::from_rgba(0.6, 0.6, 0.6, 1.0)), ..Default::default() }); items.push(disabled_item.into()); diff --git a/hcie-selection/src/lib.rs b/hcie-selection/src/lib.rs index 6b62684..968a062 100644 --- a/hcie-selection/src/lib.rs +++ b/hcie-selection/src/lib.rs @@ -282,4 +282,87 @@ pub fn lasso_fill_mask(mask: &mut [u8], w: u32, h: u32, points: &[(u32, u32)]) { k += 2; } } +} + +/// Create a border from a selection mask by keeping only edge pixels. +/// +/// **Purpose:** Extracts the outline/border of a selection by keeping pixels +/// that are selected but have at least one unselected neighbor. +/// +/// **Arguments:** +/// - `mask`: The selection mask to process (modified in place). +/// - `w`, `h`: Canvas dimensions. +/// - `thickness`: Border thickness in pixels. +pub fn border_mask(mask: &mut [u8], w: u32, h: u32, thickness: u32) { + if thickness == 0 { return; } + let original = mask.to_vec(); + + // First, create the outer border by eroding + let mut eroded = original.clone(); + for _ in 0..thickness { + let old = eroded.clone(); + for y in 0..h { + for x in 0..w { + let i = (y * w + x) as usize; + if old[i] == 0 { continue; } + let neighbors = [ + (x.wrapping_sub(1), y), (x + 1, y), + (x, y.wrapping_sub(1)), (x, y + 1), + ]; + let mut has_zero = false; + for (nx, ny) in neighbors { + if nx >= w || ny >= h || old[(ny * w + nx) as usize] == 0 { + has_zero = true; + break; + } + } + if has_zero { + eroded[i] = 0; + } + } + } + } + + // Border = original - eroded (pixels in original but not in eroded) + for i in 0..mask.len() { + if original[i] > 0 && eroded[i] == 0 { + mask[i] = original[i]; + } else { + mask[i] = 0; + } + } +} + +/// Smooth a selection mask using a box blur. +/// +/// **Purpose:** Softens jagged edges of a selection by applying a box blur +/// within the specified radius, creating smoother transitions. +/// +/// **Arguments:** +/// - `mask`: The selection mask to process (modified in place). +/// - `w`, `h`: Canvas dimensions. +/// - `radius`: Blur radius in pixels. +pub fn smooth_mask(mask: &mut [u8], w: u32, h: u32, radius: u32) { + if radius == 0 { return; } + let r = radius as i32; + let size = (w * h) as usize; + let mut result = vec![0u8; size]; + + for y in 0..h { + for x in 0..w { + let mut sum = 0u32; + let mut count = 0u32; + for dy in -r..=r { + for dx in -r..=r { + let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as u32; + let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as u32; + sum += mask[(ny * w + nx) as usize] as u32; + count += 1; + } + } + result[(y * w + x) as usize] = (sum / count) as u8; + } + } + + mask.copy_from_slice(&result); } \ No newline at end of file