Merge PR #1603: LRC-LIB-KEYWORDS: the Keyword List marks a keyword that has

# Conflicts:
#	crates/ui-egui/locales/id-formats.json
#	crates/ui-egui/locales/pl-formats.json
#	crates/ui-egui/locales/sv-formats.json
#	docs/parity-checklist.md
This commit is contained in:
francoisgn
2026-10-22 22:31:23 +03:00
21 changed files with 148 additions and 9 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
@@ -223,6 +223,7 @@
"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.",
"Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…",
"Inside: {names}": "Darin: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?",
"There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?",
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
+1
View File
@@ -223,6 +223,7 @@
"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.",
"Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…",
"Inside: {names}": "Dentro: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?",
"There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?",
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
+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
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Supprimer le mot-clé « {name} » ?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Il est retiré de {count} photo{}, avec les mots-clés qu’il contient. Annuler le rétablit.",
"Create Keyword Tag Inside “{name}”…": "Créer un mot-clé dans « {name} »…",
"Inside: {names}": "À l’intérieur : {names}",
"“{parent}” has a “{name}” already. Merge the two?": "« {parent} » contient déjà « {name} ». Fusionner les deux ?",
"There is a “{name}” at the top level already. Merge the two?": "Il existe déjà « {name} » au premier niveau. Fusionner les deux ?",
"Added “{keyword}” to {n} photo{}": "« {keyword} » ajouté à {n} photo{}",
+2 -1
View File
@@ -302,5 +302,6 @@
"Could not read {name}: {e}": "Tidak dapat membaca {name}: {e}",
"Drop to import {n} item{}": "Jatuhkan untuk mengimpor {n} item{:.0}",
"Drop to import {n} preset{}": "Jatuhkan untuk mengimpor {n} preset{:.0}",
"Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}"
"Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",
"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
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}",
"Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…",
"Inside: {names}": "この中: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?",
"There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?",
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
+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}",
+2 -1
View File
@@ -302,5 +302,6 @@
"Drop to import {n} item{}": "Upuść, aby zaimportować (elementy: {n}){:.0}",
"Drop to import {n} preset{}": "Upuść, aby zaimportować ustawienia wstępne: {n}{:.0}",
"Could not read {name}: {e}": "Nie można odczytać {name}: {e}",
"Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}"
"Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}",
"Inside: {names}": "Wewnątrz: {names}"
}
@@ -227,6 +227,7 @@
"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.",
"Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…",
"Inside: {names}": "Dentro: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?",
"There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?",
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
+1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.",
"Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…",
"Inside: {names}": "Внутри: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?",
"There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?",
"Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}",
+2 -1
View File
@@ -302,5 +302,6 @@
"Could not read {name}: {e}": "Kunde inte läsa {name}: {e}",
"Drop to import {n} item{}": "Släpp för att importera {n} objekt{:.0}",
"Drop to import {n} preset{}": "Släpp för att importera {n} förinställningar{:.0}",
"Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}"
"Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}",
"Inside: {names}": "Inuti: {names}"
}
+1
View File
@@ -223,6 +223,7 @@
"Delete the keyword “{name}”?": "Видалити ключове слово «{name}»?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Його буде знято з фото (кількість: {count}{:.0}) разом із вкладеними ключовими словами. Скасування поверне його.",
"Create Keyword Tag Inside “{name}”…": "Створити ключове слово в «{name}»…",
"Inside: {names}": "Усередині: {names}",
"“{parent}” has a “{name}” already. Merge the two?": "«{parent}» уже містить «{name}». Об’єднати їх?",
"There is a “{name}” at the top level already. Merge the two?": "На верхньому рівні вже є «{name}». Об’єднати їх?",
"Added “{keyword}” to {n} photo{}": "«{keyword}» додано до фото: {n}{:.0}",
+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}",
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "要删除关键字“{name}”吗?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}",
"Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…",
"Inside: {names}": "其中:{names}",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?",
"There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?",
"Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}",
@@ -227,6 +227,7 @@
"Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}",
"Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…",
"Inside: {names}": "其中:{names}",
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?",
"There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?",
"Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}",
+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
@@ -196,6 +196,38 @@ fn the_active_photo_alone_is_the_selection() {
assert!(has(&h, "keywordCheck:beach"));
}
/// 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:")));
}
/// Esc in the Add keyword box gives back what it held before this edit, as every text field does:
/// text left there by an earlier edit stays.
#[test]
File diff suppressed because one or more lines are too long
+9
View File
@@ -470,6 +470,15 @@
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
and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it