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
+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());