# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-09 20:55:49 -04:00
26 changed files with 813 additions and 59 deletions
+4
View File
@@ -432,6 +432,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<Item = &keywords::ListedKeyword> {
self.keyword_list.values()
+5 -1
View File
@@ -184,7 +184,11 @@ pub fn specs() -> Vec<CommandSpec> {
};
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<u64> = 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
+13
View File
@@ -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<u64> = 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");
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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",
"Remove “{name}”": "„{name}“ entfernen"
}
+5 -1
View File
@@ -1974,5 +1974,9 @@
"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",
"Will Export": "Wird exportiert",
"No keywords are exported": "Es werden keine Stichwörter exportiert",
"& Containing": "& Übergeordnete"
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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",
"Remove “{name}”": "Quitar “{name}”"
}
+5 -1
View File
@@ -1819,5 +1819,9 @@
"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",
"Will Export": "Se exportará",
"No keywords are exported": "No se exporta ninguna palabra clave",
"& Containing": "y contenedoras"
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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}枚",
"Remove “{name}”": "「{name}」を削除"
}
+5 -1
View File
@@ -1821,5 +1821,9 @@
"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": "選択したすべての写真に追加",
"Will Export": "書き出されるキーワード",
"No keywords are exported": "書き出されるキーワードはありません",
"& Containing": "と親キーワード"
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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",
"Remove “{name}”": "Remover “{name}”"
}
+5 -1
View File
@@ -1819,5 +1819,9 @@
"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",
"Will Export": "Será exportado",
"No keywords are exported": "Nenhuma palavra-chave é exportada",
"& Containing": "e as que as contêm"
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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} выбранных фото",
"Remove “{name}”": "Убрать «{name}»"
}
+5 -1
View File
@@ -1529,5 +1529,9 @@
"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": "Добавить ко всем выбранным фото",
"Will Export": "Будет экспортировано",
"No keywords are exported": "Ключевые слова не экспортируются",
"& Containing": "и родительские"
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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} 张",
"Remove “{name}”": "移除“{name}”"
}
+5 -1
View File
@@ -1819,5 +1819,9 @@
"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": "添加到所有所选照片",
"Will Export": "将导出",
"No keywords are exported": "没有要导出的关键字",
"& Containing": "及上级"
}
+3 -1
View File
@@ -213,5 +213,7 @@
"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} 張",
"Remove “{name}”": "移除「{name}」"
}
+5 -1
View File
@@ -1819,5 +1819,9 @@
"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": "加入所有選取的照片",
"Will Export": "將匯出",
"No keywords are exported": "沒有要匯出的關鍵字",
"& Containing": "及上層"
}
+45
View File
@@ -52,6 +52,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;
@@ -1093,6 +1095,11 @@ mod drop_tests {
#[derive(Default)]
pub struct Caches {
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
/// 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.
keyword_ticks: Option<(u64, u64, std::sync::Arc<panels::keyword_list::Ticks>)>,
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
@@ -1195,6 +1202,44 @@ impl Caches {
}
}
}
/// 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, export));
match &self.keyword_names {
Some((r, k, c)) if *r == cat.revision && *k == 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
}
}
}
/// 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<Vec<panels::keywording::Chip>> {
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,
+8 -3
View File
@@ -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());
@@ -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<String, usize> = 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();
@@ -593,6 +593,11 @@ mod tests {
"No keywords match",
"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"] {
+380
View File
@@ -0,0 +1,380 @@
//! 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 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) {
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_view = view;
}
}
});
}
/// 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 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(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!("{prefix}:{}", n.path), body.rect);
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));
}
}
});
}
/// 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<egui::Response>) {
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 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 = chip_label(path, partial, room, measure);
let w = measure(&shown) + 16.0 + cross_w;
// 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() || 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);
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
/// 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) {
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() {
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": [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.on_hover_text(full)
} else {
register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect);
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));
}
});
}
/// 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<char> = 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 {
/// 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
}
}
/// "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<Chip> {
let photos = distinct(catalog, photos);
let mut have: std::collections::BTreeMap<String, (String, usize)> = Default::default();
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;
}
}
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();
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<Chip> {
use lightcraft_catalog::keywords::clean;
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<String, (String, usize)> = Default::default();
let mut seen = std::collections::HashSet::new();
for p in &photos {
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;
}
}
// 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)]
mod tests {
use lightcraft_catalog::{Op, Photo, Source};
use super::*;
fn library(keywords: &[&[&str]]) -> (Catalog, Vec<PhotoId>) {
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)]);
}
/// "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());
}
/// 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");
}
/// 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)]);
}
/// 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:?}");
}
/// "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)]);
}
}
+1
View File
@@ -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;
+46 -39
View File
@@ -810,15 +810,20 @@ 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| {
let kid = egui::Id::new("kw-input");
let mut text = ui.data_mut(|d| d.get_temp::<String>(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 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<String> = text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect();
let _ = app.run("photo.setMeta", json!({"addKeywords": kws}));
@@ -826,49 +831,37 @@ 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::view_switch(app, ui);
ui.add_space(4.0);
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, &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| {
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) }}));
}
@@ -878,7 +871,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::<String>(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.targets(&serde_json::Value::Null);
let have: Vec<String> =
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| {
@@ -901,7 +898,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.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);
let current = sets["current"].as_str().unwrap_or_default().to_string();
@@ -945,8 +945,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],
@@ -954,6 +956,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}));
}
+14
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,6 +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>,
/// 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).
@@ -795,6 +808,7 @@ impl Default for UiState {
slideshow: None,
second_window: false,
keyword_painter: None,
keywording_view: KeywordingView::default(),
keyword_list_open: Vec::new(),
keyword_list_selected: None,
dragging_keyword: None,
+229
View File
@@ -0,0 +1,229 @@
//! 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<u64>) {
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<u64> = 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<String> {
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::<Vec<_>>()
);
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");
}
/// 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);
}
/// 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");
}
/// 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"))));
}
/// "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"));
}
/// 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"));
}
/// 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");
}
/// 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_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", 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() >= 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}");
}
}
}
/// 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"));
}
/// 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:?}");
}
/// "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");
}
+2 -2
View File
@@ -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; 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
+10
View File
@@ -38,6 +38,16 @@
means no (it used to count as yes), and a value that is neither yes nor no is refused.
- Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.
### 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 & 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.
### 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