LR-MASK-PANEL: reorder masks, duplicate and invert, list context menu, overlay colour cycle

- mask.move {to | delta} reorders the list (no-op moves record nothing); mask.duplicate
  {invert} = Duplicate and Invert, the copy placed right after the original.
- Masks list: show/hide eye per row (on hover, always while hidden; new eye-off icon drawn
  in code), double-click to rename in place, right-click menu (duplicate, duplicate and
  invert, invert, show/hide, move up/down, rename, delete).
- View → Cycle Mask Overlay Color (view.maskOverlayColor without params).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 22:49:55 -04:00
parent c812352940
commit cc6c0e457d
9 changed files with 200 additions and 19 deletions

View File

@ -232,16 +232,49 @@ pub fn specs() -> Vec<CommandSpec> {
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(())
})
}),

View File

@ -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::<Vec<_>>();
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));
}

View File

@ -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);

View File

@ -121,6 +121,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"view.clipping",
"view.maskOverlay",
"view.maskOverlayMode",
"view.maskOverlayColor",
"view.maskPins",
"view.cropOverlay",
"---",

View File

@ -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<Re
Ok(json!({"mode": next.name()}))
}
"view.maskOverlayColor" => {
// 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,

View File

@ -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) {

View File

@ -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(),

View File

@ -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()]);
}

View File

@ -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%) |
<!-- /parity:summary -->
## 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 |