diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 8a50d38f1..4f8f6768e 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 af29f7134..6b60b47b7 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -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", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index fd1540169..cf8b4b147 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -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", diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json index 698080e10..46059240f 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 ae2b77872..ad6c634cb 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -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", diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json index b3887f917..300e1dd39 100644 --- a/crates/ui-egui/locales/id-formats.json +++ b/crates/ui-egui/locales/id-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index ccd01e6a7..9b4200a49 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 ce56500b5..658b8ff0b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -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}合成をキャンセルしました", diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index c3b48ef62..542480487 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 e3ce316a0..153222d65 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -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.", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index bab81a368..f69253b1e 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -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", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 1efc30268..8003868e6 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -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}: слияние отменено", diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index 50e3fa4be..6cd96647e 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -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}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 0ada44e08..8c10bf0bb 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -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} скасовано", diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json index 9ff8ed6b9..2a4df0ada 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 a28b07d2c..887a3d33f 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -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} 合并", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 119272225..25dfcfe10 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -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}合併", 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..228823f82 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -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:"))); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..6b203ce92 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -711,7 +711,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 @@ -927,3 +927,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged | | 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) | | 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status 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 cffff83e2..ed62b971d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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