diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 0d68ff437..8c8ac8aa6 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -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: {}", diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 4c3a4303a..b8f958ba4 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -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", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 51fa90754..f3db96856 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -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{}", diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json index 433ee7f67..dc425bd98 100644 --- a/crates/ui-egui/locales/fi-formats.json +++ b/crates/ui-egui/locales/fi-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 8772c29b7..ae832f80e 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -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{}", diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json index 3ab3d7f83..45073d5d2 100644 --- a/crates/ui-egui/locales/id-formats.json +++ b/crates/ui-egui/locales/id-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 2a63f3842..ae08bdd88 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -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", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 014061ffd..10e35ab3d 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index 3fb5ab691..ee6028621 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 7a31b4c26..6fc26d90e 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index c4a95da3e..68483b141 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -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{}", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 6d6fa0046..10ab27bed 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index a40df031c..7a0114a77 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 473702c08..4cca48044 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json index 8dfcb1531..fe4d50492 100644 --- a/crates/ui-egui/locales/vi-formats.json +++ b/crates/ui-egui/locales/vi-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 66c935604..84bec24a7 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -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}", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 1bf4076cb..0f4bfbc06 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -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}", diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 1ec97d1c9..64210d896 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -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 { + let named = inside.iter().take(INSIDE_NAMED).map(String::as_str).collect::>().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, + /// 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>, } impl Ticks { pub(crate) fn of(catalog: &Catalog, photos: &[PhotoId]) -> Ticks { let photos = super::keywording::distinct(catalog, photos); let mut counts: std::collections::HashMap = Default::default(); + // (each keyword as it was first written, to name it by) + let mut shown: std::collections::HashMap = 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> = 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] diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 95b02967c..076f25789 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -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] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 490febbe3..a3f4ecd5d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -723,7 +723,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields. In the Keyword List a keyword's empty box carries a dot when the selected photos have a keyword inside it (its tooltip names them), so a folded keyword shows that something in it is applied | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people @@ -960,3 +960,4 @@ Graphic watermarks honour EXIF rotation/mirroring through the shared `read_graph ### 2026-10-20 — graphic watermark inset regression Oversized graphics fit the space remaining after the applicable anchor insets, with one aspect-preserving scale before quarter-turn rotation. Centered axes continue to ignore insets. Pixel-bound regressions cover edge/corner anchors, centered placement and all four rotations. Main d999e382 is integrated; HDR size-limit and EXIF-orientation fixes are unchanged. +| 2026-10-13 | minor | LRC-LIB-KEYWORDS: the Keyword List marks a keyword that has an applied keyword inside it (a dot in its box); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 285cb1efb..137651c2a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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