From 6b4839f5fc1337a940872a75df28f79002163f7b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:55:15 -0400 Subject: [PATCH 01/10] LRC-LIB-KEYWORDS: keyword sets keep their slots, and can be renamed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For Edit Keyword Set (Lightroom Classic's), `keyword.saveSet`: - keeps each slot in its place: an empty one ("") is an empty slot, so the keywords after it keep their ⌥ keys (empty ones were dropped and the rest moved up); trailing empty slots go, nine at most; - holds each keyword once, whatever its case: a second one leaves its slot empty; - with `replace`, renames that set in its place among the sets and keeps it current; a name another set has is refused. ⌥ on an empty slot does nothing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 51 +++++++++++++++++++------ crates/engine/src/tests_keyword_list.rs | 46 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index a3862b3b..f167c512 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -89,6 +89,22 @@ pub fn note_recent(s: &mut Session, added: &[String]) { let _ = s.save_prefs(); } +/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others +/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty), +/// nine at most, no empty ones at the end. +pub fn slots(typed: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + for k in typed.iter().take(9) { + let k = clean(k); + let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k)); + out.push(if twice { String::new() } else { k }); + } + while out.last().is_some_and(String::is_empty) { + out.pop(); + } + out +} + /// 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))); @@ -129,7 +145,7 @@ pub fn specs() -> Vec { "Save Keyword Set", [], None, - "{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current", + "{name, keywords?: [up to 9, \"\" = an empty slot] (default: the current nine), replace?: the set it renames} — replaces a set of that name (or `replace`, in its place) and makes it current", always, |s, p| { let name = str_param(p, "name") @@ -137,16 +153,28 @@ pub fn specs() -> Vec { .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 keywords = if p.get("keywords").is_some() { slots(&strs(p, "keywords")) } else { current_keywords(s) }; 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), + let same_name = |x: &KeywordSet, n: &str| x.name.to_lowercase() == n.to_lowercase(); + match str_param(p, "replace").map(str::trim) { + // rename: in its place, onto a name no other set has + Some(old) => { + let at = s + .keyword_sets + .iter() + .position(|x| same_name(x, old)) + .ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?; + if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) { + return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already"))); + } + if let Some(x) = s.keyword_sets.get_mut(at) { + *x = set; + } + } + None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) { + Some(x) => *x = set, + None => s.keyword_sets.push(set), + }, } s.keyword_set = Some(name); s.save_prefs()?; @@ -179,7 +207,8 @@ pub fn specs() -> Vec { .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 { + // (an empty slot does nothing) + let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else { return Ok(json!({"changed": 0})); }; let ids = s.targets(p); diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 3084fc12..cfff1f4f 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -183,3 +183,49 @@ fn a_set_keyword_toggles_whatever_its_case() { s.execute("keyword.toggleFromSet", &json!({"index": 1, "ids": ids})).unwrap(); assert!(ids.iter().all(|id| keywords_of(&s, *id).is_empty()), "taken off both"); } + +fn set_named(s: &mut Session, name: &str) -> serde_json::Value { + let sets = s.execute("keyword.sets", &json!({})).unwrap(); + sets["sets"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned().unwrap_or(serde_json::Value::Null) +} + +/// Edit Keyword Set: a set's nine slots keep their places, so each keyword stays on its ⌥ key; an +/// empty slot is allowed and does nothing. +#[test] +fn a_keyword_sets_slots_keep_their_places() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": [" ceremony ", "", "reception", ""]})).unwrap(); + assert_eq!(set_named(&mut s, "Weddings")["keywords"], json!(["ceremony", "", "reception"]), "trailing empty slots go"); + let before = keywords_of(&s, id); + let r = s.execute("keyword.toggleFromSet", &json!({"index": 2, "ids": [id]})).unwrap(); + assert_eq!((r["changed"].as_u64(), keywords_of(&s, id)), (Some(0), before), "⌥2 is empty: nothing"); + s.execute("keyword.toggleFromSet", &json!({"index": 3, "ids": [id]})).unwrap(); + assert!(keywords_of(&s, id).contains(&"reception".to_string()), "⌥3 is reception"); +} + +/// A set holds each keyword once, whatever its case: a second one leaves its slot empty. +#[test] +fn a_keyword_set_holds_each_keyword_once() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["Lisbon", "harbour", "LISBON"]})).unwrap(); + assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["Lisbon", "harbour"])); +} + +/// Renaming a set keeps its place among the sets and keeps it current; a name another set has is +/// refused. +#[test] +fn renaming_a_keyword_set_keeps_its_place() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "Weddings"})).unwrap(); + let r = s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "weddings", "keywords": ["ceremony", "rings"]})).unwrap(); + let names: Vec = r["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["Recent Keywords", "Ceremonies", "Travel"]); + assert_eq!(r["current"], "Ceremonies"); + assert_eq!(r["keywords"], json!(["ceremony", "rings"])); + let err = s.execute("keyword.saveSet", &json!({"name": "travel", "replace": "Ceremonies", "keywords": []})).unwrap_err().to_string(); + assert!(err.contains("already"), "{err}"); + assert!(s.execute("keyword.saveSet", &json!({"name": "Rings", "replace": "Lisbon", "keywords": []})).is_err(), "no such set"); +} From 68c1e78c1bd99bf5dcb44c19fbd3ee79f9798357 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:57:57 -0400 Subject: [PATCH 02/10] LRC-LIB-KEYWORDS: Edit Keyword Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Keyword Set menu gets Edit Set… (Lightroom Classic's Edit Keyword Set): the set's name and its nine keywords, one field per ⌥ key, typed in place. Editing Recent Keywords and naming them saves a new set and makes it current; editing a named set renames it in its place. An empty field is an empty slot, shown as an idle button so the others keep their keys. Return in any field saves; a set without a name says so and the dialog stays open. Russian gets "Set name", and the label check covers the new labels. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 6 +- crates/ui-egui/locales/es.json | 6 +- crates/ui-egui/locales/ja.json | 6 +- crates/ui-egui/locales/pt-br.json | 6 +- crates/ui-egui/locales/ru.json | 7 +- crates/ui-egui/locales/zh-hans.json | 6 +- crates/ui-egui/locales/zh-hant.json | 6 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 45 +++++++++++ crates/ui-egui/src/panels/keyword_list.rs | 4 + crates/ui-egui/src/panels/right.rs | 26 +++++- crates/ui-egui/src/state.rs | 8 ++ crates/ui-egui/src/tests_keyword_set.rs | 99 +++++++++++++++++++++++ 13 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tests_keyword_set.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index dba70e1a..3d153d3c 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1915,5 +1915,9 @@ "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", "Will Export": "Wird exportiert", "No keywords are exported": "Es werden keine Stichwörter exportiert", - "& Containing": "& Übergeordnete" + "& Containing": "& Übergeordnete", + "Edit Set…": "Satz bearbeiten…", + "Edit Keyword Set": "Stichwortsatz bearbeiten", + "Keywords (⌥1–⌥9)": "Stichwörter (⌥1–⌥9)", + "A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 203c53f4..e1781e25 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1760,5 +1760,9 @@ "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas", "Will Export": "Se exportará", "No keywords are exported": "No se exporta ninguna palabra clave", - "& Containing": "y contenedoras" + "& Containing": "y contenedoras", + "Edit Set…": "Editar conjunto…", + "Edit Keyword Set": "Editar conjunto de palabras clave", + "Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)", + "A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ed484a30..ad1b17a3 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1760,5 +1760,9 @@ "Add to All Selected Photos": "選択したすべての写真に追加", "Will Export": "書き出されるキーワード", "No keywords are exported": "書き出されるキーワードはありません", - "& Containing": "と親キーワード" + "& Containing": "と親キーワード", + "Edit Set…": "セットを編集…", + "Edit Keyword Set": "キーワードセットを編集", + "Keywords (⌥1–⌥9)": "キーワード (⌥1–⌥9)", + "A keyword set needs a name": "キーワードセットには名前が必要です" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 43f3e614..4e4f4384 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1760,5 +1760,9 @@ "Add to All Selected Photos": "Adicionar a todas as fotos selecionadas", "Will Export": "Será exportado", "No keywords are exported": "Nenhuma palavra-chave é exportada", - "& Containing": "e as que as contêm" + "& Containing": "e as que as contêm", + "Edit Set…": "Editar conjunto…", + "Edit Keyword Set": "Editar conjunto de palavras-chave", + "Keywords (⌥1–⌥9)": "Palavras-chave (⌥1–⌥9)", + "A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5da2cd67..49e7c8ce 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1434,5 +1434,10 @@ "Add to All Selected Photos": "Добавить ко всем выбранным фото", "Will Export": "Будет экспортировано", "No keywords are exported": "Ключевые слова не экспортируются", - "& Containing": "и родительские" + "& Containing": "и родительские", + "Edit Set…": "Изменить набор…", + "Edit Keyword Set": "Изменить набор ключевых слов", + "Keywords (⌥1–⌥9)": "Ключевые слова (⌥1–⌥9)", + "A keyword set needs a name": "Набору ключевых слов нужно название", + "Set name": "Название набора" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index bbced17d..7b4e3c7f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1760,5 +1760,9 @@ "Add to All Selected Photos": "添加到所有所选照片", "Will Export": "将导出", "No keywords are exported": "没有要导出的关键字", - "& Containing": "及上级" + "& Containing": "及上级", + "Edit Set…": "编辑集…", + "Edit Keyword Set": "编辑关键字集", + "Keywords (⌥1–⌥9)": "关键字 (⌥1–⌥9)", + "A keyword set needs a name": "关键字集需要名称" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f2c7c654..274c6cf9 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1760,5 +1760,9 @@ "Add to All Selected Photos": "加入所有選取的照片", "Will Export": "將匯出", "No keywords are exported": "沒有要匯出的關鍵字", - "& Containing": "及上層" + "& Containing": "及上層", + "Edit Set…": "編輯集…", + "Edit Keyword Set": "編輯關鍵字集", + "Keywords (⌥1–⌥9)": "關鍵字 (⌥1–⌥9)", + "A keyword set needs a name": "關鍵字集需要名稱" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f02544f1..dfaff2c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,8 @@ mod tests_keymap; #[cfg(test)] mod tests_keyword_list; #[cfg(test)] +mod tests_keyword_set; +#[cfg(test)] mod tests_keywording; #[cfg(test)] mod tests_labels; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 62cd8ca0..4ef9015f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -86,6 +86,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { .. } => "Edit Keyword Tag", Dialog::DeleteKeyword { .. } => "Delete Keyword", Dialog::MoveKeyword { .. } => "Merge Keywords", + Dialog::KeywordSet { .. } => "Edit Keyword Set", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", @@ -772,6 +773,38 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), id, r.rect); } } + Dialog::KeywordSet { name, slots, .. } => { + ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); + let r = crate::text_field::TextField::singleline("field:keywordSetName", name) + .hint(crate::i18n::tr("Set name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if just_opened(ui, "keyword-set") { + r.response.request_focus(); + } + let mut returned = r.ending == Some(crate::text_field::Ending::Return); + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); + slots.resize(9, String::new()); + let w = ((ui.available_width() - 8.0) / 3.0).floor().max(60.0); + egui::Grid::new("keyword-set-slots").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { + for (i, slot) in slots.iter_mut().enumerate() { + let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) + .hint(format!("⌥{}", i + 1)) + .width(w) + .show(ui); + returned |= r.ending == Some(crate::text_field::Ending::Return); + if i % 3 == 2 { + ui.end_row(); + } + } + }); + // Return in any field saves the set + if returned { + confirm = true; + } + } Dialog::MoveKeyword { keyword, parent } => { let name = keyword.rsplit('|').next().unwrap_or(keyword); match parent { @@ -906,6 +939,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", + Dialog::KeywordSet { .. } => "Save", Dialog::KeywordTag { editing: None, .. } => "Create", Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", @@ -955,6 +989,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } | Dialog::KeywordTag { .. } + | Dialog::KeywordSet { .. } ) => { app.toast(ctx, e) @@ -1189,6 +1224,16 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.delete", json!({"keyword": keyword})), + Dialog::KeywordSet { replaces, name, slots } => { + if name.trim().is_empty() { + return Err(crate::i18n::tr("A keyword set needs a name").to_string()); + } + let mut params = json!({"name": name.trim(), "keywords": slots}); + if let Some(old) = replaces { + params["replace"] = json!(old); + } + app.run("keyword.saveSet", params) + } Dialog::MoveKeyword { keyword, parent } => { let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); if r.is_ok() { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index e1eabd71..940f08dd 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -598,6 +598,10 @@ mod tests { "Will Export", "No keywords are exported", "Add to All Selected Photos", + "Set name", + "Edit Set…", + "Edit Keyword Set", + "A keyword set needs a name", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); for code in ["de", "es", "pt-br", "ru", "ja", "zh-hans", "zh-hant"] { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7f5aa841..eb26a7f0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -864,7 +864,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = sets["current"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); - egui::ComboBox::from_id_salt("kw-set") + let combo = egui::ComboBox::from_id_salt("kw-set") .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) .show_ui(ui, |ui| { for set in sets["sets"].as_array().into_iter().flatten() { @@ -877,6 +877,20 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } ui.separator(); + // Edit Set…: the current nine, slot by slot (named, Recent Keywords becomes a set) + let edit = ui.button(crate::i18n::tr("Edit Set…")); + register(ui.ctx(), "keywordSetMenu:edit", edit.rect); + if edit.clicked() { + let mut slots: Vec = + sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); + slots.resize(9, String::new()); + let named = current != lightcraft_engine::cmd::keywords::RECENT; + app.ui.dialog = Some(crate::state::Dialog::KeywordSet { + replaces: named.then(|| current.clone()), + name: if named { current.clone() } else { String::new() }, + slots, + }); + } if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::TextPrompt { title: "Save Keyword Set".into(), @@ -893,6 +907,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("keyword.deleteSet", json!({"name": current})); } }); + register(ui.ctx(), "keywordSetCombo", combo.response.rect); }); 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() { @@ -902,6 +917,15 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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() { + // an empty slot: an idle button, so the others keep their ⌥ keys + if k.is_empty() { + let r = ui.add_enabled_ui(false, |ui| ui.add_sized([bw, 22.0], egui::Button::new(""))).inner; + register(ui.ctx(), format!("kwSetEmpty:{}", i + 1), r.rect); + if i % 3 == 2 { + ui.end_row(); + } + continue; + } let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k))); let on = chip.is_some_and(|c| c.on_all()); let some = chip.is_some() && !on; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4e64a87c..86429f1e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -596,6 +596,14 @@ pub enum Dialog { /// A new keyword is given to the selected photos. add_to_selected: bool, }, + /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: + /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent + /// Keywords). + KeywordSet { + replaces: Option, + name: String, + slots: Vec, + }, /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: /// asks before merging the two. MoveKeyword { diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests_keyword_set.rs new file mode 100644 index 00000000..d0d69995 --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_set.rs @@ -0,0 +1,99 @@ +//! Edit Keyword Set (Lightroom Classic's): the nine keywords of a keyword set, typed in place. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The Keywords panel with a photo selected, beach and Weddings recently added. +fn keywords_panel() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let id = h.app.session.visible_cloned()[0].0; + for (method, params) in [ + ("ui.set", json!({"view": "photoGrid", "right": "keywords"})), + ("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}})), + ("engine.execute", json!({"command": "photo.setMeta", "params": {"addKeywords": ["beach", "Weddings"]}})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + h +} + +fn ask(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params.clone(), T); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); +} + +fn sets(h: &mut Headless) -> serde_json::Value { + h.app.session.execute("keyword.sets", &json!({})).unwrap() +} + +fn open_editor(h: &mut Headless) { + ask(h, "ui.clickWidget", json!({"id": "keywordSetCombo"})); + ask(h, "ui.clickWidget", json!({"id": "keywordSetMenu:edit"})); +} + +/// Edit Set… on Recent Keywords shows the current nine in their slots; named, they become a set of +/// their own (and the current one), with what was typed in an empty slot. +#[test] +fn editing_recent_keywords_saves_them_as_a_set() { + let mut h = keywords_panel(); + let recent = sets(&mut h)["keywords"].clone(); + open_editor(&mut h); + let Some(crate::state::Dialog::KeywordSet { replaces, name, slots }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((replaces, name.as_str()), (None, "")); + assert_eq!(json!(slots.iter().filter(|k| !k.is_empty()).collect::>()), recent, "the current nine"); + ask(&mut h, "ui.text", json!({"text": "Beach Weddings"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordSetSlot:3"})); + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert_eq!(h.app.ui.dialog, None); + let s = sets(&mut h); + assert_eq!(s["current"], "Beach Weddings"); + assert_eq!(s["keywords"][2], "harbour", "slot 3 is ⌥3"); +} + +/// Edit Set… on a named set edits it: renamed, it keeps its place and stays current. +#[test] +fn editing_a_named_set_renames_it() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony", "reception"]})).unwrap(); + h.settle(SETTLE); + open_editor(&mut h); + let Some(crate::state::Dialog::KeywordSet { replaces, name, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((replaces.as_deref(), name.as_str()), (Some("Weddings"), "Weddings")); + ask(&mut h, "ui.text", json!({"text": "Ceremonies"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + let s = sets(&mut h); + let names: Vec<&str> = s["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Recent Keywords", "Ceremonies"]); + assert_eq!(s["keywords"], json!(["ceremony", "reception"])); +} + +/// A set needs a name: without one the dialog says so and stays open. +#[test] +fn a_set_needs_a_name() { + let mut h = keywords_panel(); + open_editor(&mut h); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordSet { .. })), "still open"); + assert!(h.app.ui.toast.is_some(), "and says why"); +} + +/// An empty slot is an empty button that does nothing. +#[test] +fn an_empty_slot_is_an_idle_button() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony", "", "reception"]})).unwrap(); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| w == "kwSetEmpty:2"), "slot 2 is shown empty"); +} From bbf2515c200f7b67f6a691d32c8eadfb1d76f89a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:59:15 -0400 Subject: [PATCH 03/10] LRC-LIB-KEYWORDS: Edit Keyword Set's fields are alike The nine fields took their width from what the dialog had while it found its size: the first two columns came out squeezed ("ceremony" showed "ceren") and the third wide. They are now equal columns of a set width. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 5 +++-- crates/ui-egui/src/tests_keyword_set.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 4ef9015f..b5f8096d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -787,8 +787,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.label(egui::RichText::new(crate::i18n::tr("Keywords (⌥1–⌥9)")).color(t.text_dim)); slots.resize(9, String::new()); - let w = ((ui.available_width() - 8.0) / 3.0).floor().max(60.0); - egui::Grid::new("keyword-set-slots").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { + // equal columns of a set width (the window is still finding its own) + let w = 130.0; + egui::Grid::new("keyword-set-slots").num_columns(3).min_col_width(w).spacing([6.0, 4.0]).show(ui, |ui| { for (i, slot) in slots.iter_mut().enumerate() { let r = crate::text_field::TextField::singleline(&format!("field:keywordSetSlot:{}", i + 1), slot) .hint(format!("⌥{}", i + 1)) diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests_keyword_set.rs index d0d69995..f62bf65e 100644 --- a/crates/ui-egui/src/tests_keyword_set.rs +++ b/crates/ui-egui/src/tests_keyword_set.rs @@ -97,3 +97,17 @@ fn an_empty_slot_is_an_idle_button() { h.settle(SETTLE); assert!(h.app.widgets.iter().any(|(w, _)| w == "kwSetEmpty:2"), "slot 2 is shown empty"); } + +/// The nine fields are alike and wide enough for a keyword: a grid of equal columns, not one sized +/// by what the window had room for while it found its size. +#[test] +fn the_slots_are_alike() { + let mut h = keywords_panel(); + open_editor(&mut h); + let slots: Vec = + (1..=9).map(|i| h.app.widgets.iter().find(|(w, _)| *w == format!("field:keywordSetSlot:{i}")).map(|(_, r)| *r).expect("slot")).collect(); + for r in &slots { + assert!(r.width() >= 100.0, "{r:?}"); + assert!((r.width() - slots[0].width()).abs() < 1.0, "{r:?} vs {:?}", slots[0]); + } +} From 1f8447ba796670502cdbe838ddb56211d3771ee0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:59:55 -0400 Subject: [PATCH 04/10] LRC-LIB-KEYWORDS: document Edit Keyword Set Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 276db5cf..ddef25c5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -752,7 +752,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, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`). Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut | | 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; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 7e40a0c7..2dcd2fef 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Keyword sets +- Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it. An empty field + leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming them saves a new + set. + ### Keywording box - The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from From 6fd57cdcb15da84e4264fea9cc9fe5a986846c41 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:05:44 -0400 Subject: [PATCH 05/10] LRC-LIB-KEYWORDS: renaming a set alone keeps its keywords `keyword.saveSet {name, replace}` without `keywords` took the current set's keywords, so renaming a set that wasn't current gave it another set's nine. The keywords default to the renamed set's own now (the current nine only for a new set), and an empty `replace` is no rename. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 31 ++++++++++++++++++------- crates/engine/src/tests_keyword_list.rs | 10 ++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index f167c512..00d8185e 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Save Keyword Set", [], None, - "{name, keywords?: [up to 9, \"\" = an empty slot] (default: the current nine), replace?: the set it renames} — replaces a set of that name (or `replace`, in its place) and makes it current", + "{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames} — replaces a set of that name (or `replace`, in its place) and makes it current", always, |s, p| { let name = str_param(p, "name") @@ -153,17 +153,30 @@ pub fn specs() -> Vec { .filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT)) .ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))? .to_string(); - let keywords = if p.get("keywords").is_some() { slots(&strs(p, "keywords")) } else { current_keywords(s) }; - let set = KeywordSet { name: name.clone(), keywords }; let same_name = |x: &KeywordSet, n: &str| x.name.to_lowercase() == n.to_lowercase(); - match str_param(p, "replace").map(str::trim) { - // rename: in its place, onto a name no other set has - Some(old) => { - let at = s - .keyword_sets + // the set it renames, if any (an empty `replace` is none) + let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) { + Some(old) => Some( + s.keyword_sets .iter() .position(|x| same_name(x, old)) - .ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?; + .ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?, + ), + None => None, + }; + // keywords not given: the renamed set's own, else the current nine + let keywords = if p.get("keywords").is_some() { + slots(&strs(p, "keywords")) + } else { + match renames.and_then(|at| s.keyword_sets.get(at)) { + Some(x) => x.keywords.clone(), + None => current_keywords(s), + } + }; + let set = KeywordSet { name: name.clone(), keywords }; + match renames { + // rename: in its place, onto a name no other set has + Some(at) => { if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) { return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already"))); } diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index cfff1f4f..a14f44af 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -229,3 +229,13 @@ fn renaming_a_keyword_set_keeps_its_place() { assert!(err.contains("already"), "{err}"); assert!(s.execute("keyword.saveSet", &json!({"name": "Rings", "replace": "Lisbon", "keywords": []})).is_err(), "no such set"); } + +/// Renaming a set without giving its keywords keeps its keywords (not the current set's). +#[test] +fn renaming_a_set_alone_keeps_its_keywords() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "Weddings"})).unwrap(); + assert_eq!(set_named(&mut s, "Ceremonies")["keywords"], json!(["ceremony"])); +} From 405e8d82203ecd7e006d1a45c8c511917416272b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:06:31 -0400 Subject: [PATCH 06/10] LRC-LIB-KEYWORDS: a new keyword set doesn't take another set's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving Recent Keywords as a set under the name of an existing one (Edit Set… on Recent Keywords) replaced that set's keywords without a word, while renaming onto a taken name was refused. `keyword.saveSet` takes `new: true` for a set that must be new, and refuses a name another set has. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 6 +++++- crates/engine/src/tests_keyword_list.rs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 00d8185e..7329a7c4 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Save Keyword Set", [], None, - "{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames} — replaces a set of that name (or `replace`, in its place) and makes it current", + "{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current", always, |s, p| { let name = str_param(p, "name") @@ -184,6 +184,10 @@ pub fn specs() -> Vec { *x = set; } } + // a new set (`new`) never takes another set's name + None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => { + return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already"))); + } None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) { Some(x) => *x = set, None => s.keyword_sets.push(set), diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index a14f44af..1d22da34 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -239,3 +239,14 @@ fn renaming_a_set_alone_keeps_its_keywords() { s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "Weddings"})).unwrap(); assert_eq!(set_named(&mut s, "Ceremonies")["keywords"], json!(["ceremony"])); } + +/// A new set (`new: true`, what Edit Set… on Recent Keywords saves) never takes another set's +/// name: saving over it silently is refused. +#[test] +fn a_new_set_doesnt_take_a_sets_name() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour", "lisbon"]})).unwrap(); + let err = s.execute("keyword.saveSet", &json!({"name": "travel", "new": true, "keywords": ["x"]})).unwrap_err().to_string(); + assert!(err.contains("already"), "{err}"); + assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["harbour", "lisbon"]), "left as it was"); +} From efa758a6bad9c4652071740550d44b741268a7b3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:07:00 -0400 Subject: [PATCH 07/10] LRC-LIB-KEYWORDS: keyword set names match whatever their case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving compared set names by any letter's case while using, deleting and the current set compared ASCII case only: "été" saved over "ÉTÉ" but couldn't use or delete it. Every set command (and Recent Keywords) now compares names one way, as keywords are compared. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 22 ++++++++++++++-------- crates/engine/src/tests_keyword_list.rs | 12 ++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 7329a7c4..4af1a87f 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -72,6 +72,12 @@ pub struct KeywordSet { pub keywords: Vec, } +/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as +/// keywords are. +fn same_set(a: &str, b: &str) -> bool { + lightcraft_catalog::keywords::same(a.trim(), b.trim()) +} + /// The set name meaning "the nine most recently added keywords". pub const RECENT: &str = "Recent Keywords"; @@ -82,7 +88,7 @@ pub fn note_recent(s: &mut Session, added: &[String]) { if k.is_empty() { continue; } - s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k)); + s.recent_keywords.retain(|x| !same_set(x, &k)); s.recent_keywords.insert(0, k); } s.recent_keywords.truncate(9); @@ -107,7 +113,7 @@ pub fn slots(typed: &[String]) -> Vec { /// 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 set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n))); let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone()); v.truncate(9); v @@ -125,13 +131,13 @@ pub fn specs() -> 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() { + s.keyword_set = if same_set(name, RECENT) || name.is_empty() { None } else { Some( s.keyword_sets .iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) + .find(|x| same_set(&x.name, name)) .ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))? .name .clone(), @@ -150,10 +156,10 @@ pub fn specs() -> Vec { |s, p| { let name = str_param(p, "name") .map(str::trim) - .filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT)) + .filter(|n| !n.is_empty() && !same_set(n, RECENT)) .ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))? .to_string(); - let same_name = |x: &KeywordSet, n: &str| x.name.to_lowercase() == n.to_lowercase(); + let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n); // the set it renames, if any (an empty `replace` is none) let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) { Some(old) => Some( @@ -201,11 +207,11 @@ pub fn specs() -> Vec { 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)); + s.keyword_sets.retain(|x| !same_set(&x.name, 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)) { + if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) { s.keyword_set = None; } s.save_prefs()?; diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 1d22da34..dfcdb388 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -250,3 +250,15 @@ fn a_new_set_doesnt_take_a_sets_name() { assert!(err.contains("already"), "{err}"); assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["harbour", "lisbon"]), "left as it was"); } + +/// Set names match whatever the case of any of their letters, for every set command alike. +#[test] +fn set_names_match_whatever_their_case() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "ÉTÉ", "keywords": ["sunset"]})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "été"})).unwrap(); + assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "ÉTÉ"); + s.execute("keyword.deleteSet", &json!({"name": "été"})).unwrap(); + assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted"); +} From 94d88dd4cc4410209f0f1ec5fd3958270409d582 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:09:01 -0400 Subject: [PATCH 08/10] LRC-LIB-KEYWORDS: Edit Keyword Set refuses taken names in the user's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edit Set… on Recent Keywords saves a new set (`new: true`): a name another set has is refused, where it used to replace that set. - The dialog checks names before saving and says why in the user's language ("There is a keyword set “…” already"; Recent Keywords is built in), instead of showing the engine's message. - A set deleted while its dialog was open is saved again, as a new set, rather than refused with no way out but Cancel. - Save as a new set (Lightroom Classic's Save as New Preset) keeps the edited set and saves the edits under the new name. Russian gets "Recent Keywords", which showed in English. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 5 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/panels/dialogs.rs | 30 +++++++++--- crates/ui-egui/src/panels/keyword_list.rs | 3 ++ crates/ui-egui/src/panels/right.rs | 1 + crates/ui-egui/src/state.rs | 3 ++ crates/ui-egui/src/tests_keyword_set.rs | 51 ++++++++++++++++++++- 19 files changed, 117 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index fd5e1986..a259b693 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?", "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt", "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos", - "Remove “{name}”": "„{name}“ entfernen" + "Remove “{name}”": "„{name}“ entfernen", + "There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 3d153d3c..a82080c9 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1919,5 +1919,7 @@ "Edit Set…": "Satz bearbeiten…", "Edit Keyword Set": "Stichwortsatz bearbeiten", "Keywords (⌥1–⌥9)": "Stichwörter (⌥1–⌥9)", - "A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen" + "A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen", + "Save as a new set": "Als neuen Satz speichern", + "Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wählen Sie einen anderen Namen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index dfd32099..04f32100 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?", "Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}", "On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas", - "Remove “{name}”": "Quitar “{name}”" + "Remove “{name}”": "Quitar “{name}”", + "There is a keyword set “{name}” already": "Ya hay un conjunto de palabras clave “{name}”" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e1781e25..7be7d02c 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1764,5 +1764,7 @@ "Edit Set…": "Editar conjunto…", "Edit Keyword Set": "Editar conjunto de palabras clave", "Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)", - "A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre" + "A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre", + "Save as a new set": "Guardar como conjunto nuevo", + "Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index cab18a74..cadc6834 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?", "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}", "On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚", - "Remove “{name}”": "「{name}」を削除" + "Remove “{name}”": "「{name}」を削除", + "There is a keyword set “{name}” already": "キーワードセット「{name}」はすでにあります" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ad1b17a3..3c9a6cf5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1764,5 +1764,7 @@ "Edit Set…": "セットを編集…", "Edit Keyword Set": "キーワードセットを編集", "Keywords (⌥1–⌥9)": "キーワード (⌥1–⌥9)", - "A keyword set needs a name": "キーワードセットには名前が必要です" + "A keyword set needs a name": "キーワードセットには名前が必要です", + "Save as a new set": "新しいセットとして保存", + "Recent Keywords is built in: choose another name": "「最近使用したキーワード」は組み込みです。別の名前を選んでください" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 5215af8b..f0bbcb2c 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?", "Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}", "On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas", - "Remove “{name}”": "Remover “{name}”" + "Remove “{name}”": "Remover “{name}”", + "There is a keyword set “{name}” already": "Já existe um conjunto de palavras-chave “{name}”" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 4e4f4384..e2c3f582 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1764,5 +1764,7 @@ "Edit Set…": "Editar conjunto…", "Edit Keyword Set": "Editar conjunto de palavras-chave", "Keywords (⌥1–⌥9)": "Palavras-chave (⌥1–⌥9)", - "A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome" + "A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome", + "Save as a new set": "Salvar como novo conjunto", + "Recent Keywords is built in: choose another name": "“Palavras-chave recentes” é fixo: escolha outro nome" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index b59e9474..66f9cfd5 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?", "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}", "On {have} of {of} selected photos": "На {have} из {of} выбранных фото", - "Remove “{name}”": "Убрать «{name}»" + "Remove “{name}”": "Убрать «{name}»", + "There is a keyword set “{name}” already": "Набор ключевых слов «{name}» уже есть" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 49e7c8ce..40cae6c3 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1439,5 +1439,8 @@ "Edit Keyword Set": "Изменить набор ключевых слов", "Keywords (⌥1–⌥9)": "Ключевые слова (⌥1–⌥9)", "A keyword set needs a name": "Набору ключевых слов нужно название", - "Set name": "Название набора" + "Set name": "Название набора", + "Save as a new set": "Сохранить как новый набор", + "Recent Keywords is built in: choose another name": "«Недавние ключевые слова» — встроенный набор: выберите другое название", + "Recent Keywords": "Недавние ключевые слова" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 3d32f096..b329eef6 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?", "Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}", "On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张", - "Remove “{name}”": "移除“{name}”" + "Remove “{name}”": "移除“{name}”", + "There is a keyword set “{name}” already": "已有关键字集“{name}”" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7b4e3c7f..ba429c36 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1764,5 +1764,7 @@ "Edit Set…": "编辑集…", "Edit Keyword Set": "编辑关键字集", "Keywords (⌥1–⌥9)": "关键字 (⌥1–⌥9)", - "A keyword set needs a name": "关键字集需要名称" + "A keyword set needs a name": "关键字集需要名称", + "Save as a new set": "另存为新关键字集", + "Recent Keywords is built in: choose another name": "“最近使用的关键字”为内置集:请另选名称" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index dfe74be2..ba846220 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -215,5 +215,6 @@ "There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?", "Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}", "On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張", - "Remove “{name}”": "移除「{name}」" + "Remove “{name}”": "移除「{name}」", + "There is a keyword set “{name}” already": "已有關鍵字集「{name}」" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 274c6cf9..e1011b32 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1764,5 +1764,7 @@ "Edit Set…": "編輯集…", "Edit Keyword Set": "編輯關鍵字集", "Keywords (⌥1–⌥9)": "關鍵字 (⌥1–⌥9)", - "A keyword set needs a name": "關鍵字集需要名稱" + "A keyword set needs a name": "關鍵字集需要名稱", + "Save as a new set": "另存為新關鍵字集", + "Recent Keywords is built in: choose another name": "「最近使用的關鍵字」為內建集:請另選名稱" } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index b5f8096d..08788cbb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -773,7 +773,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), id, r.rect); } } - Dialog::KeywordSet { name, slots, .. } => { + Dialog::KeywordSet { replaces, name, slots, as_new } => { ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim)); let r = crate::text_field::TextField::singleline("field:keywordSetName", name) .hint(crate::i18n::tr("Set name")) @@ -801,6 +801,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } }); + if replaces.is_some() { + ui.add_space(6.0); + let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set")); + crate::widgets::register(ui.ctx(), "check:keywordSetAsNew", r.rect); + } // Return in any field saves the set if returned { confirm = true; @@ -1225,13 +1230,26 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.delete", json!({"keyword": keyword})), - Dialog::KeywordSet { replaces, name, slots } => { - if name.trim().is_empty() { + Dialog::KeywordSet { replaces, name, slots, as_new } => { + use lightcraft_catalog::keywords::same; + let name = name.trim(); + if name.is_empty() { return Err(crate::i18n::tr("A keyword set needs a name").to_string()); } - let mut params = json!({"name": name.trim(), "keywords": slots}); - if let Some(old) = replaces { - params["replace"] = json!(old); + if same(name, lightcraft_engine::cmd::keywords::RECENT) { + return Err(crate::i18n::tr("Recent Keywords is built in: choose another name").to_string()); + } + // the set it renames: none when saving as new, or when it was deleted meanwhile (then it is + // saved anew rather than refused) + let sets = &app.session.keyword_sets; + let renames = replaces.as_deref().filter(|_| !as_new).filter(|old| sets.iter().any(|x| same(&x.name, old))); + if sets.iter().any(|x| same(&x.name, name) && !renames.is_some_and(|old| same(&x.name, old))) { + return Err(crate::i18n::tr_format!("There is a keyword set “{name}” already", name = name)); + } + let mut params = json!({"name": name, "keywords": slots}); + match renames { + Some(old) => params["replace"] = json!(old), + None => params["new"] = json!(true), } app.run("keyword.saveSet", params) } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 940f08dd..e315d1bd 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -602,6 +602,9 @@ mod tests { "Edit Set…", "Edit Keyword Set", "A keyword set needs a name", + "Recent Keywords", + "Keywords (⌥1–⌥9)", + "Save as a new set", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); for code in ["de", "es", "pt-br", "ru", "ja", "zh-hans", "zh-hant"] { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index eb26a7f0..01be66fe 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -889,6 +889,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { replaces: named.then(|| current.clone()), name: if named { current.clone() } else { String::new() }, slots, + as_new: false, }); } if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 86429f1e..15bc6221 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -603,6 +603,9 @@ pub enum Dialog { replaces: Option, name: String, slots: Vec, + /// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset). + #[serde(default)] + as_new: bool, }, /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: /// asks before merging the two. diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests_keyword_set.rs index f62bf65e..4667f536 100644 --- a/crates/ui-egui/src/tests_keyword_set.rs +++ b/crates/ui-egui/src/tests_keyword_set.rs @@ -49,7 +49,7 @@ fn editing_recent_keywords_saves_them_as_a_set() { let mut h = keywords_panel(); let recent = sets(&mut h)["keywords"].clone(); open_editor(&mut h); - let Some(crate::state::Dialog::KeywordSet { replaces, name, slots }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; + let Some(crate::state::Dialog::KeywordSet { replaces, name, slots, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; assert_eq!((replaces, name.as_str()), (None, "")); assert_eq!(json!(slots.iter().filter(|k| !k.is_empty()).collect::>()), recent, "the current nine"); ask(&mut h, "ui.text", json!({"text": "Beach Weddings"})); @@ -111,3 +111,52 @@ fn the_slots_are_alike() { assert!((r.width() - slots[0].width()).abs() < 1.0, "{r:?} vs {:?}", slots[0]); } } + +/// Naming Recent Keywords like a set that exists says so, in the user's words, and keeps the +/// dialog (and that set) as they were. +#[test] +fn a_taken_name_says_so() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap(); + h.app.session.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + h.settle(SETTLE); + open_editor(&mut h); + ask(&mut h, "ui.text", json!({"text": "travel"})); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordSet { .. })), "still open"); + let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); + assert!(toast.starts_with("There is a keyword set"), "{toast}"); + let s = sets(&mut h); + let travel = s["sets"].as_array().unwrap().iter().find(|x| x["name"] == "Travel").cloned().unwrap(); + assert_eq!(travel["keywords"], json!(["harbour"]), "left alone"); +} + +/// A set deleted while its dialog was open is saved again, as a new set, rather than refused. +#[test] +fn a_set_deleted_meanwhile_is_saved_anew() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + h.settle(SETTLE); + open_editor(&mut h); + h.app.session.execute("keyword.deleteSet", &json!({"name": "Weddings"})).unwrap(); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert_eq!(h.app.ui.dialog, None); + assert_eq!(sets(&mut h)["current"], "Weddings"); +} + +/// Save as a new set (Lightroom Classic's Save as New Preset): the edited set stays, and the new +/// one, under its own name, becomes current. +#[test] +fn save_as_a_new_set_keeps_the_original() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + h.settle(SETTLE); + open_editor(&mut h); + ask(&mut h, "ui.text", json!({"text": "Ceremonies"})); + ask(&mut h, "ui.clickWidget", json!({"id": "check:keywordSetAsNew"})); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + let s = sets(&mut h); + let names: Vec<&str> = s["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Recent Keywords", "Weddings", "Ceremonies"]); + assert_eq!(s["current"], "Ceremonies"); +} From 4304a7640bc092252dacf0e2a1f012533781a3e8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:09:15 -0400 Subject: [PATCH 09/10] LRC-LIB-KEYWORDS: Chinese names keyword sets as keyword sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Set name" (Edit Keyword Set, Save Keyword Set) read 标签集名称 / 標籤集名稱, the colour label sets' term; it is 关键字集名称 / 關鍵字集名稱, as the rest of the dialog says. "Edit Set…" says what it edits: 编辑关键字集… / 編輯關鍵字集…. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/zh-hans.json | 4 ++-- crates/ui-egui/locales/zh-hant.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ba429c36..2726ff58 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1283,7 +1283,7 @@ "Set as Target Album (B adds to it)": "设为目标相册 (按 B 添加)", "Set as Top of Stack": "设为堆叠顶部", "Set date & time": "设置日期和时间", - "Set name": "标签集名称", + "Set name": "关键字集名称", "Set the mix from the photo's colours": "根据照片颜色设置混合", "Settings": "设置", "Settings file problem": "设置文件有问题", @@ -1761,7 +1761,7 @@ "Will Export": "将导出", "No keywords are exported": "没有要导出的关键字", "& Containing": "及上级", - "Edit Set…": "编辑集…", + "Edit Set…": "编辑关键字集…", "Edit Keyword Set": "编辑关键字集", "Keywords (⌥1–⌥9)": "关键字 (⌥1–⌥9)", "A keyword set needs a name": "关键字集需要名称", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e1011b32..b0c8d19b 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1283,7 +1283,7 @@ "Set as Target Album (B adds to it)": "設為目標相簿(按 B 加入)", "Set as Top of Stack": "設為堆疊頂端", "Set date & time": "設定日期與時間", - "Set name": "標籤集名稱", + "Set name": "關鍵字集名稱", "Set the mix from the photo's colours": "依照片色彩設定混色", "Settings": "設定", "Settings file problem": "設定檔有問題", @@ -1761,7 +1761,7 @@ "Will Export": "將匯出", "No keywords are exported": "沒有要匯出的關鍵字", "& Containing": "及上層", - "Edit Set…": "編輯集…", + "Edit Set…": "編輯關鍵字集…", "Edit Keyword Set": "編輯關鍵字集", "Keywords (⌥1–⌥9)": "關鍵字 (⌥1–⌥9)", "A keyword set needs a name": "關鍵字集需要名稱", From 5781b9c9f6c43a13a7e69a0cdffae3cda2fa33a8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 16:09:47 -0400 Subject: [PATCH 10/10] LRC-LIB-KEYWORDS: document saving a keyword set as a new one Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index ddef25c5..6ab883e2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -752,7 +752,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, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`). Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut | | 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; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 2dcd2fef..e0c2bb61 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -3,9 +3,9 @@ ## October 2026 ### Keyword sets -- Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it. An empty field - leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming them saves a new - set. +- Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it (or saves the edits as a new + set). An empty field leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming + them saves a new set. ### Keywording box - The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a