diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index dba70e1a..3d153d3c 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1915,5 +1915,9 @@ "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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 203c53f4..e1781e25 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1760,5 +1760,9 @@ "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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ed484a30..ad1b17a3 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1760,5 +1760,9 @@ "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": "キーワードセットには名前が必要です" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 43f3e614..4e4f4384 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1760,5 +1760,9 @@ "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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5da2cd67..49e7c8ce 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1434,5 +1434,10 @@ "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": "Название набора" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index bbced17d..7b4e3c7f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1760,5 +1760,9 @@ "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": "关键字集需要名称" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f2c7c654..274c6cf9 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1760,5 +1760,9 @@ "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": "關鍵字集需要名稱" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f02544f1..dfaff2c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,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; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 62cd8ca0..4ef9015f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -86,6 +86,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", @@ -772,6 +773,38 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), id, r.rect); } } + Dialog::KeywordSet { name, slots, .. } => { + 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()); + let w = ((ui.available_width() - 8.0) / 3.0).floor().max(60.0); + egui::Grid::new("keyword-set-slots").num_columns(3).spacing([4.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(); + } + } + }); + // 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 { @@ -906,6 +939,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", @@ -955,6 +989,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } | Dialog::KeywordTag { .. } + | Dialog::KeywordSet { .. } ) => { app.toast(ctx, e) @@ -1189,6 +1224,16 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.delete", json!({"keyword": keyword})), + Dialog::KeywordSet { replaces, name, slots } => { + if name.trim().is_empty() { + return Err(crate::i18n::tr("A keyword set needs a name").to_string()); + } + let mut params = json!({"name": name.trim(), "keywords": slots}); + if let Some(old) = replaces { + params["replace"] = json!(old); + } + 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() { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index e1eabd71..940f08dd 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -598,6 +598,10 @@ 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", ]; 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"] { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7f5aa841..eb26a7f0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -864,7 +864,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(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) .show_ui(ui, |ui| { for set in sets["sets"].as_array().into_iter().flatten() { @@ -877,6 +877,20 @@ 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 = + 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, + }); + } 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(), @@ -893,6 +907,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 = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect(); if kws.is_empty() { @@ -902,6 +917,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; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4e64a87c..86429f1e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -596,6 +596,14 @@ 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, + name: String, + slots: Vec, + }, /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: /// asks before merging the two. MoveKeyword { diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests_keyword_set.rs new file mode 100644 index 00000000..d0d69995 --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_set.rs @@ -0,0 +1,99 @@ +//! 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::>()), 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"); +}