LRC-LIB-KEYWORDS: Edit Keyword Set
The Keyword Set menu gets Edit Set… (Lightroom Classic's Edit Keyword Set): the set's name and its nine keywords, one field per ⌥ key, typed in place. Editing Recent Keywords and naming them saves a new set and makes it current; editing a named set renames it in its place. An empty field is an empty slot, shown as an idle button so the others keep their keys. Return in any field saves; a set without a name says so and the dialog stays open. Russian gets "Set name", and the label check covers the new labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6b4839f5fc
commit
68c1e78c1b
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "キーワードセットには名前が必要です"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Название набора"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "关键字集需要名称"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "關鍵字集需要名稱"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<serde_jso
|
|||
}
|
||||
}
|
||||
Dialog::DeleteKeyword { keyword, .. } => 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() {
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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<String> =
|
||||
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<String> = 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;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
name: String,
|
||||
slots: Vec<String>,
|
||||
},
|
||||
/// Move a keyword inside `parent` (`None`: the top level) where one of its name is already:
|
||||
/// asks before merging the two.
|
||||
MoveKeyword {
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()), 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");
|
||||
}
|
||||
Loading…
Reference in New Issue