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) {
if let Some(ref mut mask) = self.document.selection_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),
"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
@@ -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<MenuDef> {
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<MenuDef> {
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> {
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<MenuDef> {
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> {
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<MenuDef> {
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<MenuDef> {
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<MenuDef> {
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());
+83
View File
@@ -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);
}