LRC-LIB-KEYWORDS: a keyword's context menu in the Keyword List

Right-clicking a keyword offers Create Keyword Tag Inside it, Edit
Keyword Tag, Put New Keywords Inside This Keyword (the list marks that
keyword with a dot), Add to / Remove from Selected Photos, Show Photos
with Keyword, Purge Unused Keywords and Delete Keyword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 14:27:22 -04:00
co-authored by Claude Opus 5.5
parent c01eaadbf3
commit f1c513c20c
16 changed files with 129 additions and 15 deletions
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "In „{parent}“ einfügen",
"Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her."
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.",
"Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…"
}
+5 -1
View File
@@ -1904,5 +1904,9 @@
"Create Keyword Tag": "Stichwort erstellen",
"Edit Keyword Tag": "Stichwort bearbeiten",
"Create": "Erstellen",
"No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht."
"No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht.",
"Edit Keyword Tag…": "Stichwort bearbeiten…",
"Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen",
"Purge Unused Keywords": "Nicht verwendete Stichwörter löschen",
"Delete Keyword…": "Stichwort löschen…"
}
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "Poner dentro de “{parent}”",
"Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera."
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.",
"Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…"
}
+5 -1
View File
@@ -1749,5 +1749,9 @@
"Create Keyword Tag": "Crear palabra clave",
"Edit Keyword Tag": "Editar palabra clave",
"Create": "Crear",
"No photo has it; the keywords below it go too.": "Ninguna foto la tiene; las palabras clave que contiene también se eliminan."
"No photo has it; the keywords below it go too.": "Ninguna foto la tiene; las palabras clave que contiene también se eliminan.",
"Edit Keyword Tag…": "Editar palabra clave…",
"Put New Keywords Inside This Keyword": "Poner las nuevas palabras clave dentro de esta",
"Purge Unused Keywords": "Eliminar palabras clave no usadas",
"Delete Keyword…": "Eliminar palabra clave…"
}
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "「{parent}」の中に入れる",
"Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}"
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}",
"Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…"
}
+5 -1
View File
@@ -1749,5 +1749,9 @@
"Create Keyword Tag": "キーワードタグを作成",
"Edit Keyword Tag": "キーワードタグを編集",
"Create": "作成",
"No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。"
"No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。",
"Edit Keyword Tag…": "キーワードタグを編集…",
"Put New Keywords Inside This Keyword": "新しいキーワードをこのキーワードの中に入れる",
"Purge Unused Keywords": "使用されていないキーワードを削除",
"Delete Keyword…": "キーワードを削除…"
}
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "Colocar dentro de “{parent}”",
"Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta."
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.",
"Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…"
}
+5 -1
View File
@@ -1749,5 +1749,9 @@
"Create Keyword Tag": "Criar palavra-chave",
"Edit Keyword Tag": "Editar palavra-chave",
"Create": "Criar",
"No photo has it; the keywords below it go too.": "Nenhuma foto a tem; as palavras-chave abaixo dela também são excluídas."
"No photo has it; the keywords below it go too.": "Nenhuma foto a tem; as palavras-chave abaixo dela também são excluídas.",
"Edit Keyword Tag…": "Editar palavra-chave…",
"Put New Keywords Inside This Keyword": "Colocar novas palavras-chave dentro desta",
"Purge Unused Keywords": "Excluir palavras-chave não usadas",
"Delete Keyword…": "Excluir palavra-chave…"
}
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "Поместить в «{parent}»",
"Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его."
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.",
"Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…"
}
+5 -1
View File
@@ -1422,5 +1422,9 @@
"Create Keyword Tag": "Создать ключевое слово",
"Edit Keyword Tag": "Изменить ключевое слово",
"Create": "Создать",
"No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены."
"No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены.",
"Edit Keyword Tag…": "Изменить ключевое слово…",
"Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это",
"Purge Unused Keywords": "Удалить неиспользуемые ключевые слова",
"Delete Keyword…": "Удалить ключевое слово…"
}
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "放入“{parent}”中",
"Delete the keyword “{name}”?": "要删除关键字“{name}”吗?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}"
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}",
"Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…"
}
+5 -1
View File
@@ -1749,5 +1749,9 @@
"Create Keyword Tag": "创建关键字标记",
"Edit Keyword Tag": "编辑关键字标记",
"Create": "创建",
"No photo has it; the keywords below it go too.": "没有照片使用它;其下的关键字也会一并删除。"
"No photo has it; the keywords below it go too.": "没有照片使用它;其下的关键字也会一并删除。",
"Edit Keyword Tag…": "编辑关键字标记…",
"Put New Keywords Inside This Keyword": "将新关键字放入此关键字中",
"Purge Unused Keywords": "清除未使用的关键字",
"Delete Keyword…": "删除关键字…"
}
+2 -1
View File
@@ -209,5 +209,6 @@
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "放入「{parent}」中",
"Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}"
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}",
"Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…"
}
+5 -1
View File
@@ -1749,5 +1749,9 @@
"Create Keyword Tag": "建立關鍵字標記",
"Edit Keyword Tag": "編輯關鍵字標記",
"Create": "建立",
"No photo has it; the keywords below it go too.": "沒有照片使用它;其下的關鍵字也會一併刪除。"
"No photo has it; the keywords below it go too.": "沒有照片使用它;其下的關鍵字也會一併刪除。",
"Edit Keyword Tag…": "編輯關鍵字標記…",
"Put New Keywords Inside This Keyword": "將新關鍵字放入此關鍵字中",
"Purge Unused Keywords": "清除未使用的關鍵字",
"Delete Keyword…": "刪除關鍵字…"
}
+53 -1
View File
@@ -149,7 +149,13 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
let font = t.font(13.0);
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text_label).size().x;
let name = if measure(&r.name) <= room { r.name.clone() } else { crate::widgets::elide_head(&r.name, room, measure) };
ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label });
let label = ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label });
// where new keywords go (Put New Keywords Inside This Keyword): a dot after the name
if app.session.keyword_parent.as_deref().is_some_and(|k| same(k, &r.path)) {
let dot = Rect::from_center_size(pos2(label.right() + 7.0, cy), vec2(6.0, 6.0));
ui.painter().circle_filled(dot.center(), 3.0, t.accent);
register(ui.ctx(), format!("keywordDefault:{}", r.path), dot);
}
let resp = resp.on_hover_text(r.path.replace('|', " › "));
if resp.clicked() {
app.ui.keyword_list_selected = Some(r.path.clone());
@@ -157,6 +163,52 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
if resp.double_clicked() {
app.ui.dialog = Some(edit_dialog(app, &r.path));
}
resp.context_menu(|ui| menu(app, ui, &r.path, selection));
}
/// A keyword's context menu.
fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[PhotoId]) {
let name = path.replace('|', " › ");
let item = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool| {
let r = ui.add_enabled(enabled, egui::Button::new(label));
register(ui.ctx(), format!("keywordMenu:{id}"), r.rect);
r.clicked()
};
if item(ui, "create", &crate::i18n::tr_format!("Create Keyword Tag Inside “{name}”…", name = name), true) {
let mut d = create_dialog(app);
if let Dialog::KeywordTag { parent, inside, .. } = &mut d {
*parent = Some(path.to_string());
*inside = true;
}
app.ui.dialog = Some(d);
}
if item(ui, "edit", crate::i18n::tr("Edit Keyword Tag…"), true) {
app.ui.dialog = Some(edit_dialog(app, path));
}
let is_default = app.session.keyword_parent.as_deref().is_some_and(|k| same(k, path));
let label = format!("{}{}", if is_default { "✓ " } else { "" }, crate::i18n::tr("Put New Keywords Inside This Keyword"));
if item(ui, "defaultParent", &label, true) {
let _ = app.run("keyword.setDefaultParent", json!({"keyword": if is_default { serde_json::Value::Null } else { json!(path) }}));
}
ui.separator();
let some = !selection.is_empty();
if item(ui, "add", crate::i18n::tr("Add to Selected Photos"), some) {
let _ = app.run("photo.setMeta", json!({"addKeywords": [path]}));
}
if item(ui, "remove", crate::i18n::tr("Remove from Selected Photos"), some) {
let _ = app.run("photo.setMeta", json!({"removeKeywords": [path]}));
}
if item(ui, "show", crate::i18n::tr("Show Photos with Keyword"), true) {
super::left::browse_all_photos(app, true);
let _ = app.run("library.filter", json!({"keyword": path}));
}
ui.separator();
if item(ui, "purge", crate::i18n::tr("Purge Unused Keywords"), true) {
let _ = app.run("keyword.purgeUnused", json!({}));
}
if item(ui, "delete", crate::i18n::tr("Delete Keyword…"), true) {
app.ui.dialog = Some(delete_dialog(app, path));
}
}
/// Create Keyword Tag: inside the keyword picked in the list, else the default parent.
+27
View File
@@ -177,3 +177,30 @@ fn creating_a_keyword_that_exists_keeps_the_dialog() {
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { .. })), "still open");
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("already")), "{:?}", h.app.ui.toast);
}
/// A keyword's context menu: create a keyword inside it, edit it, make it where new keywords go
/// (marked in the list), and purge the keywords no photo has.
#[test]
fn a_keywords_menu_offers_its_actions() {
let (mut h, _) = keywords_panel();
run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"}));
find(&mut h, "events");
ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"}));
for item in ["create", "edit", "defaultParent", "purge", "delete"] {
assert!(has(&h, &format!("keywordMenu:{item}")), "{item}");
}
ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:defaultParent"}));
assert_eq!(h.app.session.keyword_parent.as_deref(), Some("Events"));
assert!(has(&h, "keywordDefault:Events"), "the list marks where new keywords go");
// Create inside it
ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"}));
ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:create"}));
let Some(crate::state::Dialog::KeywordTag { parent, inside, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) };
assert_eq!((parent.as_deref(), inside), (Some("Events"), true));
ask(&mut h, "ui.dialog.cancel", json!({}));
// Purge: Events|Weddings and Events have no photos
ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"}));
ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:purge"}));
assert!(!h.app.session.catalog.has_keyword("Events"));
assert_eq!(h.app.session.keyword_parent, None, "gone with it");
}