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); +}