diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index e330e65b..876cf485 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -1,4 +1,5 @@ -//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge. +//! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge; +//! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords. use lightcraft_catalog::keywords::{clean, is_under}; use serde_json::{Value, json}; @@ -30,8 +31,139 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol Ok(json!({"changed": n})) } +/// A named set of up to nine keywords. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct KeywordSet { + pub name: String, + pub keywords: Vec, +} + +/// The set name meaning "the nine most recently added keywords". +pub const RECENT: &str = "Recent Keywords"; + +/// Remember `added` as the most recent keywords (newest first, nine kept). +pub fn note_recent(s: &mut Session, added: &[String]) { + for k in added.iter().rev() { + let k = clean(k); + if k.is_empty() { + continue; + } + s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k)); + s.recent_keywords.insert(0, k); + } + s.recent_keywords.truncate(9); + let _ = s.save_prefs(); +} + +/// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones. +pub fn current_keywords(s: &Session) -> Vec { + let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n))); + let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone()); + v.truncate(9); + v +} + +/// `{sets: [{name, keywords}], current, keywords}` (Recent Keywords first). +pub fn keyword_sets_json(s: &Session) -> Value { + let mut sets = vec![json!({"name": RECENT, "keywords": s.recent_keywords})]; + sets.extend(s.keyword_sets.iter().map(|x| json!({"name": x.name, "keywords": x.keywords}))); + json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)}) +} + pub fn specs() -> Vec { vec![ + cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))), + cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| { + let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT); + s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() { + None + } else { + Some( + s.keyword_sets + .iter() + .find(|x| x.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))? + .name + .clone(), + ) + }; + s.save_prefs()?; + Ok(keyword_sets_json(s)) + }), + cmd!( + "keyword.saveSet", + "Save Keyword Set", + [], + None, + "{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current", + always, + |s, p| { + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT)) + .ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))? + .to_string(); + let mut keywords: Vec = if p.get("keywords").is_some() { + strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect() + } else { + current_keywords(s) + }; + keywords.truncate(9); + let set = KeywordSet { name: name.clone(), keywords }; + match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = set, + None => s.keyword_sets.push(set), + } + s.keyword_set = Some(name); + s.save_prefs()?; + Ok(keyword_sets_json(s)) + } + ), + cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| { + let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?; + let before = s.keyword_sets.len(); + s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name)); + if s.keyword_sets.len() == before { + return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`"))); + } + if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) { + s.keyword_set = None; + } + s.save_prefs()?; + Ok(keyword_sets_json(s)) + }), + cmd!( + "keyword.toggleFromSet", + "Toggle Keyword from Set", + [], + None, + "{index: 1..9, ids?} — the set's keyword N: added to the target photos, or removed when they all have it", + super::has_selection, + |s, p| { + let i = p + .get("index") + .and_then(Value::as_u64) + .filter(|i| (1..=9).contains(i)) + .ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?; + let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else { + return Ok(json!({"changed": 0})); + }; + let ids = s.targets(p); + let all = !ids.is_empty() + && ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)))); + let ids: Vec = ids.iter().map(|i| i.0).collect(); + let key = if all { "removeKeywords" } else { "addKeywords" }; + // applying from Recent Keywords mustn't reshuffle the numbers under the keys + let recent = s.recent_keywords.clone(); + let mut r = s.execute("photo.setMeta", &json!({"ids": ids, key: [k.clone()]}))?; + if s.keyword_set.is_none() { + s.recent_keywords = recent; + } + r["keyword"] = json!(k); + r["added"] = json!(!all); + Ok(r) + } + ), cmd!(query "keyword.list", "Keywords", [], None, "{} → [{name, path, count, children}] keyword tree (`a|b|c` keywords are hierarchical)", always, |s, _| { Ok(serde_json::to_value(s.catalog.keyword_tree()).unwrap_or_default()) }), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 6d1a2dc3..8de6cce3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -411,6 +411,11 @@ pub fn specs() -> Vec { } let n = ops.len(); s.commit("Edit Info", Op::Batch { ops })?; + // keywords just added become the Recent Keywords set + let added: Vec = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect(); + if n > 0 && !added.is_empty() { + crate::cmd::keywords::note_recent(s, &added); + } Ok(json!({"changed": n})) } ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index db223de7..0f68c7ab 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,7 +11,7 @@ mod develop; mod edit; mod export; pub mod filters; -mod keywords; +pub mod keywords; mod library; pub mod manage; mod masks; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f58223da..4dc193e5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -125,6 +125,11 @@ pub struct Session { pub filter_presets: Vec, /// Saved colour-label name sets. pub label_sets: Vec, + /// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent + /// Keywords) and the recently added keywords, newest first. + pub keyword_sets: Vec, + pub keyword_set: Option, + pub recent_keywords: Vec, /// Before/After: the "before" settings chosen per photo (this session; default: the photo's /// import state). See `cmd/before.rs`. pub before: std::collections::HashMap>, @@ -178,6 +183,9 @@ impl Session { metadata_presets: Vec::new(), filter_presets: Vec::new(), label_sets: Vec::new(), + keyword_sets: Vec::new(), + keyword_set: None, + recent_keywords: Vec::new(), before: Default::default(), import_probes: Default::default(), import_defaults: import::ImportDefaults::default(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 22441462..6f31ca44 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -114,6 +114,10 @@ struct PrefsFile { filter_presets: Vec, /// Colour-label name sets. label_sets: Vec, + /// Keyword sets, the one in use, recent keywords. + keyword_sets: Vec, + keyword_set: Option, + recent_keywords: Vec, /// Develop defaults for imported photos. import: crate::import::ImportDefaults, /// Thumbnail disk cache budget (MB, 0 = default). @@ -183,6 +187,9 @@ impl Session { self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.label_sets = prefs.label_sets; + self.keyword_sets = prefs.keyword_sets; + self.keyword_set = prefs.keyword_set; + self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; // view state @@ -298,6 +305,9 @@ impl Session { metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), label_sets: self.label_sets.clone(), + keyword_sets: self.keyword_sets.clone(), + keyword_set: self.keyword_set.clone(), + recent_keywords: self.recent_keywords.clone(), import: self.import_defaults.clone(), cache_mb: self.cache_mb, }) diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index fc531f9d..5ac8e19d 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -363,3 +363,41 @@ fn label_sets_and_xmp_label_names() { assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay"); let _ = std::fs::remove_dir_all(&dir); } + +/// Keyword sets: Recent Keywords fill as keywords are added; ⌥N toggles keyword N on the +/// selection (added unless every photo has it) without reshuffling the numbers; user sets. +#[test] +fn keyword_sets_and_recent_keywords() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[0]]})).unwrap(); + s.execute("photo.setMeta", &json!({"addKeywords": ["beach", "sunset"]})).unwrap(); + s.execute("photo.setMeta", &json!({"addKeywords": ["family"]})).unwrap(); + let sets = s.execute("keyword.sets", &json!({})).unwrap(); + assert_eq!(sets["current"], "Recent Keywords"); + assert_eq!(sets["keywords"], json!(["family", "beach", "sunset"]), "newest first"); + // ⌥3 on both photos: added to the one without it, so both have it + s.execute("library.select", &json!({"ids": ids})).unwrap(); + let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap(); + assert_eq!((r["keyword"].as_str(), r["added"].as_bool()), (Some("sunset"), Some(true))); + let has = |s: &Session, id: u64, k: &str| s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.iter().any(|x| x == k); + assert!(has(&s, ids[0], "sunset") && has(&s, ids[1], "sunset")); + assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["keywords"][2], "sunset", "the numbers stay put"); + // again: everyone has it, so it's removed + let r = s.execute("keyword.toggleFromSet", &json!({"index": 3})).unwrap(); + assert_eq!(r["added"], false); + assert!(!has(&s, ids[0], "sunset") && !has(&s, ids[1], "sunset")); + assert_eq!(s.execute("keyword.toggleFromSet", &json!({"index": 9})).unwrap()["changed"], 0, "an empty slot does nothing"); + assert!(s.execute("keyword.toggleFromSet", &json!({"index": 10})).is_err()); + // a saved set becomes current; deleting it falls back to Recent Keywords + let r = s.execute("keyword.saveSet", &json!({"name": "Wedding", "keywords": ["ceremony", "rings", "first dance"]})).unwrap(); + assert_eq!((r["current"].as_str(), r["keywords"][1].as_str()), (Some("Wedding"), Some("rings"))); + s.execute("keyword.toggleFromSet", &json!({"index": 2})).unwrap(); + assert!(has(&s, ids[1], "rings")); + assert!(s.execute("keyword.saveSet", &json!({"name": "Recent Keywords"})).is_err(), "reserved"); + s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "wedding"})).unwrap(); + assert_eq!(s.keyword_set.as_deref(), Some("Wedding")); + s.execute("keyword.deleteSet", &json!({"name": "Wedding"})).unwrap(); + assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "Recent Keywords"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 278fb572..d3426ddf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,7 +27,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), ("view.showOriginal", "Show Original", Some("\\"), "View"), ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), - ("view.zoom100", "Zoom 100%", Some("Cmd+1"), "View"), + ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), @@ -60,11 +60,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("panel.versions", "Versions", Some("Shift+V"), "Window"), ("panel.activity", "History", None, "Window"), ("panel.close", "Close Panel", None, ""), - ("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"), - ("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"), - ("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"), - ("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"), - ("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"), + ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), ("tool.brush", "Brush", Some("B"), "Window>Tools"), ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 2c702654..a14487d0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -583,6 +583,8 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.add_space(10.0); + keyword_set(app, ui, &p.meta.keywords); + ui.add_space(10.0); // suggestions: completions of the typed text, else keywords used together with this // photo's keywords, else the most used ones let typed = ui.data(|d| d.get_temp::(kid).unwrap_or_default()); @@ -607,6 +609,64 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } +/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the +/// selected photos; "Save as Set…" keeps the current nine under a name. +fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { + let t = Tokens::get(ui.ctx()); + let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); + let current = sets["current"].as_str().unwrap_or_default().to_string(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Keyword Set").color(t.text_dim)); + egui::ComboBox::from_id_salt("kw-set").selected_text(¤t).show_ui(ui, |ui| { + for set in sets["sets"].as_array().into_iter().flatten() { + let name = set["name"].as_str().unwrap_or_default(); + if ui.selectable_label(name == current, name).clicked() { + let _ = app.run("keyword.useSet", json!({"name": name})); + } + } + ui.separator(); + if ui.button("Save Current Keywords as Set…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: "Save Keyword Set".into(), + hint: "Set name".into(), + value: String::new(), + command: "keyword.saveSet".into(), + params: json!({}), + key: "name".into(), + }); + } + if current != lightcraft_engine::cmd::keywords::RECENT && ui.button(format!("Delete “{current}”")).clicked() { + let _ = app.run("keyword.deleteSet", json!({"name": current})); + } + }); + }); + let kws: Vec = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); + if kws.is_empty() { + ui.label(egui::RichText::new("Keywords you add appear here; ⌥1–⌥9 apply them.").color(t.text_dim)); + return; + } + let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); + egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { + for (i, k) in kws.iter().enumerate() { + let on = have.iter().any(|x| x.eq_ignore_ascii_case(k)); + let short = k.rsplit('|').next().unwrap_or(k); + let r = ui + .add_sized( + [bw, 22.0], + egui::Button::new(egui::RichText::new(short).size(11.5).color(if on { t.text } else { t.text_label })).selected(on).truncate(), + ) + .on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1)); + register(ui.ctx(), format!("kwSet:{}", i + 1), r.rect); + if r.clicked() { + let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1})); + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); +} + fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; header(ui, "Versions"); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d92d7331..af3a2999 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -20,6 +20,16 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ // Shift+[ / Shift+] arrive as { / } on most layouts ("Shift+{", "brush.featherLess", "{}"), ("Shift+}", "brush.featherMore", "{}"), + // keyword set: ⌥1–⌥9 toggle the current set's keywords on the selection + ("Alt+1", "keyword.toggleFromSet", r#"{"index": 1}"#), + ("Alt+2", "keyword.toggleFromSet", r#"{"index": 2}"#), + ("Alt+3", "keyword.toggleFromSet", r#"{"index": 3}"#), + ("Alt+4", "keyword.toggleFromSet", r#"{"index": 4}"#), + ("Alt+5", "keyword.toggleFromSet", r#"{"index": 5}"#), + ("Alt+6", "keyword.toggleFromSet", r#"{"index": 6}"#), + ("Alt+7", "keyword.toggleFromSet", r#"{"index": 7}"#), + ("Alt+8", "keyword.toggleFromSet", r#"{"index": 8}"#), + ("Alt+9", "keyword.toggleFromSet", r#"{"index": 9}"#), ]; pub fn parse(s: &str) -> Option<(Modifiers, Key)> { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8b9cf620..7881f2c8 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -233,3 +233,24 @@ fn command_drag_straightens_in_crop() { assert!(h.app.gesture.is_none()); h.settle(SETTLE); } + +#[test] +fn option_digit_toggles_keyword_from_set() { + let mut h = detail("panel.keywords"); + exec(&mut h, "photo.setMeta", json!({"addKeywords": ["alpha"]})); + exec(&mut h, "photo.setMeta", json!({"removeKeywords": ["alpha"]})); + let has = |h: &Headless| develop_photo_keywords(h).iter().any(|k| k == "alpha"); + assert!(!has(&h)); + let r = h.request("ui.key", json!({"key": "1", "alt": true}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(has(&h), "⌥1 adds the first recent keyword"); + let r = h.request("ui.clickWidget", json!({"id": "kwSet:1"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!has(&h), "its button removes it again"); + h.settle(SETTLE); +} + +fn develop_photo_keywords(h: &Headless) -> Vec { + let id = h.app.session.active().expect("active photo"); + h.app.session.catalog.photo(id).unwrap().meta.keywords.clone() +} diff --git a/docs/parity.md b/docs/parity.md index e91dec30..58cc812f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -46,11 +46,11 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | -| Z. Keyboard shortcuts (desktop) | 67 | 9 | 4 | 1 | 49/52 (94%) | 16/23 (70%) | +| Z. Keyboard shortcuts (desktop) | 68 | 8 | 4 | 1 | 49/52 (94%) | 17/23 (74%) | | Lightroom Classic extras | 15 | 15 | 49 | 9 | — | 14/21 (67%) | -| **Total** | 329 | 47 | 124 | 37 | 193/198 (97%) | 116/144 (81%) | +| **Total** | 330 | 46 | 124 | 37 | 193/198 (97%) | 117/144 (81%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.5%** of 500 in-scope rows — P0 98.7% of 198 · P1 88.9% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.6%** of 500 in-scope rows — P0 98.7% of 198 · P1 89.2% of 144 · P2 18.4% of 158. ## Top gaps @@ -639,7 +639,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | | | KEY-ADDPHOTOS | Add photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | -| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | 🟡 | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | bound to ⌘⌥1–5 | +| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | | KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | | KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | | KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) | @@ -701,7 +701,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | 🟡 | `crates/catalog/src/keywords.rs`, `cmd:keyword.list` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); no keyword sets or painter | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | 🟡 | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords in the Keywords panel, ⌥1–⌥9 toggle; no keyword painter | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings); no copyright status field | | LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | | LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |