From 852e8fca453b3b205eb48af2d5a4048973180d99 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:22:20 -0400 Subject: [PATCH] MENU-EDIT-SELECTBY + LR-PRE-MANAGE: select by flag/rating/label, manage presets and versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - library.selectBy {flag, rating, ratingOp, label, add}; Edit → Select by submenu. - preset.rename / preset.update (current settings, same groups) / preset.move; preset row menu: update, rename, move to group (existing or new), delete. - version.rename / version.update; version button menu: restore, update, rename, set as before, delete. history.clear (undoable); history row menu: create version from step, clear. - Keyword chip menu: remove, show photos with keyword, rename / delete keyword. - A generic one-field dialog (TextPrompt) runs a command with the typed value. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/develop.rs | 73 ++++++++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 47 ++++++++++++++++++ crates/engine/src/tests.rs | 44 +++++++++++++++++ crates/ui-egui/src/menubar.rs | 42 ++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 24 ++++++++- crates/ui-egui/src/panels/presets.rs | 44 +++++++++++++++++ crates/ui-egui/src/panels/right.rs | 46 +++++++++++++++++- crates/ui-egui/src/state.rs | 10 ++++ docs/parity.md | 22 ++++----- 9 files changed, 338 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index f106d7706..32c59c8d1 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -48,6 +48,28 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32) d.crop.geometry = crop_fit_angle(w, h, angle, a); } +/// The user preset named by `p.id` (built-ins can't be changed). +fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> { + let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?; + let pr = s.presets.iter_mut().find(|x| x.id == pid).ok_or_else(|| bad(c, format!("unknown preset `{pid}`")))?; + if pr.builtin { + return Err(bad(c, "built-in presets can't be changed")); + } + Ok(pr) +} + +/// Change version `p.index` of the active photo (`f` gets it and the current settings). +fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc)) -> Result { + let id = active(s, c)?; + let i = f64_req(p, "index", c)? as usize; + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let mut versions = ph.versions.clone(); + let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?; + f(v, ph.develop.clone()); + s.commit(label, Op::SetVersions { id, versions })?; + ok() +} + pub fn specs() -> Vec { vec![ cmd!( @@ -594,6 +616,42 @@ pub fn specs() -> Vec { pr.favorite = bool_or(p, "favorite", !pr.favorite); ok() }), + cmd!("preset.rename", "Rename Preset", [], None, "{id, name}", always, |s, p| { + let c = "preset.rename"; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing name"))?.to_string(); + user_preset(s, p, c)?.name = name; + ok() + }), + cmd!("preset.move", "Move Preset to Group", [], None, "{id, group}", always, |s, p| { + let c = "preset.move"; + let group = str_param(p, "group").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing group"))?.to_string(); + user_preset(s, p, c)?.group = group; + ok() + }), + cmd!( + "preset.update", + "Update Preset with Current Settings", + [], + None, + "{id, groups?: [settingsGroup] (default: the groups the preset already sets)} — from the active photo", + has_active, + |s, p| { + let c = "preset.update"; + let id = active(s, c)?; + let current = s.develop_of(id).unwrap_or_default().to_json(); + let groups = groups_param(p); + let pr = user_preset(s, p, c)?; + pr.settings = match groups { + Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g), + // the same keys as before, with the current values + None => { + let keys: Vec = pr.settings.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + Value::Object(keys.into_iter().filter_map(|k| current.get(&k).cloned().map(|v| (k, v))).collect()) + } + }; + ok() + } + ), // ---- versions & history cmd!("version.create", "Create Version", ["Photo"], Some("Cmd+Shift+S"), "{name?}", has_active, |s, p| { let id = active(s, "version.create")?; @@ -622,6 +680,21 @@ pub fn specs() -> Vec { s.commit("Delete Version", Op::SetVersions { id, versions })?; ok() }), + cmd!("version.rename", "Rename Version", [], None, "{index, name}", has_active, |s, p| { + let c = "version.rename"; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing name"))?.to_string(); + edit_version(s, p, c, "Rename Version", |v, _| v.name = name) + }), + cmd!("version.update", "Update Version with Current Settings", [], None, "{index}", has_active, |s, p| { + edit_version(s, p, "version.update", "Update Version", |v, current| v.settings = current) + }), + cmd!("history.clear", "Clear History", [], None, "{} — keeps the current settings as the only step", has_active, |s, _| { + let id = active(s, "history.clear")?; + let d = s.develop_of(id).unwrap_or_default(); + let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }]; + s.commit("Clear History", Op::SetHistory { id, history })?; + ok() + }), cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| { let id = active(s, "history.restore")?; let i = f64_req(p, "index", "history.restore")? as usize; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index dcbf8b982..b652acec3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -87,6 +87,44 @@ fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { Ok(json!({"changed": n})) } +/// `library.selectBy`: select the photos in view that match every given criterion. +fn select_by(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.selectBy"; + let flag: Option = match p.get("flag") { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(C, "flag is pick, reject or none"))?), + None => None, + }; + let rating = p.get("rating").and_then(Value::as_u64).map(|r| r.min(5) as u8); + let op = p.get("ratingOp").and_then(Value::as_str).unwrap_or("gte"); + let label: Option> = match p.get("label").and_then(Value::as_str) { + Some("none") => Some(None), + Some(l) => Some(Some(serde_json::from_value(json!(l)).map_err(|_| bad(C, format!("unknown label `{l}`")))?)), + None => None, + }; + if flag.is_none() && rating.is_none() && label.is_none() { + return Err(bad(C, "give flag, rating and/or label")); + } + let matches = |ph: &lightcraft_catalog::Photo| { + flag.is_none_or(|f| ph.flag == f) + && rating.is_none_or(|r| match op { + "eq" => ph.rating == r, + "lte" => ph.rating <= r, + _ => ph.rating >= r, + }) + && label.is_none_or(|l| ph.label == l) + }; + let mut ids: Vec = if bool_or(p, "add", false) { s.selection.ids.clone() } else { Vec::new() }; + for id in s.visible_cloned() { + if !ids.contains(&id) && s.catalog.photo(id).is_some_and(|ph| matches(ph)) { + ids.push(id); + } + } + let active = s.selection.active.filter(|a| ids.contains(a)).or(ids.first().copied()); + let n = ids.len(); + s.selection = Selection { ids, active }; + Ok(json!({"selected": n})) +} + pub fn specs() -> Vec { vec![ // ---- view source / filter / sort @@ -202,6 +240,15 @@ pub fn specs() -> Vec { s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis }; Ok(json!({"selected": s.selection.ids.len()})) }), + cmd!( + "library.selectBy", + "Select by Flag, Rating or Label", + [], + None, + "{flag?: pick|reject|none, rating?: 0..5, ratingOp?: gte|eq|lte (default gte), label?: red|…|none, add?: bool (extend the selection)} — among the photos in view", + always, + select_by + ), cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { let a = s.selection.active; s.selection = Selection { ids: a.into_iter().collect(), active: a }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 98ddfe802..fa5f23472 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -295,3 +295,47 @@ fn before_is_the_import_state_and_can_be_set_copied_and_swapped() { let k2 = s.render_job(id, 64, 64, true, true).unwrap().key; assert_ne!(k1, k2); } + +#[test] +fn presets_versions_history_and_select_by() { + let mut s = demo(); + s.execute("develop.set", &json!({"values": {"light.exposure": 0.5}})).unwrap(); + let pid = s.execute("preset.create", &json!({"name": "Warm", "groups": ["light"]})).unwrap()["id"].as_str().unwrap().to_string(); + let preset = |s: &Session| s.presets.iter().find(|p| p.id == pid).cloned().unwrap(); + s.execute("preset.rename", &json!({"id": pid, "name": "Warmer"})).unwrap(); + s.execute("preset.move", &json!({"id": pid, "group": "Mine"})).unwrap(); + assert_eq!((preset(&s).name.as_str(), preset(&s).group.as_str()), ("Warmer", "Mine")); + // update keeps the preset's groups and takes the current values + s.execute("develop.set", &json!({"values": {"light.exposure": 1.25, "color.vibrance": 40}})).unwrap(); + s.execute("preset.update", &json!({"id": pid})).unwrap(); + assert_eq!(preset(&s).settings["light"]["exposure"], 1.25); + assert!(preset(&s).settings.get("color").is_none(), "only the groups it already had"); + let builtin = s.presets.iter().find(|p| p.builtin).unwrap().id.clone(); + assert!(s.execute("preset.rename", &json!({"id": builtin, "name": "x"})).is_err()); + // versions + s.execute("version.create", &json!({"name": "A"})).unwrap(); + s.execute("version.rename", &json!({"index": 0, "name": "First"})).unwrap(); + s.execute("develop.set", &json!({"values": {"light.exposure": -2.0}})).unwrap(); + s.execute("version.update", &json!({"index": 0})).unwrap(); + let v = s.catalog.photo(s.active().unwrap()).unwrap().versions[0].clone(); + assert_eq!((v.name.as_str(), v.settings.light.exposure), ("First", -2.0)); + // history + s.execute("history.clear", &json!({})).unwrap(); + let h = &s.catalog.photo(s.active().unwrap()).unwrap().history; + assert_eq!(h.len(), 1); + assert_eq!(h[0].settings.light.exposure, -2.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.photo(s.active().unwrap()).unwrap().history.len() > 1, "undoable"); + // select by: the demo has rated and picked photos + let vis = s.visible_cloned(); + let picks = vis.iter().filter(|id| s.catalog.photo(**id).unwrap().flag == lightcraft_catalog::Flag::Pick).count(); + let r = s.execute("library.selectBy", &json!({"flag": "pick"})).unwrap(); + assert_eq!(r["selected"].as_u64(), Some(picks as u64)); + assert!(picks > 0); + let four = vis.iter().filter(|id| s.catalog.photo(**id).unwrap().rating >= 4).count(); + assert_eq!(s.execute("library.selectBy", &json!({"rating": 4})).unwrap()["selected"].as_u64(), Some(four as u64)); + let r = s.execute("library.selectBy", &json!({"rating": 0, "ratingOp": "eq", "add": true})).unwrap(); + assert!(r["selected"].as_u64().unwrap() >= four as u64); + assert!(s.execute("library.selectBy", &json!({})).is_err()); + assert!(s.execute("library.selectBy", &json!({"label": "mauve"})).is_err()); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index a48d04b00..e27e24612 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -86,6 +86,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "library.selectAll", "library.selectNone", + "@Select by", "---", "view.focusSearch", "---", @@ -343,6 +344,23 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } + "Select by" => { + let mut v: Vec = [("pick", "Picks"), ("reject", "Rejects"), ("none", "Unflagged")] + .into_iter() + .map(|(f, label)| item("library.selectBy", json!({"flag": f}), label, None, true, None)) + .collect(); + v.push(MenuNode::Separator); + v.extend( + (1..=5u8).map(|r| item("library.selectBy", json!({"rating": r}), format!("{} and higher", "★".repeat(r as usize)), None, true, None)), + ); + v.push(item("library.selectBy", json!({"rating": 0, "ratingOp": "eq"}), "Unrated", None, true, None)); + v.push(MenuNode::Separator); + v.extend(lightcraft_catalog::ColorLabel::ALL.iter().map(|l| { + let name = format!("{l:?}"); + item("library.selectBy", json!({"label": name.to_lowercase()}), format!("{name} Label"), None, true, None) + })); + v + } "Export with Preset" => { let sel = !app.session.selection.ids.is_empty() || has; let mut v: Vec = Vec::new(); @@ -679,6 +697,30 @@ mod tests { assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); } + #[test] + fn select_by_submenu_and_text_prompt() { + let mut app = app(); + let bar = menu_bar(&app); + let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) + else { + panic!("no Select by submenu") + }; + let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; + let r = run_item(&mut app, id, params.clone()).unwrap(); + assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); + // the one-field prompt runs its command with the typed value + app.session.execute("version.create", &json!({"name": "A"})).unwrap(); + crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = " Final ".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let id = app.session.active().unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); + } + #[test] fn shortcut_text_per_platform() { assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 62bbdb852..770b69944 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -18,7 +18,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); let mut close = false; let mut confirm = false; - let title = match &dlg { + let title: String = match &dlg { + Dialog::TextPrompt { title, .. } => title.as_str(), Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", @@ -39,7 +40,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", - }; + } + .to_string(); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); let shown = egui::Window::new(title) .collapsible(false) @@ -220,6 +222,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); } + Dialog::TextPrompt { value, hint, .. } => { + let r = ui.add(egui::TextEdit::singleline(value).hint_text(hint.as_str()).desired_width(f32::INFINITY)); + r.request_focus(); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + confirm = true; + } + } Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -569,6 +578,11 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::TextPrompt { value, command, params, key, .. } => { + let mut p = params.clone(); + p[key.as_str()] = json!(value.trim()); + app.run(command, p) + } Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run( "photo.setCaptureTime", match mode.as_str() { @@ -612,6 +626,12 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.toast(ui.ctx(), format!("Updated “{name}”")), + Err(e) => app.toast(ui.ctx(), e), + } + } + if ui.button("Rename…").clicked() { + crate::panels::dialogs::prompt( + app, + "Rename Preset", + "Preset name", + &name, + "preset.rename", + json!({"id": pid}), + "name", + ); + } + ui.menu_button("Move to Group", |ui| { + let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); + groups.sort(); + groups.dedup(); + for g in groups.iter().filter(|g| **g != group) { + if ui.button(g).clicked() { + let _ = app.run("preset.move", json!({"id": pid, "group": g})); + } + } + if ui.button("New Group…").clicked() { + crate::panels::dialogs::prompt( + app, + "Move Preset to New Group", + "Group name", + "", + "preset.move", + json!({"id": pid}), + "group", + ); + } + }); + ui.separator(); if ui.button("Delete Preset").clicked() { let _ = app.run("preset.delete", json!({"id": pid})); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ee5d2e646..39e811f21 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -446,9 +446,25 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { for k in &p.meta.keywords { let r = ui.add(egui::Button::new(egui::RichText::new(format!("{} ×", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0)); + register(ui.ctx(), format!("keywordChip:{k}"), r.rect); if r.clicked() { let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); } + r.context_menu(|ui| { + if ui.button("Remove from Photo").clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); + } + if ui.button("Show Photos with Keyword").clicked() { + let _ = app.run("library.filter", json!({"keyword": k})); + } + ui.separator(); + if ui.button("Rename Keyword…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() }); + } + if ui.button("Delete Keyword").clicked() { + let _ = app.run("keyword.delete", json!({"keyword": k})); + } + }); } }); ui.add_space(10.0); @@ -486,9 +502,29 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); for (i, v) in p.versions.iter().enumerate() { ui.horizontal(|ui| { - if ui.button(&v.name).on_hover_text(&v.created).clicked() { + let b = ui.button(&v.name).on_hover_text(&v.created); + register(ui.ctx(), format!("version:{i}"), b.rect); + if b.clicked() { let _ = app.run("version.restore", json!({"index": i})); } + b.context_menu(|ui| { + if ui.button("Restore").clicked() { + let _ = app.run("version.restore", json!({"index": i})); + } + if ui.button("Update with Current Settings").clicked() { + let _ = app.run("version.update", json!({"index": i})); + } + if ui.button("Rename…").clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } + if ui.button("Set as Before").clicked() { + let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name})); + } + ui.separator(); + if ui.button("Delete").clicked() { + let _ = app.run("version.delete", json!({"index": i})); + } + }); if ui.small_button("×").clicked() { let _ = app.run("version.delete", json!({"index": i})); } @@ -523,6 +559,14 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button("Copy History Step Settings to Before").clicked() { let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i})); } + if ui.button("Create Version from Step").clicked() { + let _ = app.run("history.restore", json!({"index": i})); + let _ = app.run("version.create", json!({"name": h.label})); + } + ui.separator(); + if ui.button("Clear History").clicked() { + let _ = app.run("history.clear", json!({})); + } }); } }); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 91e9c98c3..3d1b6bf8b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -287,6 +287,16 @@ pub enum Dialog { id: u64, name: String, }, + /// One text field; OK runs `command` with `params` plus `{key: value}` (rename a preset or a + /// version, move a preset to a new group…). + TextPrompt { + title: String, + hint: String, + value: String, + command: String, + params: serde_json::Value, + key: String, + }, /// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`, /// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours). CaptureTime { diff --git a/docs/parity.md b/docs/parity.md index e28f38739..4d23a6f6b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -32,7 +32,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 1 | 0 | 1 | 0 | — | 1/1 (100%) | | 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%) | +| L. Presets (PRE) | 4 | 1 | 2 | 1 | 2/2 (100%) | 2/2 (100%) | | 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%) | | O. Merge (MERGE) | 3 | 0 | 1 | 0 | — | — | @@ -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 | 61 | 11 | 13 | 8 | 46/47 (98%) | 13/23 (57%) | +| Y. Menus | 65 | 9 | 11 | 8 | 46/47 (98%) | 17/23 (74%) | | Z. Keyboard shortcuts (desktop) | 59 | 12 | 9 | 1 | 49/52 (94%) | 10/23 (43%) | -| Lightroom Classic extras | 6 | 20 | 53 | 9 | — | 5/21 (24%) | -| **Total** | 283 | 71 | 146 | 37 | 192/198 (97%) | 81/144 (56%) | +| Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) | +| **Total** | 289 | 67 | 144 | 37 | 192/198 (97%) | 87/144 (60%) | ## Top gaps @@ -336,7 +336,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | -| LR-PRE-MANAGE | Manage presets | P1 | 🟡 | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export` | no rename, update-with-current, move group, hide groups | +| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | | LR-PRE-PREMIUM | Built-in presets (own) | P2 | 🟡 | `crates/engine/src/presets.rs` | 18 own-authored presets | @@ -348,7 +348,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-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | -| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete` | no rename, update, hover preview, named/auto tabs | +| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update` | rename, update, set as before; no hover preview, named/auto tabs | | LR-VER-AUTO | Automatic versions | P2 | ⬜ | | `auto` flag reserved in the model | | LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | | LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | @@ -524,7 +524,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted | | MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | | MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | -| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ⬜ | | | +| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection | | MENU-EDIT-FIND | Find… | P0 | ✅ | `cmd:view.focusSearch`, `crates/ui-egui/src/panels/topbar.rs` | ⌘F focuses the search field | | MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | | | MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | @@ -583,10 +583,10 @@ visible menu bar yet: the menu model is only exposed through the control channel | 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` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | | 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-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ⬜ | | | -| MENU-CTX-VERSION | Version menu | P1 | ⬜ | | delete button only | -| MENU-CTX-KEYWORD | Keyword chip menu | P1 | 🟡 | `crates/ui-egui/src/panels/right.rs` | click removes; no rename/delete keyword | +| MENU-CTX-VERSION | Version menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (`versions`) | restore, update with current settings, rename, set as before, delete | +| MENU-CTX-KEYWORD | Keyword chip menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs`, `cmd:keyword.delete` | remove from photo, show photos with keyword, rename keyword, delete keyword | ## Z. Keyboard shortcuts (desktop) @@ -710,7 +710,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PREVIEWS | Build / discard previews | P1 | 🟡 | `cmd:library.clearPreviews`, `crates/preview/src/lib.rs` | disk thumbnail cache; no build-1:1 / smart previews | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | -| LRC-DEV-HISTORY | Full history panel | P1 | 🟡 | `cmd:history.list`, `cmd:history.restore` | no clear, no snapshot-from-step | +| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | | LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | 🟡 | `cmd:develop.sync` | no auto sync or paste-from-previous | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ⬜ | | |