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:
parent
c812352940
commit
cc6c0e457d
|
|
@ -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(())
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
"view.clipping",
|
||||
"view.maskOverlay",
|
||||
"view.maskOverlayMode",
|
||||
"view.maskOverlayColor",
|
||||
"view.maskPins",
|
||||
"view.cropOverlay",
|
||||
"---",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue