LRC-LIB-KEYWORDS: a Keyword List in the right panel

The Keywords panel ends with Lightroom Classic's Keyword List: every
keyword of the library, those without photos too, as a tree with photo
counts (`panels::keyword_list`).
- A filter box keeps the keywords whose name holds the text, with the
  keywords containing them opened down to them.
- The triangle opens a level (kept with the UI state).
- The tick box says whether every, some (a dash) or none of the
  selected photos have the keyword, and gives it to all of them or
  takes it away.
- The arrow shown on hover shows the photos with the keyword.
- A click picks a row, for the actions that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 14:21:04 -04:00
co-authored by Claude Opus 5.5
parent 96dcc2f643
commit a29c9f0de5
14 changed files with 432 additions and 8 deletions
+5 -1
View File
@@ -1889,5 +1889,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "Stichwortliste",
"Filter Keywords": "Stichwörter filtern",
"No keywords yet": "Noch keine Stichwörter",
"No keywords match": "Keine passenden Stichwörter"
}
+5 -1
View File
@@ -1734,5 +1734,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "Lista de palabras clave",
"Filter Keywords": "Filtrar palabras clave",
"No keywords yet": "Aún no hay palabras clave",
"No keywords match": "Ninguna palabra clave coincide"
}
+5 -1
View File
@@ -1734,5 +1734,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "キーワードリスト",
"Filter Keywords": "キーワードを絞り込む",
"No keywords yet": "キーワードはまだありません",
"No keywords match": "一致するキーワードはありません"
}
+5 -1
View File
@@ -1734,5 +1734,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "Lista de palavras-chave",
"Filter Keywords": "Filtrar palavras-chave",
"No keywords yet": "Ainda não há palavras-chave",
"No keywords match": "Nenhuma palavra-chave corresponde"
}
+5 -1
View File
@@ -1407,5 +1407,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "Список ключевых слов",
"Filter Keywords": "Фильтр ключевых слов",
"No keywords yet": "Ключевых слов пока нет",
"No keywords match": "Нет подходящих ключевых слов"
}
+5 -1
View File
@@ -1734,5 +1734,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "关键字列表",
"Filter Keywords": "过滤关键字",
"No keywords yet": "还没有关键字",
"No keywords match": "没有匹配的关键字"
}
+5 -1
View File
@@ -1734,5 +1734,9 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Keyword List": "關鍵字清單",
"Filter Keywords": "篩選關鍵字",
"No keywords yet": "尚無關鍵字",
"No keywords match": "沒有符合的關鍵字"
}
+2
View File
@@ -42,6 +42,8 @@ mod tests_grid;
#[cfg(test)]
mod tests_keymap;
#[cfg(test)]
mod tests_keyword_list;
#[cfg(test)]
mod tests_labels;
#[cfg(test)]
mod tests_library_problem;
+285
View File
@@ -0,0 +1,285 @@
//! The Keyword List (Lightroom Classic's Keyword List panel), in the right panel's Keywords: every
//! keyword of the library, those without photos too, as a tree with photo counts. A tick box per
//! keyword gives it to the selected photos or takes it away; the arrow shows the photos with it.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_catalog::keywords::{KeywordNode, same};
use lightcraft_catalog::{Catalog, PhotoId};
use serde_json::json;
use crate::LightcraftApp;
use crate::theme::Tokens;
use crate::widgets::register;
/// Rows are this tall; each level is indented this much.
const ROW_H: f32 = 24.0;
const INDENT: f32 = 14.0;
/// The Keyword List section: a filter box, then the rows.
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
crate::widgets::divider(ui);
super::right::header(ui, "Keyword List");
let fid = egui::Id::new("keyword-list-filter");
let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default();
ui.horizontal(|ui| {
ui.add_space(24.0);
crate::text_field::TextField::singleline("field:keywordFilter", &mut filter)
.hint(crate::i18n::tr("Filter Keywords"))
.width(ui.available_width() - 22.0)
.show(ui);
});
ui.data_mut(|d| d.insert_temp(fid, filter.clone()));
ui.add_space(6.0);
let tree = app.caches.keyword_tree(&app.session.catalog);
let open = app.ui.keyword_list_open.clone();
let rows = rows(&tree, &filter, &|p| open.iter().any(|o| same(o, p)));
if rows.is_empty() {
let none = if filter.trim().is_empty() { "No keywords yet" } else { "No keywords match" };
ui.horizontal(|ui| {
ui.add_space(24.0);
ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim));
});
}
let selection = app.session.selection.ids.clone();
for r in &rows {
row(app, ui, r, &selection, filter.trim().is_empty());
}
ui.add_space(12.0);
}
/// One keyword's row: the triangle, the tick box, the name, the count, and on hover the arrow.
/// `can_fold`: the triangle opens and closes the level (not while a filter opens it).
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], can_fold: bool) {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click());
register(ui.ctx(), format!("keywordRow:{}", r.path), rect);
let picked = app.ui.keyword_list_selected.as_deref().is_some_and(|k| same(k, &r.path));
let inner = Rect::from_min_max(rect.min + vec2(16.0, 0.0), rect.max - vec2(16.0, 0.0));
if picked {
ui.painter().rect_filled(inner, 4.0, t.canvas);
} else if resp.hovered() {
ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));
}
let x = inner.left() + 4.0 + r.depth as f32 * INDENT;
let cy = rect.center().y;
// the triangle
if r.has_children {
let c = pos2(x + 6.0, cy);
let tri = Rect::from_center_size(c, vec2(14.0, 14.0));
let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click());
register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri);
let pts = if r.open {
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
} else {
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
};
ui.painter().add(egui::Shape::convex_polygon(pts, if tr.hovered() { t.text } else { t.text_dim }, Stroke::NONE));
if tr.clicked() && can_fold {
let key = r.path.to_lowercase();
if r.open {
app.ui.keyword_list_open.retain(|o| !same(o, &key));
} else {
app.ui.keyword_list_open.push(key);
}
}
}
// the tick box: does the selection have it?
let state = tick(&app.session.catalog, selection, &r.path);
let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0));
let on = !selection.is_empty();
let tb =
ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), if on { Sense::click() } else { Sense::hover() });
register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr);
let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) };
match state {
Tick::All => {
ui.painter().rect_filled(boxr, 2.0, t.accent);
let pts = vec![boxr.left_center() + vec2(2.5, 0.0), boxr.center_bottom() + vec2(-1.0, -3.0), boxr.right_top() + vec2(-2.5, 3.0)];
ui.painter().add(egui::Shape::line(pts, Stroke::new(1.6, Color32::WHITE)));
}
Tick::Some => {
ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside);
ui.painter().line_segment([boxr.left_center() + vec2(3.0, 0.0), boxr.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, t.text));
}
Tick::No => {
ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside);
}
}
let tb = tb.on_hover_text(crate::i18n::tr(match state {
Tick::All => "Remove from Selected Photos",
_ => "Add to Selected Photos",
}));
if tb.clicked() {
let key = if state == Tick::All { "removeKeywords" } else { "addKeywords" };
let _ = app.run("photo.setMeta", json!({key: [r.path]}));
}
// the count, then the arrow left of it (shown on hover)
let count = ui.painter().layout_no_wrap(r.count.to_string(), t.font(12.0), t.text_dim);
let count_rect = Rect::from_min_size(pos2(inner.right() - 6.0 - count.size().x, cy - count.size().y / 2.0), count.size());
register(ui.ctx(), format!("keywordCount:{}", r.path), count_rect);
ui.painter().galley(count_rect.min, count, t.text_dim);
let arrow = Rect::from_center_size(pos2(count_rect.left() - 12.0, cy), vec2(16.0, 16.0));
let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click());
register(ui.ctx(), format!("keywordShow:{}", r.path), arrow);
if resp.hovered() || ar.hovered() {
ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim });
}
if ar.on_hover_text(crate::i18n::tr("Show Photos with Keyword")).clicked() {
super::left::browse_all_photos(app, true);
let _ = app.run("library.filter", json!({"keyword": r.path}));
}
// the name, cut short before the arrow
let room = (arrow.left() - 4.0 - (x + 34.0)).max(0.0);
let font = t.font(13.0);
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text_label).size().x;
let name = if measure(&r.name) <= room { r.name.clone() } else { crate::widgets::elide_head(&r.name, room, measure) };
ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label });
let resp = resp.on_hover_text(r.path.replace('|', " › "));
if resp.clicked() {
app.ui.keyword_list_selected = Some(r.path.clone());
}
}
/// One row of the Keyword List as drawn.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Row {
pub path: String,
pub name: String,
/// Levels below the top (0 = a top-level keyword).
pub depth: usize,
pub count: usize,
pub has_children: bool,
/// Its children are shown.
pub open: bool,
}
/// The rows to draw: the tree with its levels open as `open` says (by path, any case). With a
/// `filter`, the keywords whose name contains it (any case) and the keywords containing them,
/// opened so that they show.
pub(crate) fn rows(tree: &[KeywordNode], filter: &str, open: &dyn Fn(&str) -> bool) -> Vec<Row> {
let filter = filter.trim().to_lowercase();
let mut out = Vec::new();
add_rows(tree, 0, &filter, open, &mut out);
out
}
/// A node holds a match: its name, or a name below it.
fn holds(n: &KeywordNode, filter: &str) -> bool {
n.name.to_lowercase().contains(filter) || n.children.iter().any(|c| holds(c, filter))
}
fn add_rows(nodes: &[KeywordNode], depth: usize, filter: &str, open: &dyn Fn(&str) -> bool, out: &mut Vec<Row>) {
for n in nodes {
// filtering: a match, or a keyword containing one, opened down to it
let below = n.children.iter().any(|c| holds(c, filter));
if !filter.is_empty() && !below && !n.name.to_lowercase().contains(filter) {
continue;
}
let is_open = if filter.is_empty() { open(&n.path) } else { below };
out.push(Row { path: n.path.clone(), name: n.name.clone(), depth, count: n.count, has_children: !n.children.is_empty(), open: is_open });
if is_open {
add_rows(&n.children, depth + 1, filter, open, out);
}
}
}
/// Whether the selected photos have a keyword: the tick box's state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Tick {
/// None of them (or nothing is selected).
No,
/// Some of them: the box shows a dash.
Some,
/// All of them.
All,
}
/// The tick box of `path` for `photos`: a photo counts when it has the keyword itself (any case);
/// one with only a keyword below it doesn't.
pub(crate) fn tick(catalog: &Catalog, photos: &[PhotoId], path: &str) -> Tick {
let path = lightcraft_catalog::keywords::clean(path);
let has = photos
.iter()
.filter(|id| {
catalog
.photo(**id)
.is_some_and(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::same(&lightcraft_catalog::keywords::clean(k), &path)))
})
.count();
match has {
0 => Tick::No,
n if n == photos.len() => Tick::All,
_ => Tick::Some,
}
}
#[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(rows: &[Row]) -> Vec<(String, usize, usize)> {
rows.iter().map(|r| (r.path.clone(), r.depth, r.count)).collect()
}
/// The list shows the top level, and the keywords below a level once it is open.
#[test]
fn the_list_shows_the_levels_that_are_open() {
let (c, _) = library(&[&["travel|Italy|Rome", "beach"], &["travel|Spain"]]);
let tree = c.keyword_tree();
let closed = rows(&tree, "", &|_| false);
assert_eq!(shown(&closed), [("beach".to_string(), 0, 1), ("travel".to_string(), 0, 2)]);
assert!(closed[1].has_children && !closed[1].open && !closed[0].has_children);
let open = rows(&tree, "", &|p| p.eq_ignore_ascii_case("TRAVEL"));
assert_eq!(
shown(&open),
[("beach".to_string(), 0, 1), ("travel".to_string(), 0, 2), ("travel|Italy".to_string(), 1, 1), ("travel|Spain".to_string(), 1, 1)]
);
}
/// Filtering shows the keywords whose name holds the text, whatever its case, with the keywords
/// containing them opened down to them; keywords below a match aren't shown unless they match.
#[test]
fn filtering_shows_the_matches_with_their_parents() {
let (c, _) = library(&[&["travel|Italy|Rome", "travel|Spain|Ronda", "beach"]]);
let tree = c.keyword_tree();
let found = rows(&tree, "RO", &|_| false);
assert_eq!(
found.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
["travel", "travel|Italy", "travel|Italy|Rome", "travel|Spain", "travel|Spain|Ronda"]
);
assert!(found.iter().filter(|r| r.has_children).all(|r| r.open));
assert_eq!(rows(&tree, "italy", &|_| false).iter().map(|r| r.path.as_str()).collect::<Vec<_>>(), ["travel", "travel|Italy"]);
assert!(rows(&tree, "lisbon", &|_| false).is_empty());
}
/// The tick box: ticked when every selected photo has the keyword itself, a dash when some do,
/// empty when none do or nothing is selected.
#[test]
fn the_tick_box_says_how_many_selected_photos_have_the_keyword() {
let (c, ids) = library(&[&["Travel|Italy"], &["travel|italy", "beach"], &["travel|spain"]]);
assert_eq!(tick(&c, &ids[..2], "travel|Italy"), Tick::All, "whatever the case");
assert_eq!(tick(&c, &ids, "travel|Italy"), Tick::Some);
assert_eq!(tick(&c, &ids, "travel"), Tick::No, "a keyword below doesn't tick its parent");
assert_eq!(tick(&c, &[], "beach"), Tick::No);
assert_eq!(tick(&c, &ids[2..], "beach"), Tick::No);
// any letter's case, not only ASCII
let (c, ids) = library(&[&["Ärzte"]]);
assert_eq!(tick(&c, &ids, "ärzte"), Tick::All);
}
}
+1 -1
View File
@@ -674,7 +674,7 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str,
/// By Date and Keywords count every photo in the library, so choosing a row shows those photos
/// from All Photos, not from whatever album or folder happened to be open, where they could be
/// missing (issue #341). Only when choosing (`on`), not when clearing the row again.
fn browse_all_photos(app: &mut LightcraftApp, on: bool) {
pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) {
if on && app.session.source != LibrarySource::All {
let _ = app.run("library.source", json!({"kind": "all"}));
}
+1
View File
@@ -12,6 +12,7 @@ pub mod faces;
pub mod filterbar;
pub mod grid;
pub mod keymap;
pub mod keyword_list;
pub mod left;
pub mod library_problem;
pub mod masking;
+1
View File
@@ -840,6 +840,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
}
});
super::keyword_list::show(app, ui);
}
/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the
+7
View File
@@ -449,6 +449,11 @@ pub struct UiState {
/// The keyword painter: clicking a photo in the grid toggles this keyword on it.
#[serde(skip)]
pub keyword_painter: Option<String>,
/// The 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).
#[serde(skip)]
pub keyword_list_selected: Option<String>,
/// A running slideshow (full screen): seconds per photo, when the next one is due (egui
/// time), paused.
#[serde(skip)]
@@ -757,6 +762,8 @@ impl Default for UiState {
slideshow: None,
second_window: false,
keyword_painter: None,
keyword_list_open: Vec::new(),
keyword_list_selected: None,
info_overlay: InfoOverlay::Off,
navigator: true,
settings: AppSettings::default(),
+100
View File
@@ -0,0 +1,100 @@
//! The Keyword List in the right panel's Keywords (Lightroom Classic's Keyword List panel),
//! 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 demo library in the Library grid with the Keywords panel open, two photos selected.
fn keywords_panel() -> (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": "library.select", "params": {"ids": ids}})),
] {
let r = h.request(method, params, T);
assert_eq!(r["ok"], true, "{r}");
}
h.settle(SETTLE);
(h, ids)
}
fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
h.settle(SETTLE);
r
}
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 list shows every keyword with its photo count, a keyword without photos too; the triangle
/// opens a level.
#[test]
fn the_keyword_list_shows_every_keyword() {
let (mut h, _) = keywords_panel();
run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"}));
assert!(has(&h, "keywordRow:Events"), "a keyword with no photos is listed");
assert!(!has(&h, "keywordRow:Events|Weddings"), "inside a closed level");
ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Events"}));
assert!(has(&h, "keywordRow:Events|Weddings"));
assert!(has(&h, "keywordCount:mountains"), "photo counts are shown");
}
/// The tick box gives the keyword to every selected photo, then takes it away; with only some of
/// them having it, a click gives it to all.
#[test]
fn the_tick_box_gives_a_keyword_to_the_selection() {
let (mut h, ids) = keywords_panel();
run(&mut h, "keyword.create", json!({"name": "Weddings"}));
// the demo has many keywords: find it with the filter box
ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordFilter"}));
ask(&mut h, "ui.text", json!({"text": "wedd"}));
ask(&mut h, "ui.clickWidget", json!({"id": "keywordCheck:Weddings"}));
assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Weddings".to_string())), "given to both");
ask(&mut h, "ui.clickWidget", json!({"id": "keywordCheck:Weddings"}));
assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"Weddings".to_string())), "taken from both");
run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "addKeywords": ["Weddings"]}));
ask(&mut h, "ui.clickWidget", json!({"id": "keywordCheck:Weddings"}));
assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Weddings".to_string())), "some had it: now all do");
}
/// Typing in the filter box keeps the keywords whose name holds the text, with their parents.
#[test]
fn the_filter_box_narrows_the_list() {
let (mut h, _) = keywords_panel();
run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"}));
ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordFilter"}));
ask(&mut h, "ui.text", json!({"text": "wed"}));
let rows: Vec<String> = h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("keywordRow:").map(str::to_string)).collect();
assert_eq!(rows, ["Events", "Events|Weddings"]);
}
/// The arrow on a row shows the photos with that keyword.
#[test]
fn the_arrow_shows_the_photos_with_the_keyword() {
let (mut h, _) = keywords_panel();
ask(&mut h, "ui.hoverWidget", json!({"id": "keywordRow:mountains"}));
ask(&mut h, "ui.clickWidget", json!({"id": "keywordShow:mountains"}));
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("mountains"));
}