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:
Nathaniel Ledford 2026-10-09 15:57:57 -04:00
parent 6b4839f5fc
commit 68c1e78c1b
No known key found for this signature in database
13 changed files with 219 additions and 8 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": "キーワードセットには名前が必要です"
}

View File

@ -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"
}

View File

@ -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": "Название набора"
}

View File

@ -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": "关键字集需要名称"
}

View File

@ -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": "關鍵字集需要名稱"
}

View File

@ -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;

View File

@ -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() {

View File

@ -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"] {

View File

@ -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(&current, 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;

View File

@ -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 {

View File

@ -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");
}