MENU-EDIT-SELECTBY + LR-PRE-MANAGE: select by flag/rating/label, manage presets and versions

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:22:20 -04:00
co-authored by Claude Opus 5.5
parent 1a48b5bcc7
commit 852e8fca45
9 changed files with 338 additions and 14 deletions
+73
View File
@@ -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<DevelopSettings>)) -> Result<Value> {
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<CommandSpec> {
vec![
cmd!(
@@ -594,6 +616,42 @@ pub fn specs() -> Vec<CommandSpec> {
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<String> = 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<CommandSpec> {
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;
+47
View File
@@ -87,6 +87,44 @@ fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
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<Value> {
const C: &str = "library.selectBy";
let flag: Option<lightcraft_catalog::Flag> = 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<Option<lightcraft_catalog::ColorLabel>> = 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<PhotoId> = 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<CommandSpec> {
vec![
// ---- view source / filter / sort
@@ -202,6 +240,15 @@ pub fn specs() -> Vec<CommandSpec> {
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 };
+44
View File
@@ -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());
}
+42
View File
@@ -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<Vec<MenuNode>> {
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<MenuNode> = [("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<MenuNode> = 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");
+22 -2
View File
@@ -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<serde_jso
match dlg {
Dialog::NewAlbum { name, folder } => 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<serde_jso
}
}
/// Open a one-field dialog that runs `command` with `params` + `{key: typed value}`.
pub fn prompt(app: &mut LightcraftApp, title: &str, hint: &str, value: &str, command: &str, params: serde_json::Value, key: &str) {
app.ui.dialog =
Some(Dialog::TextPrompt { title: title.into(), hint: hint.into(), value: value.into(), command: command.into(), params, key: key.into() });
}
/// The Export dialog's choices as `app.export` params (without the folder).
fn export_dialog_params(
opts: &lightcraft_engine::export::ExportOptions,
+44
View File
@@ -148,10 +148,54 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
app.toast(ui.ctx(), format!("Preset: {name}"));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
return;
}
ui.separator();
if ui.add_enabled(app.session.active().is_some(), egui::Button::new("Update with Current Settings")).clicked() {
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => 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<String> = 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}));
}
+45 -1
View File
@@ -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!({}));
}
});
}
});
+10
View File
@@ -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 {
+11 -11
View File
@@ -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%) |
<!-- /parity:summary -->
## 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 | ⬜ | | |