diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index a09cecbb..a9daf676 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -232,16 +232,49 @@ pub fn specs() -> Vec { Ok(()) }) }), - cmd!("mask.duplicate", "Duplicate Mask", [], None, "{id?}", has_active, |s, p| { - let mid = mask_id(p, s.active_mask, "mask.duplicate")?; - let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); - masks_edit(s, "mask.duplicate", "Duplicate Mask", |masks, active| { - let i = find(masks, mid, "mask.duplicate")?; - let mut m = masks[i].clone(); - m.id = next; - m.name = format!("{} copy", m.name); - masks.push(m); - *active = Some(next); + cmd!( + "mask.duplicate", + "Duplicate Mask", + [], + None, + "{id?, invert?: bool (Duplicate and Invert)} — the copy goes right after the original and is selected", + has_active, + |s, p| { + let mid = mask_id(p, s.active_mask, "mask.duplicate")?; + let invert = bool_or(p, "invert", false); + let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); + let label = if invert { "Duplicate and Invert Mask" } else { "Duplicate Mask" }; + masks_edit(s, "mask.duplicate", label, |masks, active| { + let i = find(masks, mid, "mask.duplicate")?; + let mut m = masks[i].clone(); + m.id = next; + m.name = format!("{} copy", m.name); + if invert { + m.invert = !m.invert; + } + masks.insert(i + 1, m); + *active = Some(next); + Ok(()) + }) + } + ), + cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| { + let mid = mask_id(p, s.active_mask, "mask.move")?; + let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default(); + let i = find(&mut masks, mid, "mask.move")?; + let to = match (p.get("to").and_then(Value::as_i64), p.get("delta").and_then(Value::as_i64)) { + (Some(t), _) => t, + (None, Some(d)) => i as i64 + d, + (None, None) => return Err(bad("mask.move", "give `to` (index) or `delta`")), + } + .clamp(0, masks.len() as i64 - 1) as usize; + if to == i { + return Ok(json!({"activeMask": s.active_mask})); + } + masks_edit(s, "mask.move", "Reorder Masks", |masks, _| { + let i = find(masks, mid, "mask.move")?; + let m = masks.remove(i); + masks.insert(to, m); Ok(()) }) }), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fa68ba61..ede8defc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -228,3 +228,32 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() { s.execute("develop.paste", &json!({"ids": [ids[1].0]})).unwrap(); assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0); } + +#[test] +fn masks_reorder_and_duplicate_and_invert() { + let mut s = demo(); + for kind in ["linear", "radial", "brush"] { + s.execute("mask.add", &json!({"kind": kind})).unwrap(); + } + let ids = |s: &Session| active_dev(s).masks.iter().map(|m| m.id).collect::>(); + let [a, b, c] = ids(&s)[..] else { panic!("{:?}", ids(&s)) }; + s.execute("mask.move", &json!({"id": c, "to": 0})).unwrap(); + assert_eq!(ids(&s), [c, a, b]); + s.execute("mask.move", &json!({"id": c, "delta": 1})).unwrap(); + assert_eq!(ids(&s), [a, c, b]); + s.execute("mask.move", &json!({"id": a, "delta": -5})).unwrap(); + assert_eq!(ids(&s), [a, c, b], "clamped at the top"); + assert!(s.execute("mask.move", &json!({"id": a})).is_err()); + // one undo step per move + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ids(&s), [c, a, b]); + // duplicate and invert: right after the original, inverted, selected + let r = s.execute("mask.duplicate", &json!({"id": a, "invert": true})).unwrap(); + let copy = r["activeMask"].as_u64().unwrap() as u32; + let d = active_dev(&s); + let pos = d.masks.iter().position(|m| m.id == copy).unwrap(); + assert_eq!(d.masks[pos - 1].id, a); + assert!(d.masks[pos].invert && !d.masks[pos - 1].invert); + assert_eq!(d.masks[pos].components, d.masks[pos - 1].components); + assert_eq!(s.active_mask, Some(copy)); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2b5c09d5..258dbaf0 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -19,6 +19,8 @@ pub enum Icon { Eraser, Mask, Eye, + /// Eye with a slash (hidden). + EyeOff, Presets, Versions, Activity, @@ -209,6 +211,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 4.0, 9.0, 32.0, 148.0); pen.circle(10.0, 10.0, 2.5); } + EyeOff => { + pen.arc(10.0, 16.0, 9.0, 212.0, 328.0); + pen.arc(10.0, 4.0, 9.0, 32.0, 148.0); + pen.circle(10.0, 10.0, 2.5); + pen.line(&[(3.5, 3.5), (16.5, 16.5)]); + } Presets => { pen.circle(8.0, 8.0, 5.0); pen.circle(12.0, 12.0, 5.0); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index d54e99a8..89dbf49a 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -121,6 +121,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.clipping", "view.maskOverlay", "view.maskOverlayMode", + "view.maskOverlayColor", "view.maskPins", "view.cropOverlay", "---", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bd352bd4..670e409a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -36,7 +36,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), - ("view.maskOverlayColor", "Mask Overlay Color", None, ""), + ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), ("view.maskPins", "Show Mask Pins", None, "View"), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), @@ -331,6 +331,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // no params: the next of the panel's swatch colours + if p.get("color").is_none() && p.get("opacity").is_none() { + let all = crate::panels::masking::OVERLAY_COLORS; + let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); + app.ui.mask_overlay_color = all[i]; + } if let Some(c) = p.get("color") { match parse_rgb(c) { Some(rgb) => app.ui.mask_overlay_color = rgb, diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 2aad39ff..5f6a3d85 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -116,8 +116,28 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if d.masks.is_empty() { ui.label(egui::RichText::new("No masks yet. Choose a mask type above.").color(t.text_dim)); } - for m in &d.masks { + let count = d.masks.len(); + for (index, m) in d.masks.iter().enumerate() { let sel = app.session.active_mask == Some(m.id); + if let Some((rid, name)) = app.ui.renaming_mask.as_mut().filter(|(rid, _)| *rid == m.id) { + // inline rename: Enter (or leaving the field) commits, Escape cancels + let rid = *rid; + let r = ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width()).id_salt(("maskRename", rid))); + register(ui.ctx(), format!("maskRename:{rid}"), r.rect); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.renaming_mask = None; + } else if r.lost_focus() { + let name = name.trim().to_string(); + app.ui.renaming_mask = None; + if !name.is_empty() && name != m.name { + let _ = app.run("mask.rename", json!({"id": rid, "name": name})); + } + } + continue; + } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); register(ui.ctx(), format!("mask:{}", m.id), r); ui.painter().rect_filled( @@ -140,9 +160,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { t.font(13.0), if m.visible { t.text } else { t.text_disabled }, ); + // show / hide on hover (and always while hidden) + // (the pointer test, not `hovered`: over the eye, the row itself no longer counts as hovered) + if ui.rect_contains_pointer(r) || !m.visible { + let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0)); + register(ui.ctx(), format!("maskVisible:{}", m.id), er); + let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click()); + paint( + ui.painter(), + er.shrink(3.0), + if m.visible { Icon::Eye } else { Icon::EyeOff }, + if eye.hovered() { t.text } else { t.text_dim }, + ); + if eye.clicked() { + let _ = app.run("mask.visible", json!({"id": m.id})); + } + } if resp.clicked() { let _ = app.run("mask.select", json!({"id": m.id})); } + if resp.double_clicked() { + app.ui.renaming_mask = Some((m.id, m.name.clone())); + } + resp.context_menu(|ui| mask_menu(app, ui, m.id, &m.name, m.visible, index, count)); } if !d.masks.is_empty() { ui.add_space(6.0); @@ -208,8 +248,34 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = Stroke::NONE; } +/// The right-click menu of a mask in the Masks list. +fn mask_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: u32, name: &str, visible: bool, index: usize, count: usize) { + let mut run = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, p: serde_json::Value| { + if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + let _ = app.run(cmd, p); + ui.close(); + } + }; + run(ui, "Duplicate Mask", true, "mask.duplicate", json!({"id": id})); + run(ui, "Duplicate and Invert Mask", true, "mask.duplicate", json!({"id": id, "invert": true})); + run(ui, "Invert Mask", true, "mask.invert", json!({"id": id})); + run(ui, if visible { "Hide Mask" } else { "Show Mask" }, true, "mask.visible", json!({"id": id})); + ui.separator(); + run(ui, "Move Up", index > 0, "mask.move", json!({"id": id, "delta": -1})); + run(ui, "Move Down", index + 1 < count, "mask.move", json!({"id": id, "delta": 1})); + ui.separator(); + if ui.button("Rename…").clicked() { + app.ui.renaming_mask = Some((id, name.to_string())); + ui.close(); + } + if ui.button("Delete Mask").clicked() { + let _ = app.run("mask.delete", json!({"id": id})); + ui.close(); + } +} + /// Overlay colours offered as swatches (the colour of the selected one is used for the tint). -const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]]; +pub const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]]; /// How the selected mask is shown: overlay mode, colour, opacity, pins. fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8d994ce7..8d76acc6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -202,6 +202,9 @@ pub struct UiState { /// Focus the search field on the next frame (Edit → Find…). #[serde(skip)] pub focus_search: bool, + /// A mask being renamed in the Masks list: its id and the edited name. + #[serde(skip)] + pub renaming_mask: Option<(u32, String)>, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -390,6 +393,7 @@ impl Default for UiState { show_filenames: true, search: String::new(), focus_search: false, + renaming_mask: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 75e946ff..e3cc3938 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -168,3 +168,37 @@ fn remove_spots_by_pointer_and_keyboard() { assert_eq!(spots(&h).len(), 1, "nothing selected: nothing deleted"); h.settle(SETTLE); } + +/// Masks list: double-click renames in place, the hover eye hides a mask, the overlay colour +/// cycles through the swatches. +#[test] +fn mask_list_rename_hide_and_overlay_colour() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "linear"})); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + h.settle(SETTLE); + let first = develop(&h).masks[0].id; + let r = h.request("ui.clickWidget", json!({"id": format!("mask:{first}"), "count": 2}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(Duration::from_secs(5)); + assert_eq!(h.app.ui.renaming_mask.as_ref().map(|r| r.0), Some(first), "double-click starts renaming"); + h.request("ui.key", json!({"key": "A", "cmd": true}), T); + h.request("ui.text", json!({"text": "Sky"}), T); + h.request("ui.key", json!({"key": "Enter"}), T); + h.settle(Duration::from_secs(5)); + assert!(h.app.ui.renaming_mask.is_none()); + assert_eq!(develop(&h).masks[0].name, "Sky"); + // hover the row, then click its eye + let r = h.request("ui.hoverWidget", json!({"id": format!("mask:{first}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(Duration::from_secs(5)); + let r = h.request("ui.clickWidget", json!({"id": format!("maskVisible:{first}")}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!develop(&h).masks[0].visible); + // overlay colour: no params = next swatch + let before = h.app.ui.mask_overlay_color; + exec(&mut h, "view.maskOverlayColor", json!({})); + let all = crate::panels::masking::OVERLAY_COLORS; + let i = all.iter().position(|c| *c == before).unwrap(); + assert_eq!(h.app.ui.mask_overlay_color, all[(i + 1) % all.len()]); +} diff --git a/docs/parity.md b/docs/parity.md index 557525e1..be22b1ef 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 5 | 1 | 4 | 2 | 4/4 (100%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | -| K. Masking (MASK) | 10 | 6 | 7 | 0 | 7/8 (88%) | 3/5 (60%) | +| K. Masking (MASK) | 11 | 5 | 7 | 0 | 8/8 (100%) | 3/5 (60%) | | L. Presets (PRE) | 3 | 2 | 2 | 1 | 2/2 (100%) | 1/2 (50%) | | M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | | N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -45,10 +45,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) | -| Y. Menus | 56 | 16 | 13 | 8 | 41/47 (87%) | 13/23 (57%) | +| Y. Menus | 57 | 15 | 13 | 8 | 42/47 (89%) | 13/23 (57%) | | Z. Keyboard shortcuts (desktop) | 56 | 13 | 11 | 1 | 47/52 (90%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) | -| **Total** | 265 | 79 | 156 | 37 | 183/198 (92%) | 72/144 (50%) | +| **Total** | 267 | 77 | 156 | 37 | 185/198 (93%) | 72/144 (50%) | ## Top gaps @@ -306,7 +306,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MASK-PANEL | Masks panel | P0 | 🟡 | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate`, `cmd:mask.visible`, `cmd:mask.delete` | no reorder, no one-step duplicate-and-invert | +| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); no drag-to-reorder | | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic | @@ -544,7 +544,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | | | MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | | MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | | -| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | 🟡 | `cmd:view.maskOverlay` | no colour cycle | +| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor` | colour cycles through the panel's swatches (no params) or takes `color` / `opacity` | | MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive | | MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key | | MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | @@ -582,7 +582,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-CTX-GRID | Photo context menu | P0 | 🟡 | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, delete; no set-as-album-cover | | MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | 🟡 | `crates/ui-egui/src/panels/left.rs` | add selected, rename, delete; no move-to, export album | -| MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` | add/subtract component; rename/duplicate by command only | +| MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`) | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; components: add/subtract only (no per-component menu) | | MENU-CTX-PRESET | Preset menu | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs` | favourite, delete, export group; no rename/update/move | | MENU-CTX-PROFILE | Profile favourites | P2 | ⬜ | | | | MENU-CTX-VERSION | Version menu | P1 | ⬜ | | delete button only |