From 4cb024164ec04d1e8149ab0489d8c2a7250540fc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:16:36 -0400 Subject: [PATCH 01/19] 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"); +} From a3b7c4caf42d7985a3566101117c19e970a44fab Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:17:25 -0400 Subject: [PATCH 02/19] LRC-LIB-KEYWORDS: a chip's menu acts on the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip menu offered Delete Keyword, which takes the keyword off every photo in the library, beside "Remove from Photo". It now acts on the selected photos only: Add to All Selected Photos (for a keyword only some have), Remove from Selected Photos, Show Photos with Keyword, and Edit Keyword Tag… (the Keyword List's dialog, for renaming too). Deleting a keyword from the library is the Keyword List's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/panels/keywording.rs | 42 +++++++++++++++---------- crates/ui-egui/src/tests_keywording.rs | 19 +++++++++++ 9 files changed, 59 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 23a5879ed..be30fa177 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1911,5 +1911,6 @@ "Delete Keyword…": "Stichwort löschen…", "Drop here for the top level": "Hier ablegen für die oberste Ebene", "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", - "Select photos to give them keywords.": "Wählen Sie Fotos aus, um ihnen Stichwörter zu geben." + "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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c8cd8f87d..1095fac21 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1756,5 +1756,6 @@ "Delete Keyword…": "Eliminar palabra clave…", "Drop here for the top level": "Soltar aquí para el nivel superior", "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", - "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave." + "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", + "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index e8610ea0b..c17f3b3e3 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1756,5 +1756,6 @@ "Delete Keyword…": "キーワードを削除…", "Drop here for the top level": "ここにドロップすると最上位に移動", "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。", - "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。" + "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。", + "Add to All Selected Photos": "選択したすべての写真に追加" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 7dddca874..2c26013a1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1756,5 +1756,6 @@ "Delete Keyword…": "Excluir palavra-chave…", "Drop here for the top level": "Solte aqui para o nível superior", "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave.", - "Select photos to give them keywords.": "Selecione fotos para dar palavras-chave a elas." + "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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 57440fe76..94d636352 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1430,5 +1430,6 @@ "Drop here for the top level": "Отпустите здесь для верхнего уровня", "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова.", - "Person": "Человек" + "Person": "Человек", + "Add to All Selected Photos": "Добавить ко всем выбранным фото" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c652318ad..184b117e1 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1756,5 +1756,6 @@ "Delete Keyword…": "删除关键字…", "Drop here for the top level": "拖放到此处以移至顶层", "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。", - "Select photos to give them keywords.": "选择照片以为其添加关键字。" + "Select photos to give them keywords.": "选择照片以为其添加关键字。", + "Add to All Selected Photos": "添加到所有所选照片" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d29103389..aa02f290a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1756,5 +1756,6 @@ "Delete Keyword…": "刪除關鍵字…", "Drop here for the top level": "拖放到此處以移至最上層", "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。", - "Select photos to give them keywords.": "選取照片以加入關鍵字。" + "Select photos to give them keywords.": "選取照片以加入關鍵字。", + "Add to All Selected Photos": "加入所有選取的照片" } diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 73c833d20..09107dc11 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -9,6 +9,30 @@ use crate::LightcraftApp; use crate::theme::Tokens; use crate::widgets::register; +/// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the +/// Keyword List's). +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { + let item = |ui: &mut egui::Ui, id: &str, label: &str| { + let r = ui.button(label); + register(ui.ctx(), format!("keywordChipMenu:{id}"), r.rect); + r.clicked() + }; + if !chip.on_all() && item(ui, "add", crate::i18n::tr("Add to All Selected Photos")) { + let _ = app.run("photo.setMeta", json!({"addKeywords": [chip.path]})); + } + if item(ui, "remove", crate::i18n::tr("Remove from Selected Photos")) { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})); + } + if item(ui, "show", crate::i18n::tr("Show Photos with Keyword")) { + super::left::browse_all_photos(app, true); + let _ = app.run("library.filter", json!({"keyword": chip.path})); + } + ui.separator(); + if item(ui, "edit", crate::i18n::tr("Edit Keyword Tag…")) { + app.ui.dialog = Some(super::keyword_list::edit_dialog(app, &chip.path)); + } +} + /// 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) { @@ -43,22 +67,8 @@ pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { } 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})); - } - }); + let chip = chip.clone(); + shown.inner.context_menu(|ui| menu(app, ui, &chip)); } }); } diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 8e3468cd5..4f8b034f7 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -67,3 +67,22 @@ fn only_the_cross_removes_a_keyword() { 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"); } + +/// A chip's menu acts on the selection, never on the whole library: give a keyword only some have +/// to all of them, take it off them, show its photos, or edit it (Edit Keyword Tag). Deleting a +/// keyword from every photo of the library is the Keyword List's. +#[test] +fn a_chips_menu_acts_on_the_selection() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:Weddings", "button": "right"})); + for item in ["add", "remove", "show", "edit"] { + assert!(has(&h, &format!("keywordChipMenu:{item}")), "{item}"); + } + assert!(!has(&h, "keywordChipMenu:delete"), "no library-wide delete"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipMenu:add"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Weddings".to_string())), "given to both"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:Weddings", "button": "right"})); + assert!(!has(&h, "keywordChipMenu:add"), "both have it now"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipMenu:edit"})); + assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { editing: Some(k), .. }) if k == "Weddings"), "{:?}", h.app.ui.dialog); +} From f83ac3377ed3341d3b2f7e63bd9657a1cc942a7e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:18:10 -0400 Subject: [PATCH 03/19] LRC-LIB-KEYWORDS: the Add keyword box is a shared text field It gets the Cut / Copy / Paste / Select All menu (paste a list of keywords from elsewhere), Return gives the keywords to every selected photo as before, and Esc gives up what was typed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/right.rs | 10 +++++++--- crates/ui-egui/src/tests_keywording.rs | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index d434a17b9..23b7adc2b 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -773,9 +773,13 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { let kid = egui::Id::new("kw-input"); let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("Add keyword")).desired_width(f32::INFINITY)); - register(ui.ctx(), "field:keyword", r.rect); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !text.trim().is_empty() { + let r = + crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); + // Return gives the keywords to the selected photos; Esc gives up what was typed + if r.cancelled() { + text.clear(); + } + if r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty() { // a new name goes inside the default parent (Put New Keywords Inside This Keyword) let kws: Vec = text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect(); let _ = app.run("photo.setMeta", json!({"addKeywords": kws})); diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 4f8b034f7..c5dd7ea37 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -86,3 +86,24 @@ fn a_chips_menu_acts_on_the_selection() { ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipMenu:edit"})); assert!(matches!(&h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { editing: Some(k), .. }) if k == "Weddings"), "{:?}", h.app.ui.dialog); } + +/// The "Add keyword" box is a shared text field: right-click ▸ Paste fills it, Return gives the +/// keywords to every selected photo, and Esc gives up what was typed. +#[test] +fn the_add_keyword_box_pastes_adds_and_gives_up() { + let (mut h, ids) = two_selected(); + h.view.clipboard = "travel, sunset".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + for id in &ids { + let k = keywords_of(&h, *id); + assert!(k.contains(&"travel".to_string()) && k.contains(&"sunset".to_string()), "{k:?}"); + } + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"harbour".to_string())), "Esc gave it up"); +} From 3e0c412249449f453a617dff22d92fc00448114f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:19:13 -0400 Subject: [PATCH 04/19] LRC-LIB-KEYWORDS: the keyword set's buttons reflect the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nine buttons showed "on" when the active photo had the keyword, while their ⌥1–⌥9 toggle acts on every selected photo. A button is now on when every selected photo has its keyword, and marked with an asterisk when only some do, as the chips are. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/right.rs | 18 ++++++++++++++---- crates/ui-egui/src/tests_keywording.rs | 12 ++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 23b7adc2b..d92acaab3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -789,7 +789,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); super::keywording::chip_row(app, ui); ui.add_space(10.0); - keyword_set(app, ui, &p.meta.keywords); + keyword_set(app, ui); ui.add_space(8.0); // the painter: click photos in the grid to give them (or take away) a keyword ui.horizontal(|ui| { @@ -838,7 +838,10 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the /// selected photos; "Save as Set…" keeps the current nine under a name. -fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { +fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { + // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do + let selection = app.session.selection.ids.clone(); + let chips = app.caches.keyword_chips(&app.session.catalog, &selection); let t = Tokens::get(ui.ctx()); let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); let current = sets["current"].as_str().unwrap_or_default().to_string(); @@ -882,8 +885,10 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0); egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| { for (i, k) in kws.iter().enumerate() { - let on = have.iter().any(|x| x.eq_ignore_ascii_case(k)); - let short = k.rsplit('|').next().unwrap_or(k); + let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k))); + let on = chip.is_some_and(|c| c.on_all()); + let some = chip.is_some() && !on; + let short = format!("{}{}", k.rsplit('|').next().unwrap_or(k), if some { " *" } else { "" }); let r = ui .add_sized( [bw, 22.0], @@ -891,6 +896,11 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) { ) .on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1)); register(ui.ctx(), format!("kwSet:{}", i + 1), r.rect); + if on { + register(ui.ctx(), format!("kwSetOn:{}", i + 1), r.rect); + } else if some { + register(ui.ctx(), format!("kwSetSome:{}", i + 1), r.rect); + } if r.clicked() { let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1})); } diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index c5dd7ea37..c669d709f 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -107,3 +107,15 @@ fn the_add_keyword_box_pastes_adds_and_gives_up() { ask(&mut h, "ui.key", json!({"key": "Enter"})); assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"harbour".to_string())), "Esc gave it up"); } + +/// The keyword set's buttons say whether the selection has each keyword, as their ⌥1–⌥9 toggle it +/// on the selection: on when every selected photo has it, partly on when only some do. +#[test] +fn keyword_set_buttons_reflect_the_selection() { + let (mut h, _) = two_selected(); + let sets = h.app.session.execute("keyword.sets", &json!({})).unwrap(); + let at = |k: &str| sets["keywords"].as_array().unwrap().iter().position(|x| x == k).map(|i| i + 1).unwrap_or_else(|| panic!("{k} in {sets}")); + assert!(has(&h, &format!("kwSetOn:{}", at("beach"))), "both have beach"); + assert!(has(&h, &format!("kwSetSome:{}", at("Weddings"))), "only the active one has Weddings"); + assert!(!has(&h, &format!("kwSetOn:{}", at("Weddings")))); +} From f577ccf8284eee82c998e13eecc6a0dcb33d96f0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:21:13 -0400 Subject: [PATCH 05/19] LRC-LIB-KEYWORDS: Will Export shows what exported files carry Lightroom Classic's Keywording box can show the keywords that will be exported rather than those entered. A Keywords / Will Export switch under the box now does the same for the selection: the names exported files carry as the keyword tag options say (a keyword left out of export goes, parents and synonyms come), one only some of the photos carry marked with an asterisk. Read only; cached per selection. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/lib.rs | 18 ++++++ crates/ui-egui/src/panels/keyword_list.rs | 2 + crates/ui-egui/src/panels/keywording.rs | 74 +++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 8 ++- crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/src/tests_keywording.rs | 15 +++++ 13 files changed, 141 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index be30fa177..785e0fa99 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1912,5 +1912,7 @@ "Drop here for the top level": "Hier ablegen für die oberste Ebene", "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", "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" + "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", + "Will Export": "Wird exportiert", + "No keywords are exported": "Keine Stichwörter werden exportiert" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 1095fac21..dc0726d67 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "Soltar aquí para el nivel superior", "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", - "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas" + "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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c17f3b3e3..5f0cd0c4b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "ここにドロップすると最上位に移動", "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。", "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。", - "Add to All Selected Photos": "選択したすべての写真に追加" + "Add to All Selected Photos": "選択したすべての写真に追加", + "Will Export": "書き出される内容", + "No keywords are exported": "書き出されるキーワードはありません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2c26013a1..1414b5e05 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "Solte aqui para o nível superior", "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave.", "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" + "Add to All Selected Photos": "Adicionar a todas as fotos selecionadas", + "Will Export": "Será exportado", + "No keywords are exported": "Nenhuma palavra-chave é exportada" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 94d636352..d70290784 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1431,5 +1431,7 @@ "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова.", "Person": "Человек", - "Add to All Selected Photos": "Добавить ко всем выбранным фото" + "Add to All Selected Photos": "Добавить ко всем выбранным фото", + "Will Export": "Будет экспортировано", + "No keywords are exported": "Ключевые слова не экспортируются" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 184b117e1..67545963e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "拖放到此处以移至顶层", "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。", "Select photos to give them keywords.": "选择照片以为其添加关键字。", - "Add to All Selected Photos": "添加到所有所选照片" + "Add to All Selected Photos": "添加到所有所选照片", + "Will Export": "将导出", + "No keywords are exported": "没有要导出的关键字" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index aa02f290a..448fa70ca 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1757,5 +1757,7 @@ "Drop here for the top level": "拖放到此處以移至最上層", "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。", "Select photos to give them keywords.": "選取照片以加入關鍵字。", - "Add to All Selected Photos": "加入所有選取的照片" + "Add to All Selected Photos": "加入所有選取的照片", + "Will Export": "將匯出", + "No keywords are exported": "沒有要匯出的關鍵字" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 55138bd7d..0e2fed5ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1080,6 +1080,8 @@ 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 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. @@ -1170,6 +1172,22 @@ impl Caches { } } } + /// What exported files carry for the selection (the Keywording box's Will Export). + pub(crate) fn keyword_export( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc> { + let key = key_of(selection); + match &self.keyword_export { + 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())); + c + } + } + } /// The selection's keywords (the Keywording box's chips). pub(crate) fn keyword_chips( &mut self, diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 432f75c98..fb51a14cd 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -593,6 +593,8 @@ mod tests { "No keywords match", "Add to Selected Photos", "Remove from Selected Photos", + "Keywords", + "Will Export", ]; 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 09107dc11..c3e47717c 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -9,6 +9,47 @@ 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. +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)); + register(ui.ctx(), format!("keywordView:{id}"), r.rect); + if r.clicked() { + app.ui.keywording_will_export = export; + } + } + }); +} + +/// 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) { + let t = Tokens::get(ui.ctx()); + let selection = app.session.selection.ids.clone(); + let names = app.caches.keyword_export(&app.session.catalog, &selection); + if names.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No keywords are exported")).color(t.text_dim)); + return; + } + ui.horizontal_wrapped(|ui| { + for n in names.iter() { + let partial = !n.on_all(); + let text = format!("{}{}", n.path, if partial { " *" } else { "" }); + let r = egui::Frame::NONE + .stroke(egui::Stroke::new(1.0, t.field_border)) + .corner_radius(10.0) + .inner_margin(egui::Margin::symmetric(8, 2)) + .show(ui, |ui| ui.label(egui::RichText::new(text).color(if partial { t.text_dim } else { t.text_label }))) + .response; + register(ui.ctx(), format!("keywordExport:{}", n.path), r.rect); + if partial { + r.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = n.have, of = n.of)); + } + } + }); +} + /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the /// Keyword List's). fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { @@ -93,6 +134,20 @@ impl Chip { /// The keywords of the selected `photos`, each once whatever its case, with how many of them have /// it; by name. +/// "Will Export" (Lightroom Classic's Keyword Tags ▸ Will Export): the names exported files carry +/// for the selected `photos`, as the keyword tag options say, each with how many of them carry it; +/// by name. +pub(crate) fn will_export(catalog: &Catalog, photos: &[PhotoId]) -> Vec { + let mut have: std::collections::BTreeMap = Default::default(); + for id in photos { + let Some(p) = catalog.photo(*id) else { continue }; + for name in catalog.export_keywords(&p.meta.keywords).flat { + have.entry(name.to_lowercase()).or_insert_with(|| (name.clone(), 0)).1 += 1; + } + } + have.into_values().map(|(path, have)| Chip { path, have, of: photos.len() }).collect() +} + pub(crate) fn chips(catalog: &Catalog, photos: &[PhotoId]) -> Vec { use lightcraft_catalog::keywords::clean; // by lower-case keyword: the photos with it (each once) @@ -152,4 +207,23 @@ mod tests { let (c, ids) = library(&[&["Beach"], &["beach"], &["BEACH", "beach"]]); assert_eq!(shown(&chips(&c, &ids)), [("Beach", 3, 3)]); } + + /// "Will Export": the names exported files carry for the selection, as the keyword tag options + /// say (a keyword left out goes, its parents and synonyms come), each with how many of the + /// selected photos carry it. + #[test] + fn will_export_shows_what_exported_files_carry() { + use lightcraft_catalog::keywords::KeywordInfo; + let (mut c, ids) = library(&[&["Places|Lisbon", "draft"], &["Places|Lisbon"]]); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); + c.apply(Op::SetKeyword { + path: "Places|Lisbon".into(), + info: Some(KeywordInfo { synonyms: vec!["Lisboa".into()], ..KeywordInfo::default() }), + }) + .unwrap(); + assert_eq!(shown(&will_export(&c, &ids)), [("Lisboa", 2, 2), ("Lisbon", 2, 2)]); + assert!(will_export(&c, &[]).is_empty()); + } } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index d92acaab3..f4c5fe899 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -787,7 +787,13 @@ 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); - super::keywording::chip_row(app, ui); + super::keywording::view_switch(app, ui); + ui.add_space(4.0); + if app.ui.keywording_will_export { + super::keywording::export_row(app, ui); + } else { + super::keywording::chip_row(app, ui); + } ui.add_space(10.0); keyword_set(app, ui); ui.add_space(8.0); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 20fddc3b7..f4dd8eadd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -449,6 +449,9 @@ 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, /// 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). @@ -795,6 +798,7 @@ impl Default for UiState { slideshow: None, second_window: false, keyword_painter: None, + keywording_will_export: false, 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 c669d709f..3de8c53a0 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -119,3 +119,18 @@ fn keyword_set_buttons_reflect_the_selection() { assert!(has(&h, &format!("kwSetSome:{}", at("Weddings"))), "only the active one has Weddings"); assert!(!has(&h, &format!("kwSetOn:{}", at("Weddings")))); } + +/// "Will Export" shows the names exported files will carry for the selection instead of the chips: +/// a keyword left out of export isn't there; "Keywords" brings the chips back. +#[test] +fn will_export_shows_what_exports_carry() { + let (mut h, _) = two_selected(); + let r = h.request("engine.execute", json!({"command": "keyword.edit", "params": {"keyword": "Weddings", "includeOnExport": false}}), T); + assert_eq!(r["ok"], true, "{r}"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordView:willExport"})); + assert!(has(&h, "keywordExport:beach")); + assert!(!has(&h, "keywordExport:Weddings"), "left out of export"); + assert!(!has(&h, "keywordChip:beach"), "the chips give way"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordView:keywords"})); + assert!(has(&h, "keywordChip:beach") && !has(&h, "keywordExport:beach")); +} From b501b7fffa1fa42b1273848a722097fcb9020eab Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:22:21 -0400 Subject: [PATCH 06/19] LRC-LIB-KEYWORDS: the painter's keyword field is a shared text field "Keyword to paint" gets the Cut / Copy / Paste / Select All menu and Esc, and Return starts painting as the Paint button does. It stays fixed while painting. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/right.rs | 16 +++++++++++----- crates/ui-egui/src/tests_keywording.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f4c5fe899..f6e6d3f96 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -802,16 +802,22 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let pid = egui::Id::new("kw-painter"); let painting = app.ui.keyword_painter.clone(); let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default()); - let r = ui.add_enabled( - painting.is_none(), - egui::TextEdit::singleline(&mut k).hint_text(crate::i18n::tr("Keyword to paint")).desired_width(130.0), - ); - register(ui.ctx(), "field:keywordPainter", r.rect); + // (fixed while painting: Stop first) + let field = ui + .add_enabled_ui(painting.is_none(), |ui| { + crate::text_field::TextField::singleline("field:keywordPainter", &mut k) + .hint(crate::i18n::tr("Keyword to paint")) + .width(130.0) + .show(ui) + }) + .inner; ui.data_mut(|d| d.insert_temp(pid, k.clone())); let label = if painting.is_some() { "Stop" } else { "Paint" }; + let returned = field.ending == Some(crate::text_field::Ending::Return) && painting.is_none() && !k.trim().is_empty(); if text_button(ui, "keywordPaint", label, painting.is_some()) .on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops")) .clicked() + || returned { let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }})); } diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 3de8c53a0..167ddb4d5 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -134,3 +134,15 @@ fn will_export_shows_what_exports_carry() { ask(&mut h, "ui.clickWidget", json!({"id": "keywordView:keywords"})); assert!(has(&h, "keywordChip:beach") && !has(&h, "keywordExport:beach")); } + +/// The painter's keyword field is a shared text field: right-click ▸ Paste fills it, and Return +/// starts painting as the Paint button does. +#[test] +fn the_painters_field_pastes_and_return_paints() { + let (mut h, _) = two_selected(); + h.view.clipboard = "Weddings".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordPainter", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordPainter:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert_eq!(h.app.ui.keyword_painter.as_deref(), Some("Weddings")); +} From d6c9c324572f62d0e986cf74d6e67987da9f0a10 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:23:20 -0400 Subject: [PATCH 07/19] LRC-LIB-KEYWORDS: suggestions follow the selection Suggestions were worked out from the active photo's keywords: a keyword it had wasn't suggested though other selected photos lacked it. They now leave out only the keywords every selected photo has, and build on those for keywords used with them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/right.rs | 11 +++++++++-- crates/ui-egui/src/tests_keywording.rs | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f6e6d3f96..a649d8b5a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -767,7 +767,10 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str } fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let Some(p) = app.session.catalog.photo(id).cloned() else { return }; + // (the box is the selection's: the active photo only says that there is one) + if app.session.catalog.photo(id).is_none() { + return; + } header(ui, "Keywords"); let t = Tokens::get(ui.ctx()); padded(ui, |ui| { @@ -827,7 +830,11 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // photo's keywords, else the most used ones let typed = ui.data(|d| d.get_temp::(kid).unwrap_or_default()); let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); - let suggestions = (*app.caches.suggestions(&app.session.catalog, &p.meta.keywords, &last, 12)).clone(); + // the keywords every selected photo has: those only some have are still suggested + let selection = app.session.selection.ids.clone(); + let have: Vec = + app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); + let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); if !suggestions.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 167ddb4d5..cc4c35f86 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -146,3 +146,12 @@ fn the_painters_field_pastes_and_return_paints() { ask(&mut h, "ui.key", json!({"key": "Enter"})); assert_eq!(h.app.ui.keyword_painter.as_deref(), Some("Weddings")); } + +/// Suggestions follow the selection: a keyword only some selected photos have is suggested, to give +/// it to all of them; one every selected photo has isn't. +#[test] +fn suggestions_follow_the_selection() { + let (h, _) = two_selected(); + assert!(has(&h, "kwSuggest:Weddings"), "only the active one has it"); + assert!(!has(&h, "kwSuggest:beach"), "both have it"); +} From d73efbcb47951648f72f2593b81081915781562f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:26:07 -0400 Subject: [PATCH 08/19] LRC-LIB-KEYWORDS: chips wrap whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chip was a frame laid out in what was left of its line: one that didn't fit wrapped its own text, down to a letter a line, and pushed its × to the next line. A chip is now one widget measured before it is placed (its name cut short to fit the row), so the row moves it whole to the next line; the Will Export names alike. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keywording.rs | 97 ++++++++++++++----------- crates/ui-egui/src/tests_keywording.rs | 21 ++++++ 2 files changed, 77 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index c3e47717c..aacc342ae 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -34,22 +34,50 @@ pub fn export_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.horizontal_wrapped(|ui| { for n in names.iter() { - let partial = !n.on_all(); - let text = format!("{}{}", n.path, if partial { " *" } else { "" }); - let r = egui::Frame::NONE - .stroke(egui::Stroke::new(1.0, t.field_border)) - .corner_radius(10.0) - .inner_margin(egui::Margin::symmetric(8, 2)) - .show(ui, |ui| ui.label(egui::RichText::new(text).color(if partial { t.text_dim } else { t.text_label }))) - .response; - register(ui.ctx(), format!("keywordExport:{}", n.path), r.rect); - if partial { - r.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = n.have, of = n.of)); + let (body, _) = chip(ui, &n.path, !n.on_all(), false); + register(ui.ctx(), format!("keywordExport:{}", n.path), body.rect); + if !n.on_all() { + body.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = n.have, of = n.of)); } } }); } +/// One chip, measured before it is placed so that a wrapping row moves it whole to the next line: +/// the name (cut short to fit the row) and, with `cross`, a × at its end. A `partial` one (only +/// some of the selected photos) is dimmed and marked with an asterisk. Returns the name's response +/// and the ×'s. +fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Response, Option) { + use egui::{Align2, Rect, Sense, pos2, vec2}; + let t = Tokens::get(ui.ctx()); + let font = t.font(13.0); + let color = if partial { t.text_dim } else { t.text_label }; + let name = format!("{}{}", path.replace('|', " › "), if partial { " *" } else { "" }); + let cross_w = if cross { 18.0 } else { 0.0 }; + // at most the row's width + let room = (ui.max_rect().width() - 16.0 - cross_w).max(24.0); + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; + let shown = if measure(&name) <= room { name.clone() } else { crate::widgets::elide_head(&name, room, measure) }; + let w = measure(&shown) + 16.0 + cross_w; + let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); + } + ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); + let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom())); + let resp = if shown != name { resp.on_hover_text(path.replace('|', " › ")) } else { resp }; + let x = cross.then(|| { + let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); + let xresp = ui.interact(xr, resp.id.with("remove"), Sense::click()); + ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if xresp.hovered() { t.text } else { t.text_dim }); + xresp + }); + let mut body_resp = resp; + body_resp.rect = body; + (body_resp, x) +} + /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the /// Keyword List's). fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { @@ -77,39 +105,26 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { /// 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); + for c in chips.iter() { + let (body, x) = chip(ui, &c.path, !c.on_all(), true); + register(ui.ctx(), format!("keywordChip:{}", c.path), body.rect); + if let Some(x) = x { + register(ui.ctx(), format!("keywordChipRemove:{}", c.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]})); + let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); } - label - }); - let chip = chip.clone(); - shown.inner.context_menu(|ui| menu(app, ui, &chip)); + } + let body = if c.on_all() { + body + } else { + register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); + body.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of)) + }; + let c = c.clone(); + body.context_menu(|ui| menu(app, ui, &c)); } }); } @@ -132,8 +147,6 @@ impl Chip { } } -/// The keywords of the selected `photos`, each once whatever its case, with how many of them have -/// it; by name. /// "Will Export" (Lightroom Classic's Keyword Tags ▸ Will Export): the names exported files carry /// for the selected `photos`, as the keyword tag options say, each with how many of them carry it; /// by name. @@ -148,6 +161,8 @@ pub(crate) fn will_export(catalog: &Catalog, photos: &[PhotoId]) -> Vec { have.into_values().map(|(path, have)| Chip { path, have, of: photos.len() }).collect() } +/// 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) diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index cc4c35f86..e142c24a6 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -155,3 +155,24 @@ fn suggestions_follow_the_selection() { assert!(has(&h, "kwSuggest:Weddings"), "only the active one has it"); assert!(!has(&h, "kwSuggest:beach"), "both have it"); } + +/// Chips wrap whole: each stays on one line with its ×, however many there are and however long +/// (a chip laid out in what was left of a line used to wrap its own text, a letter a line). +#[test] +fn chips_stay_on_one_line() { + let (mut h, ids) = two_selected(); + let r = h.request( + "engine.execute", + json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["Events|Weddings", "sunset", "Places|Portugal|Lisbon", "harbour"]}}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); + assert!(chips.len() >= 5, "{chips:?}"); + for (id, r) in &chips { + assert!(r.height() < 30.0, "{id} spans lines: {r:?}"); + let x = h.app.widgets.iter().find(|(w, _)| *w == id.replace("keywordChip:", "keywordChipRemove:")).map(|(_, r)| *r).unwrap(); + assert!((x.center().y - r.center().y).abs() < 4.0, "{id}: its × is on its line"); + } +} From 03aa2edc9d86fb0b4e10523c7ebcbb2b5ed17336 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:26:28 -0400 Subject: [PATCH 09/19] LRC-LIB-KEYWORDS: document the selection-aware Keywording box The keyword rows of the parity tracker describe the box and the chip menu; What's New explains the chips, Will Export and what follows the selection. Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 4 ++-- docs/whats-new.md | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index e639467f7..c33e510ca 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -635,7 +635,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | | MENU-CTX-VERSION | Version menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (`versions`) | restore, update with current settings, rename, set as before, delete | -| MENU-CTX-KEYWORD | Keyword chip menu | P1 | ✅ | `crates/ui-egui/src/panels/right.rs`, `cmd:keyword.delete` | remove from photo, show photos with keyword, rename keyword, delete keyword | +| MENU-CTX-KEYWORD | Keyword chip menu | P1 | ✅ | `crates/ui-egui/src/panels/keywording.rs` (`menu`) | acts on the selected photos: add to all selected photos (a keyword only some have), remove from selected photos, show photos with keyword, Edit Keyword Tag…; deleting a keyword from the library is the Keyword List's | ## Z. Keyboard shortcuts (desktop) @@ -752,7 +752,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. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set, a selection-aware Keywording box | +| 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), a Keywords / Will Export switch (what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set | | 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` | 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, × to hide). 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. No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 2b8190242..69be80316 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,15 @@ ## October 2026 +### Keywording box +- The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a + keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from + every selected photo; a chip's menu acts on the selection (add to all, remove, show photos, edit) and no longer + deletes a keyword from the whole library. +- Switch to Will Export to see the keywords exported files will carry for the selection. +- The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste + menu, and Return in the painter's box starts painting. + ### Keyword List - The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no photo has yet too. Filter it, tick a keyword to give it to the selected photos (a dash: only some have it), or click From 69c91013e0c53aeeb99ef882ba367b9b3e1786dc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:32:40 -0400 Subject: [PATCH 10/19] LRC-LIB-KEYWORDS: the active photo alone is the selection Keyword commands act on the selected photos, or on the active photo when the selection is empty (`Session::targets`), but the Keywording box, the keyword set's buttons, the suggestions and the Keyword List's tick boxes read the selection only: with just an active photo the box showed nothing while typed keywords went to it. They now all use the photos the commands use. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keyword_list.rs | 2 +- crates/ui-egui/src/panels/keywording.rs | 4 ++-- crates/ui-egui/src/panels/right.rs | 4 ++-- crates/ui-egui/src/tests_keywording.rs | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index fb51a14cd..0dcf40d0c 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -72,7 +72,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim)); }); } - let selection = app.session.selection.ids.clone(); + let selection = app.session.targets(&serde_json::Value::Null); let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); for r in &rows { row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index aacc342ae..ce3b60fc2 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -26,7 +26,7 @@ pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// carry is marked with an asterisk. pub fn export_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let selection = app.session.selection.ids.clone(); + let selection = app.session.targets(&serde_json::Value::Null); let names = app.caches.keyword_export(&app.session.catalog, &selection); if names.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("No keywords are exported")).color(t.text_dim)); @@ -105,7 +105,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { /// 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 selection = app.session.selection.ids.clone(); + let selection = app.session.targets(&serde_json::Value::Null); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); ui.horizontal_wrapped(|ui| { for c in chips.iter() { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a649d8b5a..f9a2ce9e5 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -831,7 +831,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let typed = ui.data(|d| d.get_temp::(kid).unwrap_or_default()); let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); // the keywords every selected photo has: those only some have are still suggested - let selection = app.session.selection.ids.clone(); + let selection = app.session.targets(&serde_json::Value::Null); let have: Vec = app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); @@ -859,7 +859,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.selection.ids.clone(); + let selection = app.session.targets(&serde_json::Value::Null); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); let t = Tokens::get(ui.ctx()); let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index e142c24a6..a3caabe51 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -176,3 +176,17 @@ fn chips_stay_on_one_line() { assert!((x.center().y - r.center().y).abs() < 4.0, "{id}: its × is on its line"); } } + +/// With an active photo and nothing else selected, the box is that photo's, as the commands are: +/// its chips show (keywords typed go to it), and the Keyword List ticks what it has. +#[test] +fn the_active_photo_alone_is_the_selection() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [], "active": ids[0]}})); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(ids[0])); + assert!(h.app.session.selection.ids.is_empty()); + assert!(has(&h, "keywordChip:Weddings") && !has(&h, "keywordChipPartial:Weddings"), "the active photo's keywords"); + let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.targets(&json!({}))); + assert_eq!(ticks.tick("Weddings"), crate::panels::keyword_list::Tick::All); + assert!(has(&h, "keywordCheck:beach")); +} From f2f164f0128981acff2f3ec758ec5e19d3e9ac53 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:33:39 -0400 Subject: [PATCH 11/19] LRC-LIB-KEYWORDS: a set keyword toggles off whatever its case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `keyword.toggleFromSet` checked whether every target photo had the keyword by ASCII case, while the set's buttons and the rest of the keyword code match any letter's case: with "ÄRZTE" and "ärzte" on the photos the button showed on, but ⌥N added the keyword (a no-op) instead of taking it off, so it could never be toggled off. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 6 +++++- crates/engine/src/tests_keyword_list.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 3d9492499..a3862b3b4 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -184,7 +184,11 @@ pub fn specs() -> Vec { }; let ids = s.targets(p); let all = !ids.is_empty() - && ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)))); + && ids.iter().all(|id| { + s.catalog + .photo(*id) + .is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k)))) + }); let ids: Vec = ids.iter().map(|i| i.0).collect(); let key = if all { "removeKeywords" } else { "addKeywords" }; // applying from Recent Keywords mustn't reshuffle the numbers under the keys diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 3d5e21666..3084fc122 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -170,3 +170,16 @@ fn keyword_actions_count_the_photos_they_change() { assert_eq!(s.execute("keyword.rename", &json!({"from": "Marriages", "to": "Weddings"})).unwrap()["changed"], 1); assert_eq!(s.execute("keyword.delete", &json!({"keyword": "weddings"})).unwrap()["changed"], 1); } + +/// ⌥1–⌥9 take a set's keyword off the selected photos when they all have it, whatever the case of +/// any of its letters (the set has "ärzte", the photos "ÄRZTE" and "ärzte"). +#[test] +fn a_set_keyword_toggles_whatever_its_case() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(2).map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["ÄRZTE"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["ärzte"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Clinic", "keywords": ["ärzte"]})).unwrap(); + s.execute("keyword.toggleFromSet", &json!({"index": 1, "ids": ids})).unwrap(); + assert!(ids.iter().all(|id| keywords_of(&s, *id).is_empty()), "taken off both"); +} From 5bb43c79cbf4e2f88a74031aec0535dbe8958b71 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:35:10 -0400 Subject: [PATCH 12/19] LRC-LIB-KEYWORDS: a long chip keeps its name and asterisk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chip too long for its row was cut with the file-path shortener, which keeps the start: "Places › Portugal ›…" lost the keyword's own name and the asterisk of a partial one. `chip_label` now cuts from the front ("…Lisbon › Belém tower *"). A chip also had two tooltips (its whole name, and how many selected photos have it); it has one with both. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keywording.rs | 55 +++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index ce3b60fc2..6ad1364f8 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -36,8 +36,11 @@ pub fn export_row(app: &mut LightcraftApp, ui: &mut egui::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); - if !n.on_all() { - body.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = n.have, of = n.of)); + if n.on_all() { + body.on_hover_text(&n.path); + } else { + let count = crate::i18n::tr_format!("On {have} of {of} selected photos", have = n.have, of = n.of); + body.on_hover_text(format!("{}\n{count}", n.path)); } } }); @@ -52,12 +55,11 @@ fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Res let t = Tokens::get(ui.ctx()); let font = t.font(13.0); let color = if partial { t.text_dim } else { t.text_label }; - let name = format!("{}{}", path.replace('|', " › "), if partial { " *" } else { "" }); let cross_w = if cross { 18.0 } else { 0.0 }; // at most the row's width let room = (ui.max_rect().width() - 16.0 - cross_w).max(24.0); let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; - let shown = if measure(&name) <= room { name.clone() } else { crate::widgets::elide_head(&name, room, measure) }; + let shown = chip_label(path, partial, room, measure); let w = measure(&shown) + 16.0 + cross_w; let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); @@ -66,7 +68,6 @@ fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Res } ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom())); - let resp = if shown != name { resp.on_hover_text(path.replace('|', " › ")) } else { resp }; let x = cross.then(|| { let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); let xresp = ui.interact(xr, resp.id.with("remove"), Sense::click()); @@ -117,11 +118,14 @@ pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); } } + // one tooltip: the whole name (it may be cut short), and how many have it + let full = c.path.replace('|', " › "); let body = if c.on_all() { - body + body.on_hover_text(full) } else { register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); - body.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of)) + let n = crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of); + body.on_hover_text(format!("{full}\n{n}")) }; let c = c.clone(); body.context_menu(|ui| menu(app, ui, &c)); @@ -129,6 +133,30 @@ pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// A chip's label within `room` (as `measure` measures text): the keyword's path with " › " +/// between its levels, and an asterisk when it is `partial`. Too long, it is cut from the front so +/// that the keyword's own name and the asterisk stay ("…Lisbon › Belém tower *"). +pub(crate) fn chip_label(path: &str, partial: bool, room: f32, measure: impl Fn(&str) -> f32) -> String { + let full = path.replace('|', " › "); + let marker = if partial { " *" } else { "" }; + let whole = format!("{full}{marker}"); + if measure(&whole) <= room { + return whole; + } + // the longest end of the path that fits after "…" + let chars: Vec = full.chars().collect(); + let mut best = format!("…{marker}"); + for start in (0..chars.len()).rev() { + let tail: String = chars.get(start..).map(|c| c.iter().collect()).unwrap_or_default(); + let candidate = format!("…{}{marker}", tail.trim_start()); + if measure(&candidate) > room { + break; + } + best = candidate; + } + best +} + /// A keyword of the selection, as a chip shows it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Chip { @@ -241,4 +269,17 @@ mod tests { assert_eq!(shown(&will_export(&c, &ids)), [("Lisboa", 2, 2), ("Lisbon", 2, 2)]); assert!(will_export(&c, &[]).is_empty()); } + + /// A chip too long for its row is cut from the front: the keyword's own name and the asterisk + /// of a partial one stay. + #[test] + fn a_long_chip_keeps_its_name_and_asterisk() { + let chars = |s: &str| s.chars().count() as f32; + assert_eq!(chip_label("travel|Lisbon", false, 40.0, chars), "travel › Lisbon"); + assert_eq!(chip_label("travel|Lisbon", true, 40.0, chars), "travel › Lisbon *"); + let cut = chip_label("Places|Portugal|Lisbon|Belém tower", true, 24.0, chars); + assert_eq!(cut, "…Lisbon › Belém tower *"); + assert!(chars(&cut) <= 24.0); + assert!(chip_label("Places|Lisbon", false, 4.0, chars).starts_with('…'), "even with hardly any room"); + } } From 9dabd38941e6b8e3e82bdde61f6f4fd34d72bd75 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:37:16 -0400 Subject: [PATCH 13/19] LRC-LIB-KEYWORDS: keyword counts are of photos, fast with a big selection - A photo listed twice in the selection counted twice, and an id the library doesn't have counted in "of": the chips, Will Export and the Keyword List's tick boxes now count the distinct selected photos. - The chips looked each keyword's spelling up by scanning every photo (178 ms with 20,000 photos and 2,000 keywords selected, at every change while selected): they now take the keyword list's spelling (`Catalog::listed_path`) or the first one seen, 26 ms. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 4 +++ crates/ui-egui/src/panels/keyword_list.rs | 4 +-- crates/ui-egui/src/panels/keywording.rs | 41 +++++++++++++++++++---- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index a9e6acab7..c7225502b 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -382,6 +382,10 @@ impl Catalog { pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> { self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info) } + /// How the keyword list spells a listed keyword (any case), `None` when it isn't listed. + pub fn listed_path(&self, path: &str) -> Option<&str> { + self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str()) + } /// The keyword list: keywords listed on their own or given attributes, by path. pub fn listed_keywords(&self) -> impl Iterator { self.keyword_list.values() diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 0dcf40d0c..4ebc57aaf 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -477,10 +477,10 @@ pub(crate) struct Ticks { 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(); let mut seen = std::collections::HashSet::new(); - for id in photos { - let Some(p) = catalog.photo(*id) else { continue }; + for p in &photos { seen.clear(); for k in &p.meta.keywords { let key = lightcraft_catalog::keywords::clean(k).to_lowercase(); diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 6ad1364f8..64767c916 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -179,9 +179,9 @@ impl Chip { /// for the selected `photos`, as the keyword tag options say, each with how many of them carry it; /// by name. pub(crate) fn will_export(catalog: &Catalog, photos: &[PhotoId]) -> Vec { + let photos = distinct(catalog, photos); let mut have: std::collections::BTreeMap = Default::default(); - for id in photos { - let Some(p) = catalog.photo(*id) else { continue }; + for p in &photos { for name in catalog.export_keywords(&p.meta.keywords).flat { have.entry(name.to_lowercase()).or_insert_with(|| (name.clone(), 0)).1 += 1; } @@ -189,15 +189,21 @@ pub(crate) fn will_export(catalog: &Catalog, photos: &[PhotoId]) -> Vec { 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(); + photos.iter().filter(|id| seen.insert(**id)).filter_map(|id| catalog.photo(*id).map(|p| &**p)).collect() +} + /// 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 photos = distinct(catalog, photos); + // by lower-case keyword: as first seen, and 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 }; + for p in &photos { seen.clear(); for k in &p.meta.keywords { let k = clean(k); @@ -207,7 +213,8 @@ pub(crate) fn chips(catalog: &Catalog, photos: &[PhotoId]) -> Vec { 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() + // spelled as the keyword list spells it, else as first seen (no scan of the library) + have.into_values().map(|(k, n)| Chip { path: catalog.listed_path(&k).map(str::to_string).unwrap_or(k), have: n, of: photos.len() }).collect() } #[cfg(test)] @@ -282,4 +289,26 @@ mod tests { assert!(chars(&cut) <= 24.0); assert!(chip_label("Places|Lisbon", false, 4.0, chars).starts_with('…'), "even with hardly any room"); } + + /// A photo listed twice in the selection counts once, and one that isn't in the library not at + /// all: "On 1 of 2", not "On 2 of 4". + #[test] + fn the_counts_are_of_photos_not_ids() { + let (c, ids) = library(&[&["beach"], &["sea"]]); + let selection = [ids[0], ids[0], ids[1], PhotoId(999)]; + assert_eq!(shown(&chips(&c, &selection)), [("beach", 1, 2), ("sea", 1, 2)]); + assert_eq!(shown(&will_export(&c, &selection)), [("beach", 1, 2), ("sea", 1, 2)]); + let ticks = crate::panels::keyword_list::Ticks::of(&c, &selection); + assert_eq!(ticks.tick("beach"), crate::panels::keyword_list::Tick::Some); + assert_eq!(crate::panels::keyword_list::Ticks::of(&c, &[ids[0], ids[0]]).tick("beach"), crate::panels::keyword_list::Tick::All); + } + + /// A keyword the Keyword List has is spelled as it spells it. + #[test] + fn a_listed_keyword_is_spelled_as_listed() { + use lightcraft_catalog::keywords::KeywordInfo; + let (mut c, ids) = library(&[&["beach"]]); + c.apply(Op::SetKeyword { path: "Beach".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(shown(&chips(&c, &ids)), [("Beach", 1, 1)]); + } } From f5b1c09e4249e963156cc67c6270f5d613fc229f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:39:04 -0400 Subject: [PATCH 14/19] LRC-LIB-KEYWORDS: chips speak, and keep their menu with their keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawn as one widget, a chip and its × lost the names a button gave them: screen readers met unlabeled buttons. The chip is now named after its whole keyword (even when cut short on screen) and the × "Remove “…”". Both answer under ids made from their keyword rather than their place in the row, so a chip's open menu stays with its keyword when the chips change underneath it (an undo, a sync). 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/panels/keywording.rs | 55 ++++++++++++++++----- 8 files changed, 57 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 43afc32fb..fd5e19861 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -214,5 +214,6 @@ "“{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", - "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos" + "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos", + "Remove “{name}”": "„{name}“ entfernen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index f7109ab26..dfd320993 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -214,5 +214,6 @@ "“{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{}", - "On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas" + "On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas", + "Remove “{name}”": "Quitar “{name}”" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 589509c4c..cab18a74a 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -214,5 +214,6 @@ "“{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}", - "On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚" + "On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚", + "Remove “{name}”": "「{name}」を削除" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index d4d8441bb..5215af8b3 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -214,5 +214,6 @@ "“{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{}", - "On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas" + "On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas", + "Remove “{name}”": "Remover “{name}”" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f0e819db9..b59e94746 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -214,5 +214,6 @@ "“{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}", - "On {have} of {of} selected photos": "На {have} из {of} выбранных фото" + "On {have} of {of} selected photos": "На {have} из {of} выбранных фото", + "Remove “{name}”": "Убрать «{name}»" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 9d9b6b42a..3d32f0968 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -214,5 +214,6 @@ "“{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}", - "On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张" + "On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张", + "Remove “{name}”": "移除“{name}”" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 29203443f..dfe74be2a 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -214,5 +214,6 @@ "“{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}", - "On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張" + "On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張", + "Remove “{name}”": "移除「{name}」" } diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 64767c916..336378156 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -61,22 +61,30 @@ fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Res let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; let shown = chip_label(path, partial, room, measure); let w = measure(&shown) + 16.0 + cross_w; - let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + // the row places it; its name and × answer under ids of their own keyword, so an open menu + // stays with its keyword when the chips change + let (rect, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + let id = egui::Id::new(("keyword-chip", path.to_lowercase(), cross)); + let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom())); + let full = path.replace('|', " › "); + let resp = ui.interact(body, id, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &full)); + let x = cross.then(|| { + let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); + let xresp = ui.interact(xr, id.with("remove"), Sense::click()); + let said = crate::i18n::tr_format!("Remove “{name}”", name = full); + xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &said)); + xresp + }); ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - if resp.hovered() { + if resp.hovered() || x.as_ref().is_some_and(egui::Response::hovered) { ui.painter().rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); } ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); - let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom())); - let x = cross.then(|| { - let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); - let xresp = ui.interact(xr, resp.id.with("remove"), Sense::click()); - ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if xresp.hovered() { t.text } else { t.text_dim }); - xresp - }); - let mut body_resp = resp; - body_resp.rect = body; - (body_resp, x) + if let Some(x) = &x { + ui.painter().text(x.rect.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if x.hovered() { t.text } else { t.text_dim }); + } + (resp, x) } /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the @@ -311,4 +319,27 @@ mod tests { c.apply(Op::SetKeyword { path: "Beach".into(), info: Some(KeywordInfo::default()) }).unwrap(); assert_eq!(shown(&chips(&c, &ids)), [("Beach", 1, 1)]); } + + /// Screen readers hear a chip by its whole keyword (even when cut short on screen) and its × + /// as removing that keyword. + #[test] + fn chips_describe_themselves() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut found = Vec::new(); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.set_max_width(120.0); + let _ = chip(ui, "Places|Portugal|Lisbon|Belém tower", true, true); + }); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + let has = |role: &str, label: &str| found.iter().any(|(r, l)| r == role && l == label); + assert!(has("Button", "Places › Portugal › Lisbon › Belém tower"), "{found:?}"); + assert!(has("Button", "Remove “Places › Portugal › Lisbon › Belém tower”"), "{found:?}"); + } } From c2bc46d7473a7f04660d09c110eadd6a2e5907e4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:39:54 -0400 Subject: [PATCH 15/19] LRC-LIB-KEYWORDS: the chip layout test checks what matters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `chips_stay_on_one_line` held by construction once chips were drawn at a fixed height with their × on their own line. It now checks that every chip is on one line inside the panel and that none overlaps another, with a keyword longer than the panel among them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tests_keywording.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a3caabe51..ffc37bb0e 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -156,24 +156,29 @@ fn suggestions_follow_the_selection() { assert!(!has(&h, "kwSuggest:beach"), "both have it"); } -/// Chips wrap whole: each stays on one line with its ×, however many there are and however long -/// (a chip laid out in what was left of a line used to wrap its own text, a letter a line). +/// Chips wrap whole and stay inside the panel: none overlaps another or runs past the panel's +/// edge, and one longer than the panel is shortened to fit (a chip laid out in what was left of a +/// line used to wrap its own text, a letter a line, over the others). #[test] -fn chips_stay_on_one_line() { +fn chips_wrap_whole_inside_the_panel() { let (mut h, ids) = two_selected(); + let long = "Places|Portugal|Lisbon|Belém tower and the old town square on the river"; let r = h.request( "engine.execute", - json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["Events|Weddings", "sunset", "Places|Portugal|Lisbon", "harbour"]}}), + json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["Events|Weddings", "sunset", "Places|Portugal|Lisbon", "harbour", long]}}), T, ); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); + let panel = h.app.widgets.iter().find(|(w, _)| w == "panel:right_panel").map(|(_, r)| *r).expect("the right panel"); let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); - assert!(chips.len() >= 5, "{chips:?}"); - for (id, r) in &chips { - assert!(r.height() < 30.0, "{id} spans lines: {r:?}"); - let x = h.app.widgets.iter().find(|(w, _)| *w == id.replace("keywordChip:", "keywordChipRemove:")).map(|(_, r)| *r).unwrap(); - assert!((x.center().y - r.center().y).abs() < 4.0, "{id}: its × is on its line"); + assert!(chips.len() >= 6, "{chips:?}"); + for (i, (id, r)) in chips.iter().enumerate() { + assert!(r.height() < 30.0, "{id} on one line: {r:?}"); + assert!(r.left() >= panel.left() && r.right() <= panel.right(), "{id} inside the panel: {r:?} in {panel:?}"); + for (other, o) in chips.iter().skip(i + 1) { + assert!(!r.intersects(o.shrink(0.5)), "{id} overlaps {other}"); + } } } From fe02cfe2e1326f673d885f2807954074cc90a131 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:40:33 -0400 Subject: [PATCH 16/19] LRC-LIB-KEYWORDS: Esc in Add keyword gives back the text from before The box emptied itself on Esc, on top of the shared field's own undo of the edit: text left there by an earlier edit (ended by clicking away) went too. Esc now gives back what the box held before the edit, as every text field does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/right.rs | 6 ++---- crates/ui-egui/src/tests_keywording.rs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f9a2ce9e5..3afbdf1bb 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -778,10 +778,8 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); let r = crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); - // Return gives the keywords to the selected photos; Esc gives up what was typed - if r.cancelled() { - text.clear(); - } + // Return gives the keywords to the selected photos; Esc gives back what was there before + // this edit (the shared field does) if r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty() { // a new name goes inside the default parent (Put New Keywords Inside This Keyword) let kws: Vec = text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect(); diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index ffc37bb0e..93352650a 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -195,3 +195,21 @@ fn the_active_photo_alone_is_the_selection() { assert_eq!(ticks.tick("Weddings"), crate::panels::keyword_list::Tick::All); assert!(has(&h, "keywordCheck:beach")); } + +/// 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] +fn escape_gives_back_the_text_from_before_the_edit() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": ", sea"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + let k = keywords_of(&h, ids[0]); + assert!(k.contains(&"harbour".to_string()) && !k.contains(&"sea".to_string()), "{k:?}"); +} From 4b5c7ccd3ca361f7fd4f1b5e6fc8532bfc95c067 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:42:40 -0400 Subject: [PATCH 17/19] LRC-LIB-KEYWORDS: a Keywords & Containing Keywords view Lightroom Classic's Keywording box has three views; the box had two. The switch now offers Keywords, & Containing (the selection's keywords with the keywords containing them, flat and read only) and Will Export, the two read-only views cached per selection. The label check covers the box's other labels passed through variables too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/lib.rs | 23 +++++---- crates/ui-egui/src/panels/keyword_list.rs | 3 ++ crates/ui-egui/src/panels/keywording.rs | 57 ++++++++++++++++++----- crates/ui-egui/src/panels/right.rs | 6 +-- crates/ui-egui/src/state.rs | 18 +++++-- crates/ui-egui/src/tests_keywording.rs | 14 ++++++ 13 files changed, 109 insertions(+), 33 deletions(-) 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"); +} From 9ae5a5f88240fdc1ea402226ffd4ae42da7741fd Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:42:51 -0400 Subject: [PATCH 18/19] LRC-LIB-KEYWORDS: clearer German and Japanese for Will Export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit German says "Es werden keine Stichwörter exportiert", the natural word order; Japanese names the view "書き出されるキーワード" (the keywords that will be exported) rather than the vaguer "書き出される内容". Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 366bdfbc0..dba70e1ad 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1914,6 +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": "Es werden keine Stichwörter exportiert", "& Containing": "& Übergeordnete" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 21735388e..ed484a305 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1758,7 +1758,7 @@ "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。", "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。", "Add to All Selected Photos": "選択したすべての写真に追加", - "Will Export": "書き出される内容", + "Will Export": "書き出されるキーワード", "No keywords are exported": "書き出されるキーワードはありません", "& Containing": "と親キーワード" } From dbf2c38ef364409468185dff7328f940928e0f1c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:43:14 -0400 Subject: [PATCH 19/19] LRC-LIB-KEYWORDS: document the Keywording box's three views Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index c33e510ca..276db5cfe 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -752,7 +752,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), a Keywords / Will Export switch (what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set | +| 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. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set | | 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` | 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, × to hide). 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. No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 69be80316..7e40a0c7f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,7 +7,8 @@ keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from every selected photo; a chip's menu acts on the selection (add to all, remove, show photos, edit) and no longer deletes a keyword from the whole library. -- Switch to Will Export to see the keywords exported files will carry for the selection. +- Switch to & Containing to see the keywords with the keywords containing them, or to Will Export to see what + exported files will carry for the selection. - The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste menu, and Return in the painter's box starts painting.