LRC-LIB-KEYWORDS + KEY-SECTIONS: keyword sets with ⌥1–⌥9, ⌘1–⌘5 edit sections

- Keyword sets: Recent Keywords (the nine most recently added, newest first) plus user sets in
  prefs (keyword.sets, useSet, saveSet, deleteSet). keyword.toggleFromSet {index}: adds keyword N
  to the selection, or removes it when every photo has it; applying from Recent Keywords keeps
  the numbering. Keywords panel: set picker (save current as a set, delete) and a 3×3 grid of
  toggles; ⌥1–⌥9.
- ⌘1–⌘5 expand/collapse Light, Color, Effects, Detail, Optics as observed in the reference;
  Zoom 100% moves to ⌥⌘0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 21:17:15 -04:00
parent 86c4048ad6
commit b8344aa5eb
11 changed files with 297 additions and 13 deletions

View File

@ -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<String>,
}
/// 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<String> {
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<CommandSpec> {
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<String> = 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<u64> = 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())
}),

View File

@ -411,6 +411,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
let n = ops.len();
s.commit("Edit Info", Op::Batch { ops })?;
// keywords just added become the Recent Keywords set
let added: Vec<String> = 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}))
}
),

View File

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

View File

@ -125,6 +125,11 @@ pub struct Session {
pub filter_presets: Vec<cmd::filters::FilterPreset>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// 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<cmd::keywords::KeywordSet>,
pub keyword_set: Option<String>,
pub recent_keywords: Vec<String>,
/// 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<PhotoId, Arc<DevelopSettings>>,
@ -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(),

View File

@ -114,6 +114,10 @@ struct PrefsFile {
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
/// Colour-label name sets.
label_sets: Vec<crate::cmd::manage::LabelSet>,
/// Keyword sets, the one in use, recent keywords.
keyword_sets: Vec<crate::cmd::keywords::KeywordSet>,
keyword_set: Option<String>,
recent_keywords: Vec<String>,
/// 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,
})

View File

@ -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<u64> = 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");
}

View File

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

View File

@ -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::<String>(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(&current).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<String> = 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");

View File

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

View File

@ -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<String> {
let id = h.app.session.active().expect("active photo");
h.app.session.catalog.photo(id).unwrap().meta.keywords.clone()
}

View File

@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |