LRC-LIB-KEYWORDS: the Add keyword box offers the library's keywords under itself

What matches the text being typed (after the last comma) is offered right
under the box instead of in the Suggestions block further down: Down / Up
move through the offers, Return or a click gives the highlighted one (with
the keywords typed before it) and leaves the cursor in the box. With none
highlighted, Return gives what was typed, as before.

A name the library doesn't have is announced as a new keyword, with the
keyword it would go inside, before Return creates it.

The offers have a cache of their own: they and the suggestions are both
asked for on every frame of typing, and each looks through the library's
keywords.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-14 07:52:50 -04:00
co-authored by Claude Opus 5.5
parent 366e6891a0
commit 5ca5e0547c
15 changed files with 423 additions and 15 deletions
+4
View File
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} von {total} MB",
"{pct} %": "{pct} %",
"+{n} more": "+{n} weitere",
"Create the keyword “{name}”": "Stichwort „{name}“ erstellen",
"new, inside {parent}": "neu, in {parent}",
"Keywords of {n} selected photos": "Stichwörter von {n} ausgewählten Fotos",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "„{name}“ von {n} Fotos entfernt. Bearbeiten → Rückgängig stellt es wieder her.",
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.",
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
+4
View File
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} de {total} MB",
"{pct} %": "{pct} %",
"+{n} more": "+{n} más",
"Create the keyword “{name}”": "Crear la palabra clave “{name}”",
"new, inside {parent}": "nueva, dentro de {parent}",
"Keywords of {n} selected photos": "Palabras clave de {n} fotos seleccionadas",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” quitada de {n} fotos. Editar → Deshacer la devuelve.",
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.",
"{what} merge cancelled": "Combinación {what} cancelada",
+4
View File
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} sur {total} Mo",
"{pct} %": "{pct} %",
"+{n} more": "+{n} autres",
"Create the keyword “{name}”": "Créer le mot-clé « {name} »",
"new, inside {parent}": "nouveau, dans {parent}",
"Keywords of {n} selected photos": "Mots-clés de {n} photos sélectionnées",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "« {name} » retiré de {n} photos. Modifier → Annuler le rétablit.",
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.",
"{what} merge cancelled": "Fusion {what} annulée",
+4
View File
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} / {total} MB",
"{pct} %": "{pct}%",
"+{n} more": "ほか{n}件",
"Create the keyword “{name}”": "キーワード「{name}」を作成",
"new, inside {parent}": "新規、{parent} の中",
"Keywords of {n} selected photos": "選択した写真{n}枚のキーワード",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "「{name}」を写真{n}枚から削除しました。「編集」→「取り消す」で元に戻せます。",
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。",
"{what} merge cancelled": "{what}合成をキャンセルしました",
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} de {total} MB",
"{pct} %": "{pct}%",
"+{n} more": "+{n} mais",
"Create the keyword “{name}”": "Criar a palavra-chave “{name}”",
"new, inside {parent}": "nova, dentro de {parent}",
"Keywords of {n} selected photos": "Palavras-chave de {n} fotos selecionadas",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” removida de {n} fotos. Editar → Desfazer a traz de volta.",
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.",
"{what} merge cancelled": "{what} mesclagem cancelada",
+4
View File
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} из {total} МБ",
"{pct} %": "{pct} %",
"+{n} more": "ещё {n}",
"Create the keyword “{name}”": "Создать ключевое слово «{name}»",
"new, inside {parent}": "новое, внутри {parent}",
"Keywords of {n} selected photos": "Ключевые слова выбранных фото: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» убрано с фото: {n}. Правка → Отменить вернёт его.",
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.",
"{what} merge cancelled": "{what}: слияние отменено",
+4
View File
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done} з {total} МБ",
"{pct} %": "{pct} %",
"+{n} more": "+{n} ще",
"Create the keyword “{name}”": "Створити ключове слово «{name}»",
"new, inside {parent}": "нове, всередині {parent}",
"Keywords of {n} selected photos": "Ключові слова вибраних фото: {n}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» вилучено з фото: {n}. Редагування → Скасувати поверне його.",
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.",
"{what} merge cancelled": "Об’єднання {what} скасовано",
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done}/{total} MB",
"{pct} %": "{pct}%",
"+{n} more": "还有 {n} 项",
"Create the keyword “{name}”": "创建关键字“{name}”",
"new, inside {parent}": "新关键字,位于 {parent} 内",
"Keywords of {n} selected photos": "所选 {n} 张照片的关键字",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已从 {n} 张照片中移除“{name}”。编辑 → 撤销可恢复。",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。",
"{what} merge cancelled": "已取消 {what} 合并",
@@ -235,6 +235,10 @@
"{done} of {total} MB": "{done}/{total} MB",
"{pct} %": "{pct}%",
"+{n} more": "還有 {n} 項",
"Create the keyword “{name}”": "建立關鍵字「{name}」",
"new, inside {parent}": "新關鍵字,位於 {parent} 內",
"Keywords of {n} selected photos": "選取的 {n} 張照片的關鍵字",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已從 {n} 張照片移除「{name}」。編輯 → 復原可恢復。",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。",
"{what} merge cancelled": "已取消{what}合併",
+22
View File
@@ -1521,6 +1521,9 @@ pub struct Caches {
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
/// What the Add keyword box offers for the text typed in it, by library revision, the
/// selection's keywords and that text.
completions: Option<(u64, std::sync::Arc<panels::keywording::Offers>)>,
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
@@ -1771,6 +1774,25 @@ impl Caches {
}
}
}
/// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart
/// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks
/// through the library's keywords.
pub(crate) fn completions(
&mut self,
cat: &lightcraft_catalog::Catalog,
current: &[String],
typed: &str,
) -> std::sync::Arc<panels::keywording::Offers> {
let k = key_of((cat.revision, current, typed));
match &self.completions {
Some((h, v)) if *h == k => v.clone(),
_ => {
let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed));
self.completions = Some((k, v.clone()));
v
}
}
}
/// Keyword suggestions for a photo with `current` keywords and the typed `prefix`.
pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> {
let k = key_of((cat.revision, current, prefix, n));
+163
View File
@@ -289,6 +289,135 @@ pub(crate) fn chip_label(path: &str, partial: bool, room: f32, measure: impl Fn(
best
}
/// The Add keyword box offers at most this many of the library's keywords.
pub(crate) const OFFERS: usize = 8;
/// What the Add keyword box offers under itself for the text typed in it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct Offers {
/// The library's keywords matching the text (those starting with it first, then the most
/// used), without the ones every selected photo has.
pub keywords: Vec<String>,
/// The keyword Return would create, when the library has none of that name: where it would go
/// (inside the default parent, if there is one).
pub create: Option<String>,
}
/// The text being typed in the Add keyword box: what follows its last comma.
pub(crate) fn typing(text: &str) -> &str {
text.rsplit(',').next().unwrap_or("").trim()
}
/// The keywords typed before the last comma, each where the library would put it.
pub(crate) fn typed_before(catalog: &Catalog, text: &str) -> Vec<String> {
let Some((before, _)) = text.rsplit_once(',') else { return Vec::new() };
before.split(',').map(|s| catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()
}
/// What the box offers for `typed`, for photos that all have the `current` keywords.
pub(crate) fn offers(catalog: &Catalog, current: &[String], typed: &str) -> Offers {
let name = lightcraft_catalog::keywords::clean(typed);
if name.is_empty() {
return Offers::default();
}
let keywords = catalog.keyword_suggestions(current, typed, OFFERS);
let create = (!catalog.has_keyword(&name)).then(|| catalog.typed_keyword(typed));
Offers { keywords, create }
}
/// What was chosen from the offers under the Add keyword box.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Picked {
/// One of the library's keywords.
Keyword(String),
/// The new keyword, as typed.
Typed,
}
/// The offers under the Add keyword box (`below` is the box): the library's keywords matching
/// what is typed, each by its own name with the keyword it is inside after it, the one at `at`
/// highlighted (↑ / ↓); then, for a name the library doesn't have, the keyword Return creates.
/// Returns the offer clicked and where the offers were drawn.
pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at: Option<usize>) -> (Option<Picked>, egui::Rect) {
use egui::{Align2, Sense, pos2, vec2};
use lightcraft_catalog::keywords::SEP;
let t = Tokens::get(ui.ctx());
let mut picked = None;
let width = (below.width() - 10.0).max(60.0);
let area = egui::Area::new(egui::Id::new("keyword-offers"))
.order(egui::Order::Foreground)
.fixed_pos(below.left_bottom() + vec2(0.0, 2.0))
.constrain(true);
let shown = area.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| {
ui.set_width(width);
ui.spacing_mut().item_spacing.y = 0.0;
// a line: the keyword's own name, then (dimmer, cut from the front) where it is
let line = |ui: &mut egui::Ui, id: egui::Id, name: &str, place: &str, said: &str, lit: bool| {
let (r, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover());
let resp = ui.interact(r, id, Sense::click()).union(resp);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, lit, said));
if lit || resp.hovered() {
ui.painter().rect_filled(r, 4.0, if lit { t.pressed } else { t.hover });
}
let font = t.font(13.0);
let measure = |s: &str, f: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), f.clone(), t.text).size().x;
let name = chip_label(name, false, width - 16.0, |s| measure(s, &font));
let end = r.left() + 8.0 + measure(&name, &font);
ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &name, font, t.text);
let small = t.font(11.5);
let room = r.right() - 8.0 - (end + 10.0);
if !place.is_empty() && room >= 30.0 {
let place = chip_label(place, false, room, |s| measure(s, &small));
ui.painter().text(pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, place, small, t.text_label);
}
resp
};
for (i, k) in offers.keywords.iter().enumerate() {
let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k));
let whole = k.replace(SEP, " › ");
let lit = at == Some(i);
let r = line(ui, egui::Id::new(("keyword-offer", k.to_lowercase())), name, place, &whole, lit);
register(ui.ctx(), format!("kwComplete:{k}"), r.rect);
if lit {
register(ui.ctx(), format!("kwCompleteAt:{k}"), r.rect);
}
if r.clicked() {
picked = Some(Picked::Keyword(k.clone()));
}
}
if let Some(new) = &offers.create {
if !offers.keywords.is_empty() {
ui.add_space(3.0);
let (r, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, t.field_border);
ui.add_space(3.0);
}
let (place, name) = new.rsplit_once(SEP).unwrap_or(("", new));
let whole = new.replace(SEP, " › ");
let (place, said) = if place.is_empty() {
(crate::i18n::tr("new keyword").to_string(), crate::i18n::tr_format!("Create the keyword “{name}”", name = whole))
} else {
let inside = place.replace(SEP, " › ");
(
crate::i18n::tr_format!("new, inside {parent}", parent = inside),
crate::i18n::tr_format!("Create the keyword “{name}”", name = whole),
)
};
// (its place is said in words: not a path to cut from the front)
let r = line(ui, egui::Id::new("keyword-offer-create"), name, "", &said, false);
let small = t.font(11.5);
ui.painter().text(pos2(r.rect.right() - 8.0, r.rect.center().y), Align2::RIGHT_CENTER, place, small, t.text_label);
register(ui.ctx(), "kwCompleteCreate", r.rect);
if r.on_hover_text(crate::i18n::tr("Return creates this keyword and gives it to the selected photos")).clicked() {
picked = Some(Picked::Typed);
}
}
});
});
(picked, shown.response.rect)
}
/// Keywords of the selection that are inside the same keyword, as the grouped box shows them.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Group {
@@ -689,6 +818,40 @@ mod tests {
assert!(lines(&[(true, vec![])], 100.0, 6.0, false).is_empty(), "a group without chips has no heading");
}
/// Given a library with Places › Lisbon, the box offers it for "lis" (not for photos that all
/// have it), says a name the library lacks would be created (inside the default parent when
/// there is one), and offers nothing for nothing.
#[test]
fn the_box_offers_what_matches_and_says_what_is_new() {
use lightcraft_catalog::keywords::KeywordInfo;
let (mut c, _) = library(&[&["Places|Lisbon", "sea"], &["Places|Porto"]]);
let got = offers(&c, &[], "lis");
assert_eq!(got.keywords, ["Places|Lisbon"]);
assert_eq!(got.create.as_deref(), Some("lis"), "“lis” itself is no keyword");
assert!(offers(&c, &["Places|Lisbon".into()], "lis").keywords.is_empty(), "they all have it");
assert_eq!(offers(&c, &[], " SEA ").create, None, "the library has sea");
assert_eq!(offers(&c, &[], "places").create, None, "and Places, which holds keywords");
assert_eq!(offers(&c, &[], " "), Offers::default());
assert_eq!(offers(&c, &[], " | "), Offers::default());
c.apply(Op::SetKeyword { path: "Inbox".into(), info: Some(KeywordInfo { new_keywords_inside: true, ..KeywordInfo::default() }) }).unwrap();
assert_eq!(offers(&c, &[], "harbour").create.as_deref(), Some("Inbox|harbour"));
assert_eq!(offers(&c, &[], "a|b").create.as_deref(), Some("a|b"), "a path typed whole goes where it says");
assert!(offers(&c, &[], "p").keywords.len() <= OFFERS);
}
/// What is typed is what follows the last comma; what came before it goes along when an offer
/// is taken.
#[test]
fn typing_is_what_follows_the_last_comma() {
let (c, _) = library(&[&["sea"]]);
assert_eq!(typing("sea, lis "), "lis");
assert_eq!(typing("lis"), "lis");
assert_eq!(typing("sea,"), "");
assert_eq!(typing(""), "");
assert_eq!(typed_before(&c, "sea, , beach ,lis"), ["sea", "beach"]);
assert!(typed_before(&c, "lis").is_empty());
}
/// The grouped box's suggestions say a keyword's own name; two with the same name say their
/// whole paths, so they can be told apart.
#[test]
+65 -14
View File
@@ -862,15 +862,75 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
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());
// the keywords every selected photo has: those only some have are still offered and 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();
// while the box has the cursor it offers the library's keywords for what is being typed,
// right under itself; it stays for a click on one of them, which takes the cursor away
let field = crate::text_field::id("field:keyword");
let oid = egui::Id::new("kw-offers");
let pass = ui.ctx().cumulative_pass_nr();
let (typed_then, at_then, drawn, drawn_pass): (String, Option<usize>, Rect, u64) =
ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0));
let clicking = drawn_pass.checked_add(1) == Some(pass)
&& ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p)));
let typed = super::keywording::typing(&text).to_string();
let offers = (ui.memory(|m| m.has_focus(field)) || clicking)
.then(|| app.caches.completions(&app.session.catalog, &have, &typed))
.filter(|o| !o.keywords.is_empty() || o.create.is_some());
// ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes
// back to none: Return then gives what was typed, as without offers); Return gives the
// highlighted one
let mut at = at_then.filter(|_| typed_then == typed).filter(|a| offers.as_ref().is_some_and(|o| *a < o.keywords.len()));
let mut picked = None;
if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) {
let pressed = |ui: &mut egui::Ui, key| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key));
if pressed(ui, egui::Key::ArrowDown) {
at = Some(at.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)));
}
if pressed(ui, egui::Key::ArrowUp) {
at = at.and_then(|a| a.checked_sub(1));
}
if let Some(k) = at.and_then(|a| o.keywords.get(a))
&& pressed(ui, egui::Key::Enter)
{
picked = Some(super::keywording::Picked::Keyword(k.clone()));
}
}
let r =
crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui);
let mut drawn = Rect::NOTHING;
if let Some(o) = &offers {
let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at);
drawn = rect;
picked = picked.or(clicked);
}
ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass)));
// 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() {
let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty();
let adding: Option<Vec<String>> = match picked {
// an offer: it, and the keywords typed before the last comma
Some(super::keywording::Picked::Keyword(k)) => {
let mut kws = super::keywording::typed_before(&app.session.catalog, &text);
kws.push(k);
Some(kws)
}
// 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();
Some(super::keywording::Picked::Typed) => {
Some(text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect())
}
None if returned => Some(text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()),
None => None,
};
if let Some(kws) = adding {
let _ = app.run("photo.setMeta", json!({"addKeywords": kws}));
text.clear();
// an offer taken leaves the cursor in the box, for the next keyword
if !returned {
ui.memory_mut(|m| m.request_focus(field));
}
}
ui.data_mut(|d| d.insert_temp(kid, text));
ui.add_space(8.0);
@@ -910,15 +970,9 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
});
ui.add_space(10.0);
// suggestions: completions of the typed text, else keywords used together with this
// 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();
// 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();
// suggestions: keywords used together with the selection's keywords, else the most used
// ones (what matches the text being typed is offered under the box itself)
let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone();
if !suggestions.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim));
// grouped: the keyword's own name, its whole path in the tooltip
@@ -936,9 +990,6 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
register(ui.ctx(), format!("kwSuggest:{k}"), r.rect);
if r.clicked() {
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
if !last.is_empty() {
ui.data_mut(|d| d.insert_temp(kid, String::new()));
}
}
}
});
+132
View File
@@ -373,3 +373,135 @@ fn grouped_suggestions_name_the_keyword_alone() {
ask(&mut h, "ui.clickWidget", json!({"id": "kwSuggest:Places|Portugal|Lisbon"}));
assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k == "Places|Portugal|Lisbon")));
}
/// Two photos selected (beach + Weddings, beach), and a third photo of the library, not selected,
/// with Places|Portugal|Lisbon and Places|Portugal|Porto.
fn with_places() -> (Headless, Vec<u64>) {
let (mut h, ids) = two_selected();
let third = h.app.session.visible_cloned().get(2).map(|p| p.0).expect("a third photo");
ask(
&mut h,
"engine.execute",
json!({"command": "photo.setMeta", "params": {"ids": [third], "keywords": ["Places|Portugal|Lisbon", "Places|Portugal|Porto"]}}),
);
(h, ids)
}
fn typed(h: &mut Headless, text: &str) {
ask(h, "ui.clickWidget", json!({"id": "field:keyword"}));
ask(h, "ui.text", json!({"text": text}));
}
fn all_have(h: &Headless, ids: &[u64], keyword: &str) -> bool {
ids.iter().all(|id| keywords_of(h, *id).iter().any(|k| k == keyword))
}
fn none_has(h: &Headless, ids: &[u64], keyword: &str) -> bool {
ids.iter().all(|id| !keywords_of(h, *id).iter().any(|k| k == keyword))
}
// Given a library with Places › Portugal › Lisbon,
// when "lis" is typed in the Add keyword box,
// then the keyword is offered right under the box, and the suggestions further down stay what
// they were (they used to turn into the completions, a long way from the box).
#[test]
fn typing_offers_the_librarys_keywords_under_the_box() {
let (mut h, _) = with_places();
assert!(has(&h, "kwSuggest:Weddings"));
typed(&mut h, "lis");
let (field, offer) = (rect(&h, "field:keyword"), rect(&h, "kwComplete:Places|Portugal|Lisbon"));
assert!(offer.top() >= field.bottom() && offer.top() - field.bottom() < 12.0, "right under the box: {offer:?} {field:?}");
assert!(offer.left() >= field.left() - 1.0 && offer.right() <= field.right() + 1.0, "as wide as the box: {offer:?} {field:?}");
assert!(!has(&h, "kwComplete:Places|Portugal|Porto"), "only what matches");
assert!(has(&h, "kwSuggest:Weddings") && !has(&h, "kwSuggest:Places|Portugal|Lisbon"), "the suggestions stay");
// nothing typed, nothing offered
ask(&mut h, "ui.key", json!({"key": "Escape"}));
assert_eq!(count(&h, "kwComplete"), 0);
}
// Given the offers under the box,
// when ↓ then Return are pressed,
// then the highlighted keyword goes to every selected photo (not the letters typed), and the box
// is empty and still has the cursor for the next keyword.
#[test]
fn down_and_return_give_the_offered_keyword() {
let (mut h, ids) = with_places();
typed(&mut h, "lis");
ask(&mut h, "ui.key", json!({"key": "ArrowDown"}));
assert!(has(&h, "kwCompleteAt:Places|Portugal|Lisbon"), "highlighted");
ask(&mut h, "ui.key", json!({"key": "Enter"}));
assert!(all_have(&h, &ids, "Places|Portugal|Lisbon"));
assert!(none_has(&h, &ids, "lis"), "the letters typed are not a keyword");
assert_eq!(count(&h, "kwComplete"), 0, "the box is empty again");
// still in the box: the next keyword is typed straight away
ask(&mut h, "ui.text", json!({"text": "harbour"}));
ask(&mut h, "ui.key", json!({"key": "Enter"}));
assert!(all_have(&h, &ids, "harbour"));
}
// Given the offers under the box, Return alone still gives what was typed, as it always did; ↑
// from the first offer goes back to that.
#[test]
fn return_alone_gives_what_was_typed() {
let (mut h, ids) = with_places();
typed(&mut h, "lis");
ask(&mut h, "ui.key", json!({"key": "Enter"}));
assert!(all_have(&h, &ids, "lis") && none_has(&h, &ids, "Places|Portugal|Lisbon"));
typed(&mut h, "por");
ask(&mut h, "ui.key", json!({"key": "ArrowDown"}));
ask(&mut h, "ui.key", json!({"key": "ArrowUp"}));
assert_eq!(count(&h, "kwCompleteAt:"), 0, "none highlighted");
ask(&mut h, "ui.key", json!({"key": "Enter"}));
assert!(all_have(&h, &ids, "por") && none_has(&h, &ids, "Places|Portugal|Porto"));
}
// Given a name the library doesn't have,
// when it is typed,
// then the box says Return will create it (and inside which keyword, with a default parent); a
// keyword the library has is not announced as new.
#[test]
fn a_new_name_is_announced_as_new() {
let (mut h, _) = with_places();
typed(&mut h, "Lisbn");
assert!(has(&h, "kwCompleteCreate"), "a misspelling is a new keyword: say so");
ask(&mut h, "ui.key", json!({"key": "Escape"}));
typed(&mut h, "weddings");
assert!(!has(&h, "kwCompleteCreate"), "the library has Weddings");
assert!(has(&h, "kwComplete:Weddings"), "one of the two lacks it");
ask(&mut h, "ui.key", json!({"key": "Escape"}));
typed(&mut h, "Places|Portugal");
assert!(!has(&h, "kwCompleteCreate"), "a keyword with keywords inside it is there too");
}
// Given the offers under the box, a click on one gives that keyword to the selected photos; a
// click on the "create" line gives what was typed. Keywords typed before a comma go along.
#[test]
fn a_click_on_an_offer_gives_it() {
let (mut h, ids) = with_places();
typed(&mut h, "sea, lis");
assert!(has(&h, "kwComplete:Places|Portugal|Lisbon"), "the offers are for what follows the comma");
ask(&mut h, "ui.clickWidget", json!({"id": "kwComplete:Places|Portugal|Lisbon"}));
assert!(all_have(&h, &ids, "Places|Portugal|Lisbon") && all_have(&h, &ids, "sea") && none_has(&h, &ids, "lis"));
typed(&mut h, "harbour");
ask(&mut h, "ui.clickWidget", json!({"id": "kwCompleteCreate"}));
assert!(all_have(&h, &ids, "harbour"));
}
// Given more offers than there is room for, ↓ stops at the last and never runs off the list.
#[test]
fn the_highlight_stays_on_the_list() {
let (mut h, ids) = with_places();
typed(&mut h, "por");
for _ in 0..40 {
ask(&mut h, "ui.key", json!({"key": "ArrowDown"}));
}
let lit: Vec<String> = h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("kwCompleteAt:").map(str::to_string)).collect();
assert_eq!(lit.len(), 1, "{lit:?}");
let offered = count(&h, "kwComplete:");
assert!(offered >= 2, "Portugal and Porto both match");
ask(&mut h, "ui.key", json!({"key": "Enter"}));
assert!(all_have(&h, &ids, &lit[0]), "the last offer, {}, was given", lit[0]);
assert!(none_has(&h, &ids, "por"));
}
+1 -1
View File
@@ -704,7 +704,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; 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. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields |
| 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. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:<keyword>`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields |
| 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`, `crates/engine/src/face_tiles.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. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people
+4
View File
@@ -209,6 +209,10 @@
side under it. A long list stops after eight lines with "+N more", and the suggestions say only the keyword's own
name. Whole names, one chip per keyword, stays the default.
- A selection without keywords now says "No keywords yet" where its chips would be.
- Typing in **Add keyword** offers the library's matching keywords right under the box. ↓ and Return (or a click)
give the offered keyword and keep the cursor in the box for the next one; Return alone still adds what you typed.
A name the library doesn't have is marked as a new keyword before you press Return, so a misspelling no longer
becomes a keyword unnoticed. The suggestions further down stay put while you type.
### HDR and panorama merges of CR2 keep their colour
- Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and