LRC-LIB-KEYWORDS: the Keyword List marks a keyword that has an applied keyword inside it

With `Places|Italy|Rome` on the selected photos, the boxes of `Places`
and `Italy` were empty: a folded keyword gave no sign that something in
it was applied.

A keyword whose own box is empty now carries a dot in it when the
selection has a keyword inside it, at any depth, and the box's tooltip
names what is inside ("Inside: Rome, Venice", four by name, then a
count). The tick and the dash stay about the keyword itself and come
first: a keyword the photos have themselves shows that, not the dot. A
click on the box does what it did.

What is inside each keyword is worked out with the ticks, once per
selection (`Ticks::inside`), not per row and frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-22 07:08:40 -04:00
co-authored by Claude Opus 5.5
parent c66014e65f
commit c2145d176e
21 changed files with 146 additions and 8 deletions
+1
View File
@@ -253,6 +253,7 @@
"new, inside {parent}": "nové, pod {parent}",
"Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.",
"Inside: {names}": "Uvnitř: {names}",
" in {g} group{}": " ve skupinách: {g}{:.0}",
" · kept, not applied: {}": " · zachováno, nepoužito: {}",
" — kept but not applied: {}": " — zachováno, ale nepoužito: {}",
+1
View File
@@ -242,6 +242,7 @@
"new, inside {parent}": "neu, in {parent}",
"Keywords of {n} selected photos": "Stichwörter von {n} ausgewählten Fotos",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "„{name}“ von {n} Fotos entfernt. Bearbeiten → Rückgängig stellt es wieder her.",
"Inside: {names}": "Darin: {names}",
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.",
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
+1
View File
@@ -242,6 +242,7 @@
"new, inside {parent}": "nueva, dentro de {parent}",
"Keywords of {n} selected photos": "Palabras clave de {n} fotos seleccionadas",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” quitada de {n} fotos. Editar → Deshacer la devuelve.",
"Inside: {names}": "Dentro: {names}",
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.",
"{what} merge cancelled": "Combinación {what} cancelada",
+1
View File
@@ -102,6 +102,7 @@
"Imported {} photo{}": "Tuotuja kuvia: {}{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Jonossa tekoälykohinanpoistoon ({ahead} edellä)…",
"Insert at the cursor (also written {})": "Lisää kohdistimen kohtaan (kirjoitetaan myös {})",
"Inside: {names}": "Sisällä: {names}",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se poistetaan {count} kuvalta{:.0}, alla olevat avainsanat mukaan lukien. Kumoa palauttaa sen.",
"Keywords of {n} selected photos": "Valittujen kuvien ({n}) avainsanat",
"Label Set: {name}": "Merkintäjoukko: {name}",
+1
View File
@@ -242,6 +242,7 @@
"new, inside {parent}": "nouveau, dans {parent}",
"Keywords of {n} selected photos": "Mots-clés de {n} photos sélectionnées",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "« {name} » retiré de {n} photos. Modifier → Annuler le rétablit.",
"Inside: {names}": "À l’intérieur : {names}",
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.",
"{what} merge cancelled": "Fusion {what} annulée",
+2 -1
View File
@@ -297,5 +297,6 @@
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Inside: {names}": "Di dalam: {names}"
}
+1
View File
@@ -283,6 +283,7 @@
"Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?",
"{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"Inside: {names}": "Al suo interno: {names}",
"Template preview: {name}": "Anteprima del modello: {name}",
"{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}",
"Applied {name}": "{name} applicato",
+1
View File
@@ -246,6 +246,7 @@
"new, inside {parent}": "新規、{parent} の中",
"Keywords of {n} selected photos": "選択した写真{n}枚のキーワード",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "「{name}」を写真{n}枚から削除しました。「編集」→「取り消す」で元に戻せます。",
"Inside: {names}": "この中: {names}",
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。",
"{what} merge cancelled": "{what}合成をキャンセルしました",
+1
View File
@@ -268,6 +268,7 @@
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.",
"Inside: {names}": "შიგნით: {names}",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"Saved template {name}": "Saved template {name}",
"Skipped {problem}": "Skipped {problem}",
+1
View File
@@ -285,6 +285,7 @@
"new, inside {parent}": "nowe, wewnątrz {parent}",
"Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.",
"Inside: {names}": "Wewnątrz: {names}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.",
@@ -246,6 +246,7 @@
"new, inside {parent}": "nova, dentro de {parent}",
"Keywords of {n} selected photos": "Palavras-chave de {n} fotos selecionadas",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” removida de {n} fotos. Editar → Desfazer a traz de volta.",
"Inside: {names}": "Dentro: {names}",
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.",
"{what} merge cancelled": "{what} mesclagem cancelada",
+1
View File
@@ -242,6 +242,7 @@
"new, inside {parent}": "новое, внутри {parent}",
"Keywords of {n} selected photos": "Ключевые слова выбранных фото: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» убрано с фото: {n}. Правка → Отменить вернёт его.",
"Inside: {names}": "Внутри: {names}",
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.",
"{what} merge cancelled": "{what}: слияние отменено",
+2 -1
View File
@@ -297,5 +297,6 @@
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}"
"Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}",
"Inside: {names}": "Inuti: {names}"
}
+1
View File
@@ -242,6 +242,7 @@
"new, inside {parent}": "нове, всередині {parent}",
"Keywords of {n} selected photos": "Ключові слова вибраних фото: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» вилучено з фото: {n}. Редагування → Скасувати поверне його.",
"Inside: {names}": "Усередині: {names}",
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.",
"{what} merge cancelled": "Об’єднання {what} скасовано",
+1
View File
@@ -102,6 +102,7 @@
"Imported {} photo{}": "Đã nhập {} ảnh{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Đang xếp hàng Khử nhiễu AI (trước bạn: {ahead})…",
"Insert at the cursor (also written {})": "Chèn tại con trỏ (cũng ghi là {})",
"Inside: {names}": "Bên trong: {names}",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Sẽ gỡ khỏi {count} ảnh{:.0}, kèm từ khóa con. Hoàn tác sẽ khôi phục.",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"Label Set: {name}": "Bộ nhãn: {name}",
@@ -246,6 +246,7 @@
"new, inside {parent}": "新关键字,位于 {parent} 内",
"Keywords of {n} selected photos": "所选 {n} 张照片的关键字",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已从 {n} 张照片中移除“{name}”。编辑 → 撤销可恢复。",
"Inside: {names}": "其中:{names}",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。",
"{what} merge cancelled": "已取消 {what} 合并",
@@ -246,6 +246,7 @@
"new, inside {parent}": "新關鍵字,位於 {parent} 內",
"Keywords of {n} selected photos": "選取的 {n} 張照片的關鍵字",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已從 {n} 張照片移除「{name}」。編輯 → 復原可恢復。",
"Inside: {names}": "其中:{names}",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。",
"{what} merge cancelled": "已取消{what}合併",
+85 -5
View File
@@ -202,6 +202,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
}
// the tick box: does the selection have it?
let state = ticks.tick(&r.path);
let inside = ticks.inside(&r.path);
let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0));
let on = !selection.is_empty();
// (it senses drags too, so a press there that moves doesn't pick the keyword up)
@@ -222,14 +223,27 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
}
Tick::No => {
ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside);
// the selection has a keyword inside this one: a dot in the empty box, so a folded
// keyword shows that it holds something applied (a shape of its own beside the tick
// and the dash, which are about the keyword itself)
if !inside.is_empty() {
let dot = Rect::from_center_size(boxr.center(), vec2(5.0, 5.0));
ui.painter().circle_filled(dot.center(), 2.5, t.text);
register(ui.ctx(), format!("keywordInside:{}", r.path), dot);
}
}
}
// (with nothing selected it does nothing, and says nothing)
let tb = if on {
tb.on_hover_text(crate::i18n::tr(match state {
let does = crate::i18n::tr(match state {
Tick::All => "Remove from Selected Photos",
_ => "Add to Selected Photos",
}))
});
// what is inside comes first: it is what the box shows
match inside_said(inside) {
Some(said) => tb.on_hover_text(format!("{said}\n{does}")),
None => tb.on_hover_text(does),
}
} else {
tb
};
@@ -275,6 +289,17 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
crate::widgets::context_menu(&resp, |ui| menu(app, ui, &r.path, selection));
}
/// How many of the keywords inside one are named before the rest are counted.
const INSIDE_NAMED: usize = 4;
/// "Inside: Rome, Venice": the applied keywords inside a keyword, the first few by name.
fn inside_said(inside: &[String]) -> Option<String> {
let named = inside.iter().take(INSIDE_NAMED).map(String::as_str).collect::<Vec<_>>().join(", ");
let more = inside.len().saturating_sub(INSIDE_NAMED);
let names = if more > 0 { format!("{named} {}", crate::i18n::tr_format!("+{n} more", n = more)) } else { named };
(!inside.is_empty()).then(|| crate::i18n::tr_format!("Inside: {names}", names = names))
}
/// A row while a keyword or photos are dragged: outlined under the pointer when the drop would
/// do something (a keyword never goes inside itself), and a release there does it: nests the
/// keyword inside this one, or gives this keyword to the photos.
@@ -485,23 +510,51 @@ pub(crate) struct Ticks {
photos: usize,
/// Photos with it, by cleaned lower-case keyword.
counts: std::collections::HashMap<String, usize>,
/// The applied keywords inside each keyword (by cleaned lower-case keyword): their own names,
/// in order, each once. What a folded keyword's box says of what it holds.
inside: std::collections::HashMap<String, Vec<String>>,
}
impl Ticks {
pub(crate) fn of(catalog: &Catalog, photos: &[PhotoId]) -> Ticks {
let photos = super::keywording::distinct(catalog, photos);
let mut counts: std::collections::HashMap<String, usize> = Default::default();
// (each keyword as it was first written, to name it by)
let mut shown: std::collections::HashMap<String, String> = Default::default();
let mut seen = std::collections::HashSet::new();
for p in &photos {
seen.clear();
for k in &p.meta.keywords {
let key = lightcraft_catalog::keywords::clean(k).to_lowercase();
let clean = lightcraft_catalog::keywords::clean(k);
let key = clean.to_lowercase();
if seen.insert(key.clone()) {
*counts.entry(key).or_default() += 1;
*counts.entry(key.clone()).or_default() += 1;
shown.entry(key).or_insert(clean);
}
}
}
Ticks { photos: photos.len(), counts }
// every keyword above an applied one holds it: worked out here, once per selection, from the
// keywords the selection has (not per row and frame)
let mut inside: std::collections::HashMap<String, Vec<String>> = Default::default();
let mut applied: Vec<(&String, &String)> = shown.iter().collect();
applied.sort();
for (key, as_written) in applied {
let name = as_written.rsplit('|').next().unwrap_or(as_written);
for (at, _) in key.match_indices('|') {
let Some(above) = key.get(..at) else { continue };
let names = inside.entry(above.to_string()).or_default();
if !names.iter().any(|n| same(n, name)) {
names.push(name.to_string());
}
}
}
Ticks { photos: photos.len(), counts, inside }
}
/// The applied keywords inside `path` (their own names): what the selection has of the
/// keywords below it, at any depth. Empty for a keyword with nothing applied inside.
pub(crate) fn inside(&self, path: &str) -> &[String] {
self.inside.get(&lightcraft_catalog::keywords::clean(path).to_lowercase()).map_or(&[], Vec::as_slice)
}
/// The tick box of `path`: a photo counts when it has the keyword itself (any case); one with
@@ -590,6 +643,33 @@ mod tests {
assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some);
}
/// Given photos that have keywords inside other keywords, when the list is asked what is
/// inside a keyword for the selection, then every keyword above an applied one names it (by
/// its own name, once, in order), whichever case it was written in and however deep; a keyword
/// with nothing applied inside, a keyword that is itself the applied one, and an empty
/// selection name nothing.
#[test]
fn a_keyword_names_the_applied_keywords_inside_it() {
let (c, ids) = library(&[&["Travel|Italy|Rome", "beach"], &["travel|italy|venice", "travel|Spain"], &["travel|italy|ROME"]]);
let all = Ticks::of(&c, &ids);
assert_eq!(all.inside("travel"), ["Italy|Rome", "Italy|venice", "Spain"].map(|k| k.rsplit('|').next().unwrap()));
assert_eq!(all.inside("Travel|Italy"), ["Rome", "venice"]);
assert_eq!(all.inside("travel|italy|rome"), [] as [&str; 0], "nothing is inside the applied keyword itself");
assert_eq!(all.inside("travel|spain"), [] as [&str; 0]);
assert_eq!(all.inside("beach"), [] as [&str; 0]);
assert_eq!(all.inside("weddings"), [] as [&str; 0], "a keyword no photo has anything of");
// the selection decides: the third photo alone has nothing of Spain or Venice
assert_eq!(Ticks::of(&c, &ids[2..]).inside("travel"), ["ROME"]);
assert_eq!(Ticks::of(&c, &[]).inside("travel"), [] as [&str; 0]);
// a keyword applied itself and with one inside: the tick is its own, and what is inside is still named
let (c, ids) = library(&[&["travel", "travel|Italy"]]);
let t = Ticks::of(&c, &ids);
assert_eq!((t.tick("travel"), t.inside("travel")), (Tick::All, &["Italy".to_string()][..]));
// a name that only starts like another is not inside it
let (c, ids) = library(&[&["travelling|Rome"]]);
assert_eq!(Ticks::of(&c, &ids).inside("travel"), [] as [&str; 0]);
}
/// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of
/// a literal search) are translated in every language.
#[test]
+32
View File
@@ -813,3 +813,35 @@ fn open_dialogs_count_once_per_library_change() {
}
assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once");
}
/// Given a selection with a keyword that is inside others, when the Keyword List is shown, then
/// every keyword above it carries a dot in its box (also folded, where the keyword itself is out
/// of sight), and its own box is the one that is ticked. A keyword above it that the selection
/// has itself shows its tick and no dot; a keyword with nothing applied inside shows neither.
#[test]
fn a_keyword_shows_that_one_inside_it_is_applied() {
let (mut h, ids) = two_selected();
ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["travel|Italy|Rome"]}}));
h.settle(SETTLE);
// folded: `travel` is the only row of the three, and says there is something inside
assert!(has(&h, "keywordRow:travel") && !has(&h, "keywordRow:travel|Italy"), "folded");
assert!(has(&h, "keywordInside:travel"));
assert!(!has(&h, "keywordInside:beach") && !has(&h, "keywordInside:Weddings"), "nothing is inside these");
// the dot sits in the keyword's own box
let rect = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap();
assert!(rect(&h, "keywordCheck:travel").contains_rect(rect(&h, "keywordInside:travel")));
// opened down to it: each level above has the dot, the keyword itself the tick
h.app.ui.keyword_list_open.extend(["travel".to_string(), "travel|italy".to_string()]);
h.settle(SETTLE);
assert!(has(&h, "keywordInside:travel") && has(&h, "keywordInside:travel|Italy"));
assert!(has(&h, "keywordRow:travel|Italy|Rome") && !has(&h, "keywordInside:travel|Italy|Rome"));
// a keyword the selection has itself shows its own state: the tick, or the dash when only some photos have it
ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["travel"]}}));
ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "addKeywords": ["travel|Italy"]}}));
h.settle(SETTLE);
assert!(!has(&h, "keywordInside:travel") && !has(&h, "keywordInside:travel|Italy"));
// with nothing selected nothing is applied, inside or not
ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": []}}));
h.settle(SETTLE);
assert!(has(&h, "keywordRow:travel") && !h.app.widgets.iter().any(|(w, _)| w.starts_with("keywordInside:")));
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -386,6 +386,14 @@
on the top bar it comes back zoomed. The other way round, a window you drag out until it is as wide as the
screen and nearly as tall counts as zoomed.
- For scripts: a size set with `ui.resize` is remembered like any other, so a script that needs a size sets it.
### Keyword List: a keyword shows that one inside it is applied
- In the Keyword List (right panel ▸ Keywords), a keyword whose box is empty now carries a dot in it when the
selected photos have a keyword inside it, at any depth: with `Places › Italy › Rome` on a photo, `Places` and
`Italy` show the dot and `Rome` the tick. A folded keyword no longer hides that something in it is applied.
Resting on the box names what is inside.
- The tick and the dash are as they were, and are about the keyword itself: all of the selected photos have it, or
some do. A keyword the photos have themselves shows that, not the dot. A click on the box still gives the
keyword itself to the selected photos.
### HDR merges keep the lens correction
- An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's