diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 785e0fa99..366bdfbc0 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1914,5 +1914,6 @@ "Select photos to give them keywords.": "Wählen Sie Fotos aus, um ihnen Stichwörter zu geben.", "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", "Will Export": "Wird exportiert", - "No keywords are exported": "Keine Stichwörter werden exportiert" + "No keywords are exported": "Keine Stichwörter werden exportiert", + "& Containing": "& Übergeordnete" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index dc0726d67..203c53f4c 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1759,5 +1759,6 @@ "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas", "Will Export": "Se exportará", - "No keywords are exported": "No se exporta ninguna palabra clave" + "No keywords are exported": "No se exporta ninguna palabra clave", + "& Containing": "y contenedoras" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 5f0cd0c4b..21735388e 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1759,5 +1759,6 @@ "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。", "Add to All Selected Photos": "選択したすべての写真に追加", "Will Export": "書き出される内容", - "No keywords are exported": "書き出されるキーワードはありません" + "No keywords are exported": "書き出されるキーワードはありません", + "& Containing": "と親キーワード" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 1414b5e05..43f3e6142 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1759,5 +1759,6 @@ "Select photos to give them keywords.": "Selecione fotos para dar palavras-chave a elas.", "Add to All Selected Photos": "Adicionar a todas as fotos selecionadas", "Will Export": "Será exportado", - "No keywords are exported": "Nenhuma palavra-chave é exportada" + "No keywords are exported": "Nenhuma palavra-chave é exportada", + "& Containing": "e as que as contêm" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index d70290784..5da2cd674 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1433,5 +1433,6 @@ "Person": "Человек", "Add to All Selected Photos": "Добавить ко всем выбранным фото", "Will Export": "Будет экспортировано", - "No keywords are exported": "Ключевые слова не экспортируются" + "No keywords are exported": "Ключевые слова не экспортируются", + "& Containing": "и родительские" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 67545963e..bbced17dc 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1759,5 +1759,6 @@ "Select photos to give them keywords.": "选择照片以为其添加关键字。", "Add to All Selected Photos": "添加到所有所选照片", "Will Export": "将导出", - "No keywords are exported": "没有要导出的关键字" + "No keywords are exported": "没有要导出的关键字", + "& Containing": "及上级" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 448fa70ca..f2c7c6547 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1759,5 +1759,6 @@ "Select photos to give them keywords.": "選取照片以加入關鍵字。", "Add to All Selected Photos": "加入所有選取的照片", "Will Export": "將匯出", - "No keywords are exported": "沒有要匯出的關鍵字" + "No keywords are exported": "沒有要匯出的關鍵字", + "& Containing": "及上層" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e2fed5ad..f02544f1b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1080,8 +1080,9 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, - /// The Keywording box's Will Export names, by library revision and selection. - keyword_export: Option<(u64, u64, std::sync::Arc>)>, + /// The Keywording box's read-only names (with containing keywords, or Will Export), by library + /// revision, selection and view. + keyword_names: Option<(u64, u64, std::sync::Arc>)>, /// The Keywording box's chips, by library revision and selection. keyword_chips: Option<(u64, u64, std::sync::Arc>)>, /// The Keyword List's tick boxes, by library revision and selection. @@ -1172,18 +1173,24 @@ impl Caches { } } } - /// What exported files carry for the selection (the Keywording box's Will Export). - pub(crate) fn keyword_export( + /// The Keywording box's read-only names for the selection: what exported files carry + /// (`export`), or the keywords with those containing them. + pub(crate) fn keyword_names( &mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId], + export: bool, ) -> std::sync::Arc> { - let key = key_of(selection); - match &self.keyword_export { + let key = key_of((selection, export)); + match &self.keyword_names { Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), _ => { - let c = std::sync::Arc::new(panels::keywording::will_export(cat, selection)); - self.keyword_export = Some((cat.revision, key, c.clone())); + let c = std::sync::Arc::new(if export { + panels::keywording::will_export(cat, selection) + } else { + panels::keywording::with_containing(cat, selection) + }); + self.keyword_names = Some((cat.revision, key, c.clone())); c } } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 4ebc57aaf..e1eabd719 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -594,7 +594,10 @@ mod tests { "Add to Selected Photos", "Remove from Selected Photos", "Keywords", + "& Containing", "Will Export", + "No keywords are exported", + "Add to All Selected Photos", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); for code in ["de", "es", "pt-br", "ru", "ja", "zh-hans", "zh-hant"] { diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 336378156..857498599 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -9,33 +9,39 @@ use crate::LightcraftApp; use crate::theme::Tokens; use crate::widgets::register; -/// The Keywording box's view: the keywords (chips), or what exported files will carry. +/// The Keywording box's view: the keywords (chips), them with the keywords containing them, or +/// what exported files will carry. pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - for (export, id, label) in [(false, "keywords", "Keywords"), (true, "willExport", "Will Export")] { - let r = ui.selectable_label(app.ui.keywording_will_export == export, crate::i18n::tr(label)); + use crate::state::KeywordingView as V; + ui.horizontal_wrapped(|ui| { + for (view, id, label) in + [(V::Keywords, "keywords", "Keywords"), (V::Containing, "containing", "& Containing"), (V::WillExport, "willExport", "Will Export")] + { + let r = ui.selectable_label(app.ui.keywording_view == view, crate::i18n::tr(label)); register(ui.ctx(), format!("keywordView:{id}"), r.rect); if r.clicked() { - app.ui.keywording_will_export = export; + app.ui.keywording_view = view; } } }); } -/// What exported files will carry for the selection, read only: one name only some of the photos -/// carry is marked with an asterisk. -pub fn export_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { +/// A read-only view of the selection's keywords: with the keywords containing them, or what +/// exported files will carry. One only some of the photos have is marked with an asterisk. +pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let selection = app.session.targets(&serde_json::Value::Null); - let names = app.caches.keyword_export(&app.session.catalog, &selection); + let export = app.ui.keywording_view == crate::state::KeywordingView::WillExport; + let names = app.caches.keyword_names(&app.session.catalog, &selection, export); if names.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("No keywords are exported")).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(if export { "No keywords are exported" } else { "No keywords yet" })).color(t.text_dim)); return; } + let prefix = if export { "keywordExport" } else { "keywordContaining" }; ui.horizontal_wrapped(|ui| { for n in names.iter() { let (body, _) = chip(ui, &n.path, !n.on_all(), false); - register(ui.ctx(), format!("keywordExport:{}", n.path), body.rect); + register(ui.ctx(), format!("{prefix}:{}", n.path), body.rect); if n.on_all() { body.on_hover_text(&n.path); } else { @@ -197,6 +203,27 @@ pub(crate) fn will_export(catalog: &Catalog, photos: &[PhotoId]) -> Vec { have.into_values().map(|(path, have)| Chip { path, have, of: photos.len() }).collect() } +/// "Keywords & Containing Keywords": the names of the selection's keywords and of the keywords +/// containing them, each with how many of the selected photos have it (itself or below it); by +/// name. +pub(crate) fn with_containing(catalog: &Catalog, photos: &[PhotoId]) -> Vec { + use lightcraft_catalog::keywords::{SEP, clean}; + let photos = distinct(catalog, photos); + let mut have: std::collections::BTreeMap = Default::default(); + let mut seen = std::collections::HashSet::new(); + for p in &photos { + seen.clear(); + for k in &p.meta.keywords { + for name in clean(k).split(SEP) { + if !name.is_empty() && seen.insert(name.to_lowercase()) { + have.entry(name.to_lowercase()).or_insert_with(|| (name.to_string(), 0)).1 += 1; + } + } + } + } + have.into_values().map(|(path, have)| Chip { path, have, of: photos.len() }).collect() +} + /// The selected photos, each once, those the library has: what "N of M" counts. pub(crate) fn distinct<'a>(catalog: &'a Catalog, photos: &[PhotoId]) -> Vec<&'a lightcraft_catalog::Photo> { let mut seen = std::collections::HashSet::new(); @@ -342,4 +369,12 @@ mod tests { assert!(has("Button", "Places › Portugal › Lisbon › Belém tower"), "{found:?}"); assert!(has("Button", "Remove “Places › Portugal › Lisbon › Belém tower”"), "{found:?}"); } + + /// "Keywords & Containing Keywords": every level of the selection's keywords, flat, each + /// counted once per photo. + #[test] + fn containing_keywords_are_every_level() { + let (c, ids) = library(&[&["travel|Italy|Rome", "travel|Spain"], &["travel|Italy"]]); + assert_eq!(shown(&with_containing(&c, &ids)), [("Italy", 2, 2), ("Rome", 1, 2), ("Spain", 1, 2), ("travel", 2, 2)]); + } } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3afbdf1bb..7f5aa8412 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -790,10 +790,10 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); super::keywording::view_switch(app, ui); ui.add_space(4.0); - if app.ui.keywording_will_export { - super::keywording::export_row(app, ui); - } else { + if app.ui.keywording_view == crate::state::KeywordingView::Keywords { super::keywording::chip_row(app, ui); + } else { + super::keywording::names_row(app, ui); } ui.add_space(10.0); keyword_set(app, ui); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f4dd8eadd..4e64a87cf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -262,6 +262,17 @@ pub const RIGHT_WIDTH: PanelWidth = PanelWidth { min: 250.0, default: 270.0, max /// The photo area the side panels always leave free (as far as their minimum widths allow). pub const MIN_PHOTO_WIDTH: f32 = 360.0; +/// What the Keywording box shows: the keywords (chips to edit), them with the keywords containing +/// them, or what exported files will carry (read only). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordingView { + #[default] + Keywords, + Containing, + WillExport, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -449,9 +460,8 @@ pub struct UiState { /// The keyword painter: clicking a photo in the grid toggles this keyword on it. #[serde(skip)] pub keyword_painter: Option, - /// The Keywording box shows what exported files will carry (Will Export) instead of the - /// keywords. - pub keywording_will_export: bool, + /// What the Keywording box shows (Lightroom Classic's Keyword Tags views). + pub keywording_view: KeywordingView, /// The Keyword List's open levels (lower-case paths). pub keyword_list_open: Vec, /// The keyword picked in the Keyword List (− deletes it, Edit edits it). @@ -798,7 +808,7 @@ impl Default for UiState { slideshow: None, second_window: false, keyword_painter: None, - keywording_will_export: false, + keywording_view: KeywordingView::default(), keyword_list_open: Vec::new(), keyword_list_selected: None, dragging_keyword: None, diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 93352650a..a130b86b2 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -213,3 +213,17 @@ fn escape_gives_back_the_text_from_before_the_edit() { let k = keywords_of(&h, ids[0]); assert!(k.contains(&"harbour".to_string()) && !k.contains(&"sea".to_string()), "{k:?}"); } + +/// "Keywords & Containing Keywords" lists the selection's keywords with the keywords containing +/// them, flat (read only). +#[test] +fn containing_keywords_view_lists_parents_too() { + let (mut h, ids) = two_selected(); + let r = h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "addKeywords": ["Events|Birthdays"]}}), T); + assert_eq!(r["ok"], true, "{r}"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordView:containing"})); + for name in ["Events", "Birthdays", "beach"] { + assert!(has(&h, &format!("keywordContaining:{name}")), "{name}"); + } + assert!(!has(&h, "keywordChip:beach"), "the chips give way"); +}