# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas 2026-10-09 20:55:49 -04:00
commit bf0656fa95
24 changed files with 493 additions and 35 deletions

View File

@ -72,6 +72,12 @@ pub struct KeywordSet {
pub keywords: Vec<String>,
}
/// 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,16 +88,32 @@ 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);
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<String> {
let mut out: Vec<String> = 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<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 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
@ -109,13 +131,13 @@ pub fn specs() -> Vec<CommandSpec> {
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(),
@ -129,24 +151,53 @@ pub fn specs() -> Vec<CommandSpec> {
"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 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")
.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 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)
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(
s.keyword_sets
.iter()
.position(|x| same_name(x, 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),
}
};
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),
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")));
}
if let Some(x) = s.keyword_sets.get_mut(at) {
*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),
},
}
s.keyword_set = Some(name);
s.save_prefs()?;
@ -156,11 +207,11 @@ pub fn specs() -> Vec<CommandSpec> {
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()?;
@ -179,7 +230,8 @@ pub fn specs() -> Vec<CommandSpec> {
.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);

View File

@ -183,3 +183,82 @@ 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<String> = 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");
}
/// 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"]));
}
/// 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");
}
/// 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");
}

View File

@ -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}“"
}

View File

@ -1978,5 +1978,11 @@
"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",
"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"
}

View File

@ -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}”"
}

View File

@ -1823,5 +1823,11 @@
"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",
"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"
}

View File

@ -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}」はすでにあります"
}

View File

@ -1825,5 +1825,11 @@
"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": "キーワードセットには名前が必要です",
"Save as a new set": "新しいセットとして保存",
"Recent Keywords is built in: choose another name": "「最近使用したキーワード」は組み込みです。別の名前を選んでください"
}

View File

@ -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}”"
}

View File

@ -1823,5 +1823,11 @@
"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",
"Save as a new set": "Salvar como novo conjunto",
"Recent Keywords is built in: choose another name": "“Palavras-chave recentes” é fixo: escolha outro nome"
}

View File

@ -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}» уже есть"
}

View File

@ -1533,5 +1533,13 @@
"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": "Название набора",
"Save as a new set": "Сохранить как новый набор",
"Recent Keywords is built in: choose another name": "«Недавние ключевые слова» — встроенный набор: выберите другое название",
"Recent Keywords": "Недавние ключевые слова"
}

View File

@ -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}”"
}

View File

@ -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": "设置文件有问题",
@ -1823,5 +1823,11 @@
"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": "关键字集需要名称",
"Save as a new set": "另存为新关键字集",
"Recent Keywords is built in: choose another name": "“最近使用的关键字”为内置集:请另选名称"
}

View File

@ -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}」"
}

View File

@ -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": "設定檔有問題",
@ -1823,5 +1823,11 @@
"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": "關鍵字集需要名稱",
"Save as a new set": "另存為新關鍵字集",
"Recent Keywords is built in: choose another name": "「最近使用的關鍵字」為內建集:請另選名稱"
}

View File

@ -52,6 +52,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;

View File

@ -91,6 +91,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",
@ -780,6 +781,44 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
crate::widgets::register(ui.ctx(), id, r.rect);
}
}
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"))
.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());
// 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))
.width(w)
.show(ui);
returned |= r.ending == Some(crate::text_field::Ending::Return);
if i % 3 == 2 {
ui.end_row();
}
}
});
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;
}
}
Dialog::MoveKeyword { keyword, parent } => {
let name = keyword.rsplit('|').next().unwrap_or(keyword);
match parent {
@ -914,6 +953,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",
@ -967,6 +1007,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
| Dialog::NewSmartAlbum { .. }
| Dialog::SmartRules { .. }
| Dialog::KeywordTag { .. }
| Dialog::KeywordSet { .. }
) =>
{
app.toast(ctx, e)
@ -1212,6 +1253,29 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
}
Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})),
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());
}
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)
}
Dialog::MoveKeyword { keyword, parent } => {
let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true}));
if r.is_ok() {

View File

@ -598,6 +598,13 @@ 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",
"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"] {

View File

@ -907,7 +907,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(&current, current == lightcraft_engine::cmd::keywords::RECENT))
.show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
@ -920,6 +920,21 @@ 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<String> =
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,
as_new: false,
});
}
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(),
@ -936,6 +951,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<String> = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
if kws.is_empty() {
@ -945,6 +961,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;

View File

@ -596,6 +596,17 @@ 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<String>,
name: String,
slots: Vec<String>,
/// 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.
MoveKeyword {

View File

@ -0,0 +1,162 @@
//! 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::<Vec<_>>()), 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");
}
/// 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<egui::Rect> =
(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]);
}
}
/// 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");
}

View File

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

View File

@ -38,6 +38,11 @@
means no (it used to count as yes), and a value that is neither yes nor no is refused.
- Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.
### Keyword sets
- 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
keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from