diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc04..b2caeff544 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 387ac97172..588575c4bd 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b05baac242..f6504989dc 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "一致するキーワードはありません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ab7f397346..eda58e989b 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 517e2613f8..3aaf92c57a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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": "Нет подходящих ключевых слов" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 46728b6285..0cbba6d54e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "没有匹配的关键字" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 083c98c2e2..d45c83718c 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "沒有符合的關鍵字" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c9d0d27f6d..e0487c4974 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs new file mode 100644 index 0000000000..4df3bb6beb --- /dev/null +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -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 { + 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) { + 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) { + 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::>(), + ["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::>(), ["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); + } +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 2676f59ca3..a7c5534699 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -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"})); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index abb0155181..c1dc9ce1fe 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7df03d72e1..9f2788a16c 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df2922..8974fc300b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, + /// The Keyword List's open levels (lower-case paths). + pub keyword_list_open: Vec, + /// The keyword picked in the Keyword List (− deletes it, Edit edits it). + #[serde(skip)] + pub keyword_list_selected: Option, /// 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(), diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs new file mode 100644 index 0000000000..b0294dc15f --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -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) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + for (method, params) in [ + ("ui.set", json!({"view": "photoGrid", "right": "keywords"})), + ("engine.execute", json!({"command": "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 { + 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 = 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")); +}