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
This commit is contained in:
2026-07-15 03:26:07 +03:00
parent 017f6f3dd3
commit 611a6655da
4 changed files with 226 additions and 72 deletions
+30
View File
@@ -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) { pub fn selection_invert(&mut self) {
if let Some(ref mut mask) = self.document.selection_mask { if let Some(ref mut mask) = self.document.selection_mask {
hcie_selection::invert_mask(mask); hcie_selection::invert_mask(mask);
+18 -7
View File
@@ -1941,6 +1941,8 @@ impl HcieIcedApp {
"Grow" => self.documents[self.active_doc].engine.selection_grow(v), "Grow" => self.documents[self.active_doc].engine.selection_grow(v),
"Shrink" => self.documents[self.active_doc].engine.selection_shrink(v), "Shrink" => self.documents[self.active_doc].engine.selection_shrink(v),
"Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32), "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)); 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, 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, 3) = separator
// (4, 4) = Remove BG // (4, 4) = Remove BG
// (4, 5) = Color Range... // (4, 5) = Color Range...
@@ -2695,13 +2701,18 @@ impl HcieIcedApp {
// (4, 9) = Refine Edge... // (4, 9) = Refine Edge...
// (4, 10) = Modify (submenu) // (4, 10) = Modify (submenu)
(4, 11) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow (4, 11) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow
// (4, 12) = Similar (4, 12) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink
// (4, 13) = separator (4, 13) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather
// (4, 14) = Transform Selection (4, 14) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Border"); } // Border
// (4, 15) = Quick Mask Mode (4, 15) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Smooth"); } // Smooth
// (4, 16) = separator // (4, 16) = separator
// (4, 17) = Load Selection // (4, 17) = Similar
// (4, 18) = Save Selection // (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 // Filter menu (5) — Photopea-style
@@ -2,7 +2,7 @@
//! //!
//! **Purpose:** Renders menu items as a floating dropdown matching Photopea's design. //! **Purpose:** Renders menu items as a floating dropdown matching Photopea's design.
//! Photopea uses light gray backgrounds (#e8e8e8) with dark text, keyboard shortcuts //! 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). //! **Design reference:** Photopea image editor (93 screenshots analyzed).
//! - Menu background: #e8e8e8 (light gray) //! - Menu background: #e8e8e8 (light gray)
@@ -10,6 +10,7 @@
//! - Hover: #b3d9ff (light blue) //! - Hover: #b3d9ff (light blue)
//! - Shortcuts: #666666 (gray, right-aligned) //! - Shortcuts: #666666 (gray, right-aligned)
//! - Separators: 1px #cccccc lines //! - Separators: 1px #cccccc lines
//! - Sub-menus: indicated by ">" arrow on the right
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
@@ -22,27 +23,32 @@ struct MenuItem {
label: String, label: String,
shortcut: String, shortcut: String,
enabled: bool, enabled: bool,
has_submenu: bool,
} }
impl MenuItem { impl MenuItem {
fn new(label: &str) -> Self { 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 { 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 { 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 { 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)] #[derive(Clone)]
struct MenuDef { struct MenuDef {
label: String, label: String,
@@ -55,21 +61,21 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::with_shortcut("New...", "Alt+Ctrl+N"), MenuItem::with_shortcut("New...", "Alt+Ctrl+N"),
MenuItem::with_shortcut("Open...", "Alt+Ctrl+O"), MenuItem::with_shortcut("Open...", "Alt+Ctrl+O"),
MenuItem::new("Open & Place..."), MenuItem::new("Open & Place..."),
MenuItem::new("Open More"), MenuItem::submenu("Open More"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Share"), MenuItem::new("Share"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::with_shortcut("Save", "Ctrl+S"), MenuItem::with_shortcut("Save", "Ctrl+S"),
MenuItem::new("Save as PSD"), MenuItem::new("Save as PSD"),
MenuItem::new("Save More"), MenuItem::submenu("Save More"),
MenuItem::new("Export as"), MenuItem::submenu("Export as"),
MenuItem::with_shortcut("Print...", "Ctrl+P"), MenuItem::with_shortcut("Print...", "Ctrl+P"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Export Layers..."), MenuItem::new("Export Layers..."),
MenuItem::new("Export Color Lookup..."), MenuItem::new("Export Color Lookup..."),
MenuItem::new("File Info..."), MenuItem::new("File Info..."),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Automate"), MenuItem::submenu("Automate"),
MenuItem::new("Script..."), MenuItem::new("Script..."),
]; ];
@@ -108,14 +114,14 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Puppet Warp"), MenuItem::new("Puppet Warp"),
MenuItem::new("Perspective Warp"), MenuItem::new("Perspective Warp"),
MenuItem::with_shortcut("Free Transform", "Alt+Ctrl+T"), MenuItem::with_shortcut("Free Transform", "Alt+Ctrl+T"),
MenuItem::new("Transform"), MenuItem::submenu("Transform"),
MenuItem::new("Auto-Align"), MenuItem::new("Auto-Align"),
MenuItem::new("Auto-Blend"), MenuItem::new("Auto-Blend"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Assign Profile"), MenuItem::submenu("Assign Profile"),
MenuItem::new("Convert to Profile"), MenuItem::submenu("Convert to Profile"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Define New"), MenuItem::submenu("Define New"),
MenuItem::new("Preset Manager..."), MenuItem::new("Preset Manager..."),
MenuItem::with_shortcut("Preferences...", "Ctrl+K"), MenuItem::with_shortcut("Preferences...", "Ctrl+K"),
MenuItem::new("Local Storage..."), MenuItem::new("Local Storage..."),
@@ -125,8 +131,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef { MenuDef {
label: "Image".to_string(), label: "Image".to_string(),
items: vec![ items: vec![
MenuItem::new("Mode"), MenuItem::submenu("Mode"),
MenuItem::new("Adjustments"), MenuItem::submenu("Adjustments"),
MenuItem::new("Auto Tone"), MenuItem::new("Auto Tone"),
MenuItem::new("Auto Contrast"), MenuItem::new("Auto Contrast"),
MenuItem::new("Auto Color"), MenuItem::new("Auto Color"),
@@ -137,9 +143,9 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(), MenuItem::separator(),
MenuItem::with_shortcut("Canvas Size...", "Alt+Ctrl+C"), MenuItem::with_shortcut("Canvas Size...", "Alt+Ctrl+C"),
MenuItem::with_shortcut("Image Size...", "Alt+Ctrl+I"), MenuItem::with_shortcut("Image Size...", "Alt+Ctrl+I"),
MenuItem::new("Transform"), MenuItem::submenu("Transform"),
MenuItem::new("Crop"), MenuItem::new("Crop"),
MenuItem::new("Trim..."), MenuItem::with_shortcut("Trim...", "Ctrl+."),
MenuItem::new("Reveal All"), MenuItem::new("Reveal All"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Duplicate"), MenuItem::new("Duplicate"),
@@ -152,29 +158,29 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef { MenuDef {
label: "Layer".to_string(), label: "Layer".to_string(),
items: vec![ items: vec![
MenuItem::new("New"), MenuItem::submenu("New"),
MenuItem::new("Duplicate Layer"), MenuItem::new("Duplicate Layer"),
MenuItem::new("Duplicate Into ..."), MenuItem::new("Duplicate Into ..."),
MenuItem::new("Delete"), MenuItem::new("Delete"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Text"), MenuItem::submenu("Text"),
MenuItem::new("Layer Style"), MenuItem::submenu("Layer Style"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("New Fill Layer"), MenuItem::submenu("New Fill Layer"),
MenuItem::new("New Adjustment Layer"), MenuItem::submenu("New Adjustment Layer"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Raster Mask"), MenuItem::submenu("Raster Mask"),
MenuItem::new("Vector Mask"), MenuItem::submenu("Vector Mask"),
MenuItem::new("Clipping Mask"), MenuItem::disabled("Clipping Mask"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Smart Object"), MenuItem::submenu("Smart Object"),
MenuItem::new("Rasterize"), MenuItem::new("Rasterize"),
MenuItem::new("Rasterize Layer Style"), MenuItem::new("Rasterize Layer Style"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::with_shortcut("Group Layers", "Ctrl+G"), MenuItem::with_shortcut("Group Layers", "Ctrl+G"),
MenuItem::new("Arrange"), MenuItem::submenu("Arrange"),
MenuItem::new("Combine Shapes"), MenuItem::submenu("Combine Shapes"),
MenuItem::new("Animation"), MenuItem::submenu("Animation"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::with_shortcut("Merge Down", "Ctrl+E"), MenuItem::with_shortcut("Merge Down", "Ctrl+E"),
MenuItem::new("Flatten Image"), MenuItem::new("Flatten Image"),
@@ -195,12 +201,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Subject"), MenuItem::new("Subject"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Refine Edge..."), MenuItem::new("Refine Edge..."),
MenuItem::new("Modify"), MenuItem::submenu("Modify"),
MenuItem::new("Grow"), 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::new("Similar"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Transform Selection"), MenuItem::new("Transform Selection"),
MenuItem::new("Quick Mask Mode"), MenuItem::with_shortcut("Quick Mask Mode", "Q"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Load Selection"), MenuItem::new("Load Selection"),
MenuItem::new("Save Selection"), MenuItem::new("Save Selection"),
@@ -218,34 +229,47 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Liquify..."), MenuItem::new("Liquify..."),
MenuItem::new("Vanishing Point..."), MenuItem::new("Vanishing Point..."),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("3D"), MenuItem::submenu("3D"),
MenuItem::new("Blur"), MenuItem::submenu("Blur"),
MenuItem::new("Blur Gallery"), MenuItem::submenu("Blur Gallery"),
MenuItem::new("Distort"), MenuItem::submenu("Distort"),
MenuItem::new("Noise"), MenuItem::submenu("Noise"),
MenuItem::new("Pixelate"), MenuItem::submenu("Pixelate"),
MenuItem::new("Render"), MenuItem::submenu("Render"),
MenuItem::new("Sharpen"), MenuItem::submenu("Sharpen"),
MenuItem::new("Stylize"), MenuItem::submenu("Stylize"),
MenuItem::new("Other"), MenuItem::submenu("Other"),
MenuItem::new("Fourier"), MenuItem::submenu("Fourier"),
], ],
}, },
// ── View menu ── // ── View menu (Photopea-style) ──
MenuDef { MenuDef {
label: "View".to_string(), label: "View".to_string(),
items: vec![ items: vec![
MenuItem::new("Zoom In"), MenuItem::with_shortcut("Zoom In", "Ctrl++"),
MenuItem::new("Zoom Out"), MenuItem::with_shortcut("Zoom Out", "Ctrl+-"),
MenuItem::new("Fit on Screen"), MenuItem::with_shortcut("Fit The Area", "Ctrl+0"),
MenuItem::new("Actual Pixels"), MenuItem::with_shortcut("Pixel to Pixel", "Ctrl+1"),
MenuItem::new("Pattern Preview"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Rulers"), MenuItem::submenu("Mode"),
MenuItem::new("Grid"), MenuItem::with_shortcut("Extras", "Ctrl+H"),
MenuItem::new("Guides"), 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 { MenuDef {
label: "Window".to_string(), label: "Window".to_string(),
items: vec![ items: vec![
@@ -261,10 +285,12 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Layer Details"), MenuItem::new("Layer Details"),
], ],
}, },
// ── More menu ── // ── More menu (Photopea-style) ──
MenuDef { MenuDef {
label: "More".to_string(), label: "More".to_string(),
items: vec![ items: vec![
MenuItem::submenu("More"),
MenuItem::separator(),
MenuItem::new("Plugins"), MenuItem::new("Plugins"),
MenuItem::new("Actions"), MenuItem::new("Actions"),
MenuItem::new("Adjustments"), MenuItem::new("Adjustments"),
@@ -381,16 +407,19 @@ pub fn dropdown_overlay(
} else { } else {
let lbl = item.label.clone(); let lbl = item.label.clone();
let sht = item.shortcut.clone(); let sht = item.shortcut.clone();
let label_row: Element<'static, Message> = if sht.is_empty() { let has_sub = item.has_submenu;
row![text(lbl).size(11)].into()
} else { // Photopea-style: label left, shortcut right in gray, submenu arrow ">" if needed
// Photopea-style: label left, shortcut right in gray let label_row: Element<'static, Message> = {
row![ let mut row_items = vec![];
text(lbl).size(11).width(Length::Fill), row_items.push(text(lbl).size(11).width(Length::Fill).into());
text(sht).size(10), 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());
.spacing(16) }
.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; let enabled = item.enabled;
@@ -428,7 +457,8 @@ pub fn dropdown_overlay(
.width(Length::Fill) .width(Length::Fill)
.padding([4, 16]) .padding([4, 16])
.style(move |_theme| iced::widget::container::Style { .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() ..Default::default()
}); });
items.push(disabled_item.into()); items.push(disabled_item.into());
+83
View File
@@ -282,4 +282,87 @@ pub fn lasso_fill_mask(mask: &mut [u8], w: u32, h: u32, points: &[(u32, u32)]) {
k += 2; 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);
} }