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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 15:42:40 -04:00
co-authored by Claude Opus 5.5
parent fe02cfe2e1
commit 4b5c7ccd3c
13 changed files with 109 additions and 33 deletions
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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": "と親キーワード"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -1433,5 +1433,6 @@
"Person": "Человек",
"Add to All Selected Photos": "Добавить ко всем выбранным фото",
"Will Export": "Будет экспортировано",
"No keywords are exported": "Ключевые слова не экспортируются"
"No keywords are exported": "Ключевые слова не экспортируются",
"& Containing": "и родительские"
}
+2 -1
View File
@@ -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": "及上级"
}
+2 -1
View File
@@ -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": "及上層"
}
+15 -8
View File
@@ -1080,8 +1080,9 @@ mod drop_tests {
#[derive(Default)]
pub struct Caches {
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
/// The Keywording box's Will Export names, by library revision and selection.
keyword_export: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>,
/// 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<Vec<panels::keywording::Chip>>)>,
/// The Keywording box's chips, by library revision and selection.
keyword_chips: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>,
/// 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<Vec<panels::keywording::Chip>> {
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
}
}
@@ -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"] {
+46 -11
View File
@@ -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<Chip> {
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<Chip> {
use lightcraft_catalog::keywords::{SEP, clean};
let photos = distinct(catalog, photos);
let mut have: std::collections::BTreeMap<String, (String, usize)> = 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)]);
}
}
+3 -3
View File
@@ -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);
+14 -4
View File
@@ -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<String>,
/// 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<String>,
/// 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,
+14
View File
@@ -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");
}