From 4cb024164ec04d1e8149ab0489d8c2a7250540fc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:16:36 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: the Keywording box shows the selection's keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chips at the top of the Keywords panel showed the active photo's keywords, while adding and removing applied to every selected photo: with several selected they misreported what the selection had. They are now the keywords of every selected photo, each once whatever its case (`panels::keywording`, cached per selection), and one only some of them have is marked with an asterisk ("On 1 of 2 selected photos"). Only the × removes a keyword, from every selected photo: a click on the chip itself used to remove it too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/src/lib.rs | 20 +++ crates/ui-egui/src/panels/keywording.rs | 145 ++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/right.rs | 26 +--- crates/ui-egui/src/tests_keywording.rs | 69 ++++++++++ 12 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/panels/keywording.rs create mode 100644 crates/ui-egui/src/tests_keywording.rs diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index d4ae39838..43afc32fb 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…", "“{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" + "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt", + "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 72bec0202..f7109ab26 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…", "“{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{}" + "Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}", + "On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index dd6060b6f..589509c4c 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…", "“{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}" + "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}", + "On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index ef31845fc..d4d8441bb 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…", "“{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{}" + "Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}", + "On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f1d4ca2f7..f0e819db9 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…", "“{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}" + "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}", + "On {have} of {of} selected photos": "На {have} из {of} выбранных фото" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index f01ccdf73..9d9b6b42a 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…", "“{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}" + "Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}", + "On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 8a4b64558..29203443f 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -213,5 +213,6 @@ "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…", "“{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}" + "Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}", + "On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4169af149..55138bd7d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,8 @@ mod tests_keymap; #[cfg(test)] mod tests_keyword_list; #[cfg(test)] +mod tests_keywording; +#[cfg(test)] mod tests_labels; #[cfg(test)] mod tests_library_problem; @@ -1078,6 +1080,8 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(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. keyword_ticks: Option<(u64, u64, std::sync::Arc)>, folder_tree: Option<(u64, std::sync::Arc>)>, @@ -1166,6 +1170,22 @@ impl Caches { } } } + /// The selection's keywords (the Keywording box's chips). + pub(crate) fn keyword_chips( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_chips { + Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + _ => { + let c = std::sync::Arc::new(panels::keywording::chips(cat, selection)); + self.keyword_chips = Some((cat.revision, key, c.clone())); + c + } + } + } /// How many selected photos have each keyword (the Keyword List's tick boxes). pub(crate) fn keyword_ticks( &mut self, diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs new file mode 100644 index 000000000..73c833d20 --- /dev/null +++ b/crates/ui-egui/src/panels/keywording.rs @@ -0,0 +1,145 @@ +//! The Keywording box (the top of the right panel's Keywords): the keywords of the selected photos, +//! not only the active one's, as Lightroom Classic shows them. A keyword only some of the selected +//! photos have is marked as such. + +use lightcraft_catalog::{Catalog, PhotoId}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// The selection's keywords as chips: the name (right-click for its menu), and × to take it off +/// every selected photo. One only some of them have is marked with an asterisk. +pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let selection = app.session.selection.ids.clone(); + let chips = app.caches.keyword_chips(&app.session.catalog, &selection); + ui.horizontal_wrapped(|ui| { + for chip in chips.iter() { + let frame = egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.field_border)).corner_radius(10.0).inner_margin(egui::Margin { + left: 8, + right: 4, + top: 2, + bottom: 2, + }); + let shown = frame.show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let partial = !chip.on_all(); + let name = format!("{}{}", chip.path.replace('|', " › "), if partial { " *" } else { "" }); + let color = if partial { t.text_dim } else { t.text_label }; + let label = ui.add(egui::Label::new(egui::RichText::new(name).color(color)).sense(egui::Sense::click())); + register(ui.ctx(), format!("keywordChip:{}", chip.path), label.rect); + let label = if partial { + register(ui.ctx(), format!("keywordChipPartial:{}", chip.path), label.rect); + label.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = chip.have, of = chip.of)) + } else { + label.on_hover_text(chip.path.replace('|', " › ")) + }; + let x = ui.add(egui::Button::new(egui::RichText::new("×").color(t.text_dim)).frame(false)); + register(ui.ctx(), format!("keywordChipRemove:{}", chip.path), x.rect); + if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})); + } + label + }); + let k = chip.path.clone(); + shown.inner.context_menu(|ui| { + if ui.button(crate::i18n::tr("Remove from Photo")).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); + } + if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() { + let _ = app.run("library.filter", json!({"keyword": k})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() }); + } + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { + let _ = app.run("keyword.delete", json!({"keyword": k})); + } + }); + } + }); +} + +/// A keyword of the selection, as a chip shows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Chip { + /// The keyword, spelled as the library spells it. + pub path: String, + /// Selected photos that have it. + pub have: usize, + /// Selected photos. + pub of: usize, +} + +impl Chip { + /// Every selected photo has it. + pub fn on_all(&self) -> bool { + self.have >= self.of + } +} + +/// The keywords of the selected `photos`, each once whatever its case, with how many of them have +/// it; by name. +pub(crate) fn chips(catalog: &Catalog, photos: &[PhotoId]) -> Vec { + use lightcraft_catalog::keywords::clean; + // by lower-case keyword: the photos with it (each once) + let mut have: std::collections::BTreeMap = Default::default(); + let mut seen = std::collections::HashSet::new(); + for id in photos { + let Some(p) = catalog.photo(*id) else { continue }; + seen.clear(); + for k in &p.meta.keywords { + let k = clean(k); + if k.is_empty() || !seen.insert(k.to_lowercase()) { + continue; + } + have.entry(k.to_lowercase()).or_insert_with(|| (k.clone(), 0)).1 += 1; + } + } + have.into_values().map(|(k, n)| Chip { path: catalog.keyword_path(&k).unwrap_or(k), have: n, of: photos.len() }).collect() +} + +#[cfg(test)] +mod tests { + use lightcraft_catalog::{Op, Photo, Source}; + + use super::*; + + fn library(keywords: &[&[&str]]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for k in keywords { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) + } + + fn shown(chips: &[Chip]) -> Vec<(&str, usize, usize)> { + chips.iter().map(|c| (c.path.as_str(), c.have, c.of)).collect() + } + + /// The chips are the keywords of every selected photo, by name, each saying how many of the + /// selected photos have it. + #[test] + fn the_chips_are_the_selections_keywords() { + let (c, ids) = library(&[&["travel|Italy", "beach"], &["beach", "Weddings"], &["sea"]]); + assert_eq!(shown(&chips(&c, &ids[..2])), [("beach", 2, 2), ("travel|Italy", 1, 2), ("Weddings", 1, 2)]); + assert!(chips(&c, &ids[..2])[0].on_all() && !chips(&c, &ids[..2])[1].on_all()); + assert_eq!(shown(&chips(&c, &ids[2..])), [("sea", 1, 1)]); + assert!(chips(&c, &[]).is_empty()); + } + + /// A keyword two photos spell differently is one chip, spelled as the library spells it. + #[test] + fn a_keyword_spelled_two_ways_is_one_chip() { + let (c, ids) = library(&[&["Beach"], &["beach"], &["BEACH", "beach"]]); + assert_eq!(shown(&chips(&c, &ids)), [("Beach", 3, 3)]); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index c1dc9ce1f..d87d6789e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod filterbar; pub mod grid; pub mod keymap; pub mod keyword_list; +pub mod keywording; pub mod left; pub mod library_problem; pub mod masking; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 23915c84a..d434a17b9 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -783,31 +783,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.data_mut(|d| d.insert_temp(kid, text)); ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - for k in &p.meta.keywords { - let r = - ui.add(egui::Button::new(egui::RichText::new(format!("{} ×", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0)); - register(ui.ctx(), format!("keywordChip:{k}"), r.rect); - if r.clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); - } - r.context_menu(|ui| { - if ui.button(crate::i18n::tr("Remove from Photo")).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]})); - } - if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() { - let _ = app.run("library.filter", json!({"keyword": k})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() }); - } - if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { - let _ = app.run("keyword.delete", json!({"keyword": k})); - } - }); - } - }); + super::keywording::chip_row(app, ui); ui.add_space(10.0); keyword_set(app, ui, &p.meta.keywords); ui.add_space(8.0); diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs new file mode 100644 index 000000000..8e3468cd5 --- /dev/null +++ b/crates/ui-egui/src/tests_keywording.rs @@ -0,0 +1,69 @@ +//! The Keywording box at the top of the right panel's Keywords: the selection's keywords, driven +//! through the control channel. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The Library grid with the Keywords panel open and two photos selected: the first with beach and +/// Weddings, the second with beach. +fn two_selected() -> (Headless, Vec) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + for (method, params) in [ + ("ui.set", json!({"view": "photoGrid", "right": "keywords"})), + ("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "keywords": ["beach", "Weddings"]}})), + ("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["beach"]}})), + ("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + (h, ids) +} + +fn ask(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params.clone(), T); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn keywords_of(h: &Headless, id: u64) -> Vec { + h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone() +} + +/// The chips are the keywords of every selected photo, not only the active one's; a keyword only +/// some of them have is marked. +#[test] +fn the_chips_are_the_selections_keywords() { + let (h, _) = two_selected(); + assert!( + has(&h, "keywordChip:beach") && has(&h, "keywordChip:Weddings"), + "{:?}", + h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip")).collect::>() + ); + assert!(has(&h, "keywordChipPartial:Weddings"), "only one of the two has Weddings"); + assert!(!has(&h, "keywordChipPartial:beach"), "both have beach"); +} + +/// Only the × removes a keyword, from every selected photo; a click on the chip itself doesn't. +#[test] +fn only_the_cross_removes_a_keyword() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:beach"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"beach".to_string())), "a click on the chip keeps it"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:beach"})); + assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"beach".to_string())), "× took it off both"); +}