From 41386353e9776579301b4d8317c8517ff67d6240 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:25:02 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: create, edit and delete keywords from the Keyword List MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The + button opens Create Keyword Tag: a name, synonyms, "Put inside" the keyword picked in the list (or the default parent), "Add to Selected Photos", and the keyword tag options (Include on Export, Export Containing Keywords, Export Synonyms, Person). Return creates it; a name that is taken says so and keeps the dialog open. - Double-clicking a keyword opens Edit Keyword Tag with its name and attributes; a new name and options apply in one undo step. - The − button deletes the picked keyword after saying how many photos lose it. The name takes the focus once when the dialog opens: the one-field dialogs' `dialog_field` takes it back every frame, which would steal it from Synonyms. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 5 +- crates/ui-egui/locales/de.json | 13 ++- crates/ui-egui/locales/es-formats.json | 5 +- crates/ui-egui/locales/es.json | 13 ++- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 13 ++- crates/ui-egui/locales/pt-br-formats.json | 5 +- crates/ui-egui/locales/pt-br.json | 13 ++- crates/ui-egui/locales/ru-formats.json | 5 +- crates/ui-egui/locales/ru.json | 13 ++- crates/ui-egui/locales/zh-hans-formats.json | 5 +- crates/ui-egui/locales/zh-hans.json | 13 ++- crates/ui-egui/locales/zh-hant-formats.json | 5 +- crates/ui-egui/locales/zh-hant.json | 13 ++- crates/ui-egui/src/panels/dialogs.rs | 121 +++++++++++++++++++- crates/ui-egui/src/panels/keyword_list.rs | 70 ++++++++++- crates/ui-egui/src/state.rs | 24 ++++ crates/ui-egui/src/tests_keyword_list.rs | 79 +++++++++++++ 18 files changed, 403 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 12672394..6babc6e9 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index b2caeff5..1cfc9bca 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1893,5 +1893,16 @@ "Keyword List": "Stichwortliste", "Filter Keywords": "Stichwörter filtern", "No keywords yet": "Noch keine Stichwörter", - "No keywords match": "Keine passenden Stichwörter" + "No keywords match": "Keine passenden Stichwörter", + "Keyword Name": "Stichwortname", + "Synonyms": "Synonyme", + "Separated by commas": "Durch Kommas getrennt", + "Keyword Tag Options": "Stichwort-Optionen", + "Include on Export": "Beim Export einschließen", + "Export Containing Keywords": "Übergeordnete Stichwörter exportieren", + "Export Synonyms": "Synonyme exportieren", + "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." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 54418c76..da9c8543 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 588575c4..d368e72e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1738,5 +1738,16 @@ "Keyword List": "Lista de palabras clave", "Filter Keywords": "Filtrar palabras clave", "No keywords yet": "Aún no hay palabras clave", - "No keywords match": "Ninguna palabra clave coincide" + "No keywords match": "Ninguna palabra clave coincide", + "Keyword Name": "Nombre de la palabra clave", + "Synonyms": "Sinónimos", + "Separated by commas": "Separados por comas", + "Keyword Tag Options": "Opciones de palabra clave", + "Include on Export": "Incluir al exportar", + "Export Containing Keywords": "Exportar palabras clave contenedoras", + "Export Synonyms": "Exportar sinónimos", + "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." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index b0e0f2f9..fb5c8bbf 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index f6504989..c13be07f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1738,5 +1738,16 @@ "Keyword List": "キーワードリスト", "Filter Keywords": "キーワードを絞り込む", "No keywords yet": "キーワードはまだありません", - "No keywords match": "一致するキーワードはありません" + "No keywords match": "一致するキーワードはありません", + "Keyword Name": "キーワード名", + "Synonyms": "同義語", + "Separated by commas": "カンマ区切り", + "Keyword Tag Options": "キーワードタグのオプション", + "Include on Export": "書き出し時に含める", + "Export Containing Keywords": "親キーワードを書き出す", + "Export Synonyms": "同義語を書き出す", + "Create Keyword Tag": "キーワードタグを作成", + "Edit Keyword Tag": "キーワードタグを編集", + "Create": "作成", + "No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index ab64e082..23c3c759 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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." } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index eda58e98..72843372 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1738,5 +1738,16 @@ "Keyword List": "Lista de palavras-chave", "Filter Keywords": "Filtrar palavras-chave", "No keywords yet": "Ainda não há palavras-chave", - "No keywords match": "Nenhuma palavra-chave corresponde" + "No keywords match": "Nenhuma palavra-chave corresponde", + "Keyword Name": "Nome da palavra-chave", + "Synonyms": "Sinônimos", + "Separated by commas": "Separados por vírgulas", + "Keyword Tag Options": "Opções da palavra-chave", + "Include on Export": "Incluir na exportação", + "Export Containing Keywords": "Exportar palavras-chave que a contêm", + "Export Synonyms": "Exportar sinônimos", + "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." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f3a0f647..12ab6b54 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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} вместе с вложенными ключевыми словами. Отмена вернёт его." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3aaf92c5..c8aa367f 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1411,5 +1411,16 @@ "Keyword List": "Список ключевых слов", "Filter Keywords": "Фильтр ключевых слов", "No keywords yet": "Ключевых слов пока нет", - "No keywords match": "Нет подходящих ключевых слов" + "No keywords match": "Нет подходящих ключевых слов", + "Keyword Name": "Название ключевого слова", + "Synonyms": "Синонимы", + "Separated by commas": "Через запятую", + "Keyword Tag Options": "Параметры ключевого слова", + "Include on Export": "Включать при экспорте", + "Export Containing Keywords": "Экспортировать родительские ключевые слова", + "Export Synonyms": "Экспортировать синонимы", + "Create Keyword Tag": "Создать ключевое слово", + "Edit Keyword Tag": "Изменить ключевое слово", + "Create": "Создать", + "No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e32c7f66..cbefa4c7 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0cbba6d5..68450643 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1738,5 +1738,16 @@ "Keyword List": "关键字列表", "Filter Keywords": "过滤关键字", "No keywords yet": "还没有关键字", - "No keywords match": "没有匹配的关键字" + "No keywords match": "没有匹配的关键字", + "Keyword Name": "关键字名称", + "Synonyms": "同义词", + "Separated by commas": "用逗号分隔", + "Keyword Tag Options": "关键字标记选项", + "Include on Export": "导出时包含", + "Export Containing Keywords": "导出包含的关键字", + "Export Synonyms": "导出同义词", + "Create Keyword Tag": "创建关键字标记", + "Edit Keyword Tag": "编辑关键字标记", + "Create": "创建", + "No photo has it; the keywords below it go too.": "没有照片使用它;其下的关键字也会一并删除。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 182a7dc6..501c6f17 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {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}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d45c8371..5fb1f4a0 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1738,5 +1738,16 @@ "Keyword List": "關鍵字清單", "Filter Keywords": "篩選關鍵字", "No keywords yet": "尚無關鍵字", - "No keywords match": "沒有符合的關鍵字" + "No keywords match": "沒有符合的關鍵字", + "Keyword Name": "關鍵字名稱", + "Synonyms": "同義詞", + "Separated by commas": "以逗號分隔", + "Keyword Tag Options": "關鍵字標記選項", + "Include on Export": "轉存時包含", + "Export Containing Keywords": "轉存包含的關鍵字", + "Export Synonyms": "轉存同義詞", + "Create Keyword Tag": "建立關鍵字標記", + "Edit Keyword Tag": "編輯關鍵字標記", + "Create": "建立", + "No photo has it; the keywords below it go too.": "沒有照片使用它;其下的關鍵字也會一併刪除。" } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 2473996a..766cd772 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -82,6 +82,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", + Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag", + Dialog::KeywordTag { .. } => "Edit Keyword Tag", + Dialog::DeleteKeyword { .. } => "Delete Keyword", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", @@ -711,6 +714,75 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { opts } => crate::import::body(app, ui, opts), Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), + Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + } => { + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); + // several fields: the name takes the focus when the dialog opens, not after + let r = crate::text_field::TextField::singleline("field:keywordTagName", name) + .hint(crate::i18n::tr("Keyword Name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if just_opened(ui, "keyword-tag") { + r.response.request_focus(); + } + if r.ending == Some(crate::text_field::Ending::Return) { + confirm = true; + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); + crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) + .hint(crate::i18n::tr("Separated by commas")) + .width(f32::INFINITY) + .show(ui); + ui.add_space(6.0); + if editing.is_none() { + if let Some(p) = parent { + let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); + crate::widgets::register(ui.ctx(), "check:keywordInside", r.rect); + } + let n = app.session.selection.ids.len(); + let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); + crate::widgets::register(ui.ctx(), "check:keywordAddToSelected", r.rect); + ui.add_space(4.0); + } + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); + for (on, label, id) in [ + (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), + (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), + (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), + (person, "Person", "check:keywordPerson"), + ] { + let r = ui.checkbox(on, crate::i18n::tr(label)); + crate::widgets::register(ui.ctx(), id, r.rect); + } + } + Dialog::DeleteKeyword { keyword, count } => { + let name = keyword.replace('|', " › "); + ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); + ui.label( + egui::RichText::new(if *count == 0 { + crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() + } else { + crate::i18n::tr_format!( + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", + if *count == 1 { "" } else { "s" }, + count = count + ) + }) + .color(t.text_dim), + ); + } Dialog::ConfirmDelete { count } => { let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); @@ -814,7 +886,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::DenoiseModel { .. } => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } => "Delete", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::KeywordTag { editing: None, .. } => "Create", + Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", @@ -861,6 +935,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } + | Dialog::KeywordTag { .. } ) => { app.toast(ctx, e) @@ -1053,6 +1128,40 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("photo.rename", json!({"template": template, "start": start})), Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + } => { + let mut params = json!({ + "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), + "includeOnExport": include_on_export, + "exportContaining": export_containing, + "exportSynonyms": export_synonyms, + "person": person, + }); + match editing { + Some(keyword) => { + params["keyword"] = json!(keyword); + params["name"] = json!(name); + app.run("keyword.edit", params) + } + None => { + params["name"] = json!(name); + params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null }; + params["addToSelected"] = json!(add_to_selected); + app.run("keyword.create", params) + } + } + } + Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), @@ -1295,3 +1404,13 @@ fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) } r.ending == Some(crate::text_field::Ending::Return) } + +/// The dialog part `key` is drawn this frame but wasn't the frame before: the dialog has just +/// opened (to give its first field the focus once). +fn just_opened(ui: &egui::Ui, key: &str) -> bool { + let id = egui::Id::new(("dialog-shown", key)); + let pass = ui.ctx().cumulative_pass_nr(); + let last: Option = ui.data_mut(|d| d.get_temp(id)); + ui.data_mut(|d| d.insert_temp(id, pass)); + last.and_then(|l| l.checked_add(1)) != Some(pass) +} diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 4df3bb6b..ac12459f 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -3,11 +3,12 @@ //! keyword gives it to the selected photos or takes it away; the arrow shows the photos with it. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use lightcraft_catalog::keywords::{KeywordNode, same}; +use lightcraft_catalog::keywords::{KeywordInfo, KeywordNode, same}; use lightcraft_catalog::{Catalog, PhotoId}; use serde_json::json; use crate::LightcraftApp; +use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::register; @@ -26,8 +27,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(24.0); crate::text_field::TextField::singleline("field:keywordFilter", &mut filter) .hint(crate::i18n::tr("Filter Keywords")) - .width(ui.available_width() - 22.0) + .width(ui.available_width() - 82.0) .show(ui); + // + creates a keyword (inside the picked one), − deletes the picked one + let plus = ui.button("+").on_hover_text(crate::i18n::tr("Create Keyword Tag")); + register(ui.ctx(), "keywordList:create", plus.rect); + if plus.clicked() { + app.ui.dialog = Some(create_dialog(app)); + } + let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)); + let minus = ui.add_enabled(picked.is_some(), egui::Button::new("−")).on_hover_text(crate::i18n::tr("Delete Keyword")); + register(ui.ctx(), "keywordList:delete", minus.rect); + if minus.clicked() + && let Some(k) = picked + { + app.ui.dialog = Some(delete_dialog(app, &k)); + } }); ui.data_mut(|d| d.insert_temp(fid, filter.clone())); ui.add_space(6.0); @@ -139,6 +154,57 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if resp.clicked() { app.ui.keyword_list_selected = Some(r.path.clone()); } + if resp.double_clicked() { + app.ui.dialog = Some(edit_dialog(app, &r.path)); + } +} + +/// Create Keyword Tag: inside the keyword picked in the list, else the default parent. +pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog { + let parent = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.keyword_parent.clone()); + let d = KeywordInfo::default(); + Dialog::KeywordTag { + editing: None, + name: String::new(), + inside: parent.is_some(), + parent, + synonyms: String::new(), + include_on_export: d.include_on_export, + export_containing: d.export_containing, + export_synonyms: d.export_synonyms, + person: d.person, + add_to_selected: false, + } +} + +/// Edit Keyword Tag for `path`, showing its name and attributes. +pub(crate) fn edit_dialog(app: &LightcraftApp, path: &str) -> Dialog { + let path = app.session.catalog.keyword_path(path).unwrap_or_else(|| path.to_string()); + let info = app.session.catalog.keyword_info(&path).cloned().unwrap_or_default(); + Dialog::KeywordTag { + name: path.rsplit('|').next().unwrap_or(&path).to_string(), + editing: Some(path), + parent: None, + inside: false, + synonyms: info.synonyms.join(", "), + include_on_export: info.include_on_export, + export_containing: info.export_containing, + export_synonyms: info.export_synonyms, + person: info.person, + add_to_selected: false, + } +} + +/// Delete Keyword, saying how many photos have it. +pub(crate) fn delete_dialog(app: &LightcraftApp, path: &str) -> Dialog { + let keyword = app.session.catalog.keyword_path(path).unwrap_or_else(|| path.to_string()); + let count = app + .session + .catalog + .photos() + .filter(|p| p.in_library() && p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, &keyword))) + .count(); + Dialog::DeleteKeyword { keyword, count } } /// One row of the Keyword List as drawn. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8974fc30..373ecfd3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -562,6 +562,30 @@ pub enum Dialog { from: String, to: String, }, + /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. + KeywordTag { + /// The keyword being edited; `None`: a new one. + editing: Option, + name: String, + /// A new keyword goes inside this one when `inside` is on (the keyword picked in the + /// list, or the default parent). + parent: Option, + inside: bool, + /// Comma-separated. + synonyms: String, + include_on_export: bool, + export_containing: bool, + export_synonyms: bool, + person: bool, + /// A new keyword is given to the selected photos. + add_to_selected: bool, + }, + /// Delete a keyword from every photo and the keyword list, after asking. + DeleteKeyword { + keyword: String, + /// Photos that have it (or one below it). + count: usize, + }, /// Merge keywords into another one on every photo. MergeKeywords { from: Vec, diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index b0294dc1..9e08dcf3 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -98,3 +98,82 @@ fn the_arrow_shows_the_photos_with_the_keyword() { ask(&mut h, "ui.clickWidget", json!({"id": "keywordShow:mountains"})); assert_eq!(h.app.session.filter.keyword.as_deref(), Some("mountains")); } + +fn find(h: &mut Headless, text: &str) { + ask(h, "ui.clickWidget", json!({"id": "field:keywordFilter"})); + ask(h, "ui.key", json!({"key": "A", "cmd": true})); + ask(h, "ui.text", json!({"text": text})); +} + +fn info(h: &mut Headless, keyword: &str) -> serde_json::Value { + h.app.session.execute("keyword.info", &json!({"keyword": keyword})).unwrap_or(serde_json::Value::Null) +} + +/// The + button creates a keyword (Create Keyword Tag): inside the keyword picked in the list, with +/// synonyms, and given to the selected photos when asked; Return creates it. +#[test] +fn plus_creates_a_keyword_inside_the_picked_one() { + let (mut h, ids) = keywords_panel(); + find(&mut h, "mountains"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:mountains"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:create"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { editing: None, .. })), "{:?}", h.app.ui.dialog); + ask(&mut h, "ui.text", json!({"text": "Alps"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordSynonyms"})); + ask(&mut h, "ui.text", json!({"text": "peaks, summits"})); + ask(&mut h, "ui.clickWidget", json!({"id": "check:keywordAddToSelected"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordTagName"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert_eq!(h.app.ui.dialog, None); + let i = info(&mut h, "mountains|Alps"); + assert_eq!((i["listed"].as_bool(), i["synonyms"].clone()), (Some(true), json!(["peaks", "summits"])), "{i}"); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"mountains|Alps".to_string())), "given to the selection"); +} + +/// Double-clicking a keyword edits it (Edit Keyword Tag): the dialog shows what it is, and a new +/// name and an option change apply together, in one undo step. +#[test] +fn double_clicking_a_keyword_edits_it() { + let (mut h, _) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Weddings", "synonyms": ["marriage"]})); + find(&mut h, "wedd"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Weddings", "count": 2})); + let Some(crate::state::Dialog::KeywordTag { editing, name, synonyms, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((editing.as_deref(), name.as_str(), synonyms.as_str()), (Some("Weddings"), "Weddings", "marriage")); + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.text", json!({"text": "Marriages"})); + ask(&mut h, "ui.clickWidget", json!({"id": "check:keywordIncludeOnExport"})); + ask(&mut h, "ui.dialog.confirm", json!({})); + let i = info(&mut h, "Marriages"); + assert_eq!((i["includeOnExport"].as_bool(), i["synonyms"].clone()), (Some(false), json!(["marriage"])), "{i}"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step"); +} + +/// The − button deletes the picked keyword after asking how many photos lose it; Cancel keeps it, and undo +/// brings a deleted one back. +#[test] +fn minus_deletes_a_keyword_after_asking() { + let (mut h, _) = keywords_panel(); + find(&mut h, "mountains"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:mountains"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:delete"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::DeleteKeyword { .. })), "{:?}", h.app.ui.dialog); + ask(&mut h, "ui.dialog.cancel", json!({})); + assert!(h.app.session.catalog.has_keyword("mountains"), "Cancel kept it"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:delete"})); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert!(!h.app.session.catalog.has_keyword("mountains")); + run(&mut h, "edit.undo", json!({})); + assert!(h.app.session.catalog.has_keyword("mountains")); +} + +/// Creating a keyword that exists says so, and the dialog stays open with what was typed. +#[test] +fn creating_a_keyword_that_exists_keeps_the_dialog() { + let (mut h, _) = keywords_panel(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:create"})); + ask(&mut h, "ui.text", json!({"text": "mountains"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + 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); +}