From f1c513c20ce9da2701726a25f97e2e7e2c1a75ee Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:27:22 -0400 Subject: [PATCH] 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 --- crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 6 ++- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 6 ++- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 6 ++- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 6 ++- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 6 ++- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 6 ++- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 6 ++- crates/ui-egui/src/panels/keyword_list.rs | 54 ++++++++++++++++++++- crates/ui-egui/src/tests_keyword_list.rs | 27 +++++++++++ 16 files changed, 129 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 6babc6e9e..9b42ac141 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -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…" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 1cfc9bcac..aa456e1e4 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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…" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index da9c85435..5be99a4af 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -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}”…" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index d368e72eb..476fc8e16 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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…" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index fb5c8bbf3..587a8f421 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -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}」の中にキーワードタグを作成…" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c13be07fd..f165ecf23 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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…": "キーワードを削除…" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 23c3c759f..4181dc253 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -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}”…" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 728433726..5da1ec6bd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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…" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 12ab6b544..6a64e0714 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -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}»…" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c8aa367f8..3ec2d67f5 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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…": "Удалить ключевое слово…" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index cbefa4c71..8034165b2 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -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}”中创建关键字标记…" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 684506432..b556d5fca 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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…": "删除关键字…" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 501c6f176..368fe1a90 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -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}」中建立關鍵字標記…" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 5fb1f4a01..8e76acca0 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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…": "刪除關鍵字…" } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index ac12459f2..e31d8e99e 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -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. diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 9e08dcf36..3b4b5668f 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -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"); +}