From 94de255e7461345d8913d01ee14148560135bf61 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:25:10 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: a standalone album picker with a tree and search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat dropdown doesn't scale to the hundreds of albums and smart albums a library grows. crates/ui-egui/src/album_picker.rs is a reusable picker: - A button opens a popup with a search field, focused on open. - With nothing typed it shows the sidebar's tree in the sidebar's order (entries_from uses Catalog::album_children_by_parent): folders with painted disclosure triangles and folder icons, album / smart-album icons. Folders are closed except those holding the current choice. - Typing lists the albums whose "Folder / Name" path holds every word, in any case, names starting with the query first. ↑ / ↓ move, Enter picks, Esc closes just the popup. - Albums that can't be picked stay visible but greyed, the reason on hover, instead of vanishing. - Pure, unit-tested helpers: path_label, ancestors, visible_tree, search, set_depths. Widget ids albumPicker / albumPickerSearch / albumPickerFolder / albumPickerItem. The rule editor's Album value uses it. The album being edited and albums that would loop back are blocked with a reason ("This is the album you're editing", "It tests this album, so testing it back would loop"). This also removes the stray divider the old dropdown drew above "Smart Albums" when no regular album preceded it. New strings are translated in all seven locales, and the strict i18n test lists them; the unused "Smart Albums" heading is dropped. Headless tests: browsing the tree, typing with ↑ / Enter, greyed loops. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 5 +- crates/ui-egui/locales/es.json | 5 +- crates/ui-egui/locales/ja.json | 5 +- crates/ui-egui/locales/pt-br.json | 5 +- crates/ui-egui/locales/ru.json | 5 +- crates/ui-egui/locales/zh-hans.json | 5 +- crates/ui-egui/locales/zh-hant.json | 5 +- crates/ui-egui/src/album_picker.rs | 371 ++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 10 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 27 +- crates/ui-egui/src/panels/rules_editor.rs | 57 +--- crates/ui-egui/src/tests_album_picker.rs | 116 +++++++ crates/ui-egui/src/tests_masking.rs | 26 +- 14 files changed, 565 insertions(+), 80 deletions(-) create mode 100644 crates/ui-egui/src/album_picker.rs create mode 100644 crates/ui-egui/src/tests_album_picker.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index fa06ae57..bc4ef39b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1932,5 +1932,8 @@ "Su": "So", "Pick a date": "Datum wählen", "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen", - "Smart Albums": "Smart-Alben" + "Search albums": "Alben suchen", + "No albums match": "Kein Album passt", + "This is the album you're editing": "Das ist das Album, das du bearbeitest", + "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index faf2a4e2..9e30e266 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1777,5 +1777,8 @@ "Su": "Do", "Pick a date": "Elegir una fecha", "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes", - "Smart Albums": "Álbumes inteligentes" + "Search albums": "Buscar álbumes", + "No albums match": "Ningún álbum coincide", + "This is the album you're editing": "Es el álbum que estás editando", + "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 11882d7f..dfc6405a 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1781,5 +1781,8 @@ "Su": "日", "Pick a date": "日付を選択", "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります", - "Smart Albums": "スマートアルバム" + "Search albums": "アルバムを検索", + "No albums match": "一致するアルバムはありません", + "This is the album you're editing": "編集中のアルバムです", + "It tests this album, so testing it back would loop": "このアルバムを条件にしているため、逆に指定すると循環します" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index cefdaeb2..dd3ef316 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1777,5 +1777,8 @@ "Su": "Dom", "Pick a date": "Escolher uma data", "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes", - "Smart Albums": "Álbuns inteligentes" + "Search albums": "Buscar álbuns", + "No albums match": "Nenhum álbum corresponde", + "This is the album you're editing": "É o álbum que você está editando", + "It tests this album, so testing it back would loop": "Ele testa este álbum, então testá-lo de volta criaria um ciclo" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 40beb384..2b58d95d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1469,5 +1469,8 @@ "Su": "Вс", "Pick a date": "Выбрать дату", "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы", - "Smart Albums": "Смарт-альбомы" + "Search albums": "Поиск альбомов", + "No albums match": "Нет подходящих альбомов", + "This is the album you're editing": "Это альбом, который вы изменяете", + "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c4dcefbd..adf2f3cf 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1777,5 +1777,8 @@ "Su": "日", "Pick a date": "选择日期", "would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身", - "Smart Albums": "智能相册" + "Search albums": "搜索相册", + "No albums match": "没有匹配的相册", + "This is the album you're editing": "这是正在编辑的相册", + "It tests this album, so testing it back would loop": "它引用了此相册,反过来引用会形成循环" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 6918e393..3a93d1ed 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1777,5 +1777,8 @@ "Su": "日", "Pick a date": "選擇日期", "would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身", - "Smart Albums": "智慧型相簿" + "Search albums": "搜尋相簿", + "No albums match": "沒有符合的相簿", + "This is the album you're editing": "這是正在編輯的相簿", + "It tests this album, so testing it back would loop": "它引用了此相簿,反過來引用會形成循環" } diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs new file mode 100644 index 00000000..7800363c --- /dev/null +++ b/crates/ui-egui/src/album_picker.rs @@ -0,0 +1,371 @@ +//! An album picker: a button that opens a popup to pick an album or a smart album, by browsing a +//! tree like the sidebar's or by typing part of its name. +//! +//! Standalone: it edits an `Option` album id over a list of [`AlbumEntry`]s, in tree order +//! ([`entries_from`] builds it from a catalog in the sidebar's order). Folders are for browsing, +//! not picking. An entry can be `blocked` with a reason: shown greyed, the reason on hover. +//! +//! With nothing typed it shows the tree, folders closed except those holding the current choice. +//! Typing lists the albums whose path ("Folder / Name") holds every word typed, names starting +//! with it first; ↑ / ↓ move through them, Enter picks, Esc closes. Widget ids for tests and agents: +//! `albumPicker:` (the button), `albumPickerSearch:`, `albumPickerFolder::` +//! and `albumPickerItem::`. + +use egui::{Response, RichText, Ui, vec2}; + +use crate::icons::Icon; +use crate::widgets::register; + +/// One row of the picker: an album, a smart album or a folder. +#[derive(Clone, Debug, PartialEq)] +pub struct AlbumEntry { + pub id: u64, + pub name: String, + pub parent: Option, + /// How deep in folders (0 at the top), from [`set_depths`]. + pub depth: usize, + pub folder: bool, + pub smart: bool, + /// Why it can't be picked, when it can't (shown on hover). + pub blocked: Option, +} + +/// Every album, smart album and folder of `cat` in the sidebar's order (folders first, the user's +/// order, else by name), each folder followed by what it holds. `blocked` says why an album can't +/// be picked, if so. +pub fn entries_from(cat: &lightcraft_catalog::Catalog, blocked: impl Fn(&lightcraft_catalog::Album) -> Option) -> Vec { + let kids = cat.album_children_by_parent(); + let mut out = Vec::new(); + // depth-first, guarded against a parent loop in a damaged catalog + let mut stack: Vec<(Option, usize)> = vec![(None, 0)]; + let mut seen = std::collections::HashSet::new(); + fn walk( + parent: Option, + kids: &std::collections::HashMap, Vec<&lightcraft_catalog::Album>>, + blocked: &dyn Fn(&lightcraft_catalog::Album) -> Option, + seen: &mut std::collections::HashSet, + out: &mut Vec, + depth: usize, + ) { + if depth > 64 { + return; + } + for a in kids.get(&parent).map(Vec::as_slice).unwrap_or_default() { + if !seen.insert(a.id) { + continue; + } + out.push(AlbumEntry { + id: a.id.0, + name: a.name.clone(), + parent: a.parent.map(|p| p.0), + depth, + folder: a.folder, + smart: a.is_smart(), + blocked: if a.folder { None } else { blocked(a) }, + }); + if a.folder { + walk(Some(a.id), kids, blocked, seen, out, depth + 1); + } + } + } + stack.clear(); + walk(None, &kids, &blocked, &mut seen, &mut out, 0); + out +} + +/// Fill in each entry's depth from its parents (for lists built by hand). +pub fn set_depths(entries: &mut [AlbumEntry]) { + let depths: Vec = entries.iter().map(|e| ancestors(entries, e.id).len()).collect(); + for (e, d) in entries.iter_mut().zip(depths) { + e.depth = d; + } +} + +/// The folders holding album `id`, nearest first; empty at the top level or for an unknown id. +pub fn ancestors(entries: &[AlbumEntry], id: u64) -> Vec { + let mut out = Vec::new(); + let mut at = entries.iter().find(|e| e.id == id).and_then(|e| e.parent); + while let Some(p) = at { + if out.contains(&p) || out.len() > 64 { + break; + } + out.push(p); + at = entries.iter().find(|e| e.id == p).and_then(|e| e.parent); + } + out +} + +/// "Folder / Sub / Name" for album `id`; empty for an unknown id. +pub fn path_label(entries: &[AlbumEntry], id: u64) -> String { + let Some(entry) = entries.iter().find(|e| e.id == id) else { return String::new() }; + let mut parts: Vec<&str> = + ancestors(entries, id).iter().rev().filter_map(|p| entries.iter().find(|e| e.id == *p)).map(|e| e.name.as_str()).collect(); + parts.push(&entry.name); + parts.join(" / ") +} + +/// The rows a tree shows with the folders in `open` open: indexes into `entries`. +pub fn visible_tree(entries: &[AlbumEntry], open: &[u64]) -> Vec { + (0..entries.len()).filter(|&i| ancestors(entries, entries[i].id).iter().all(|a| open.contains(a))).collect() +} + +/// The albums (not folders) whose path holds every word of `query`, any case: those whose own +/// name starts with the query first, then the rest, each in tree order. Indexes into `entries`. +pub fn search(entries: &[AlbumEntry], query: &str) -> Vec { + let query = query.trim().to_lowercase(); + let words: Vec<&str> = query.split_whitespace().collect(); + if words.is_empty() { + return Vec::new(); + } + let hits: Vec = (0..entries.len()) + .filter(|&i| !entries[i].folder) + .filter(|&i| { + let path = path_label(entries, entries[i].id).to_lowercase(); + words.iter().all(|w| path.contains(w)) + }) + .collect(); + let (first, rest): (Vec, Vec) = hits.into_iter().partition(|&i| entries[i].name.to_lowercase().starts_with(&query)); + first.into_iter().chain(rest).collect() +} + +/// A small filled triangle centred on `c`: pointing down (`down`), else right. +fn triangle(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { + let pts = if down { + 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)] + }; + p.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); +} + +/// What the open popup remembers between frames. +#[derive(Clone, Default)] +struct State { + query: String, + open: Vec, + /// The highlighted search result (↑ / ↓, Enter). + highlight: usize, + focus: bool, +} + +/// A button that edits an album id; see the module docs. +pub struct AlbumPicker<'a> { + id: String, + value: &'a mut Option, + entries: &'a [AlbumEntry], + width: f32, +} + +impl<'a> AlbumPicker<'a> { + /// `id` must be unique among the pickers on screen (it names their widgets and keeps their + /// state apart); `value` is the album picked (`None`: none yet); `entries` in tree order. + pub fn new(id: impl Into, value: &'a mut Option, entries: &'a [AlbumEntry]) -> Self { + AlbumPicker { id: id.into(), value, entries, width: 180.0 } + } + + /// The button's width (180 by default). + pub fn width(mut self, width: f32) -> Self { + self.width = width; + self + } + + /// The button and, while open, the popup. The response is `changed()` when an album was picked. + pub fn show(self, ui: &mut Ui) -> Response { + let AlbumPicker { id, value, entries, width } = self; + let chosen = value.filter(|v| entries.iter().any(|e| e.id == *v && !e.folder)); + let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |c| path_label(entries, c)); + // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) + let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); + let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; + triangle(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); + register(ui.ctx(), format!("albumPicker:{id}"), button.rect); + let key = egui::Id::new(("albumPicker", id.as_str())); + if button.clicked() { + // open on the chosen album's folders, with the search empty and ready for typing + let open = chosen.map(|c| ancestors(entries, c)).unwrap_or_default(); + ui.data_mut(|d| d.insert_temp(key, State { open, focus: true, ..Default::default() })); + } + let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default(); + let mut picked: Option = None; + egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(width.max(260.0)); + ui.set_max_width(width.max(260.0) + 120.0); + let search_box = + ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY)); + register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect); + if state.focus { + search_box.request_focus(); + state.focus = false; + } + if search_box.changed() { + state.highlight = 0; + } + let t = crate::theme::Tokens::get(ui.ctx()); + let row = |ui: &mut Ui, e: &AlbumEntry, label: &str, indent: f32, highlighted: bool, picked: &mut Option| { + ui.horizontal(|ui| { + ui.add_space(indent); + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); + let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album }; + crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon }); + let resp = ui.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label)); + register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect); + let resp = match &e.blocked { + Some(why) => resp.on_disabled_hover_text(why.as_str()), + None => resp, + }; + if resp.clicked() { + *picked = Some(e.id); + } + if highlighted { + resp.scroll_to_me(None); + } + }); + }; + egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| { + if state.query.trim().is_empty() { + for i in visible_tree(entries, &state.open) { + let e = &entries[i]; + let indent = e.depth as f32 * 16.0; + if e.folder { + // a folder row folds and unfolds, like the sidebar's: triangle, icon, name + let open = state.open.contains(&e.id); + let height = ui.spacing().interact_size.y; + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), height), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let x = rect.left() + indent; + triangle(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); + let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); + crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); + ui.painter().text( + egui::pos2(x + 36.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &e.name, + egui::TextStyle::Button.resolve(ui.style()), + t.text_label, + ); + register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect); + if r.clicked() { + if open { + state.open.retain(|o| *o != e.id); + } else { + state.open.push(e.id); + } + } + } else { + // past the triangle column, so names line up with folder names + row(ui, e, &e.name, indent + 14.0, false, &mut picked); + } + } + if entries.iter().all(|e| e.folder) { + ui.label(RichText::new(crate::i18n::tr("No albums yet")).weak()); + } + } else { + let hits = search(entries, &state.query); + // ↑ / ↓ move through the results that can be picked, Enter picks one + let pickable: Vec = hits.iter().copied().filter(|&i| entries[i].blocked.is_none()).collect(); + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + ) + }); + if down { + state.highlight = (state.highlight + 1).min(pickable.len().saturating_sub(1)); + } + if up { + state.highlight = state.highlight.saturating_sub(1); + } + let highlighted = pickable.get(state.highlight).copied(); + if enter && let Some(i) = highlighted { + picked = Some(entries[i].id); + } + for i in &hits { + let e = &entries[*i]; + row(ui, e, &path_label(entries, e.id), 0.0, highlighted == Some(*i), &mut picked); + } + if hits.is_empty() { + ui.label(RichText::new(crate::i18n::tr("No albums match")).weak()); + } + } + }); + // Esc closes the popup and stops there: the dialog around it keeps its edits + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + crate::widgets::take_escape(ui.ctx()); + ui.close(); + } + if picked.is_some() { + ui.close(); + } + }); + ui.data_mut(|d| d.insert_temp(key, state)); + if let Some(p) = picked { + *value = Some(p); + button.mark_changed(); + } + button + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn e(id: u64, name: &str, parent: Option, folder: bool, smart: bool) -> AlbumEntry { + AlbumEntry { id, name: name.into(), parent, depth: 0, folder, smart, blocked: None } + } + + /// [UTILS] (folder) > Excluded | Hidden Photos (smart), Uncurated (smart); Trip; Weddings > + /// Tuscany Wedding + fn sample() -> Vec { + let mut v = vec![ + e(1, "[UTILS]", None, true, false), + e(2, "Excluded | Hidden Photos", Some(1), false, true), + e(3, "Uncurated", Some(1), false, true), + e(4, "Trip", None, false, false), + e(5, "Weddings", None, true, false), + e(6, "Tuscany Wedding", Some(5), false, true), + ]; + set_depths(&mut v); + v + } + + #[test] + fn paths_and_depths_follow_the_folders() { + let v = sample(); + assert_eq!(path_label(&v, 2), "[UTILS] / Excluded | Hidden Photos"); + assert_eq!(path_label(&v, 4), "Trip"); + assert_eq!(path_label(&v, 99), ""); + assert_eq!(v.iter().map(|e| e.depth).collect::>(), vec![0, 1, 1, 0, 0, 1]); + assert_eq!(ancestors(&v, 6), vec![5]); + assert!(ancestors(&v, 4).is_empty()); + } + + #[test] + fn the_tree_hides_what_closed_folders_hold() { + let v = sample(); + let ids = |open: &[u64]| visible_tree(&v, open).into_iter().map(|i| v[i].id).collect::>(); + assert_eq!(ids(&[]), vec![1, 4, 5], "folders closed: only the top level"); + assert_eq!(ids(&[1]), vec![1, 2, 3, 4, 5]); + assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]); + } + + /// Every word typed is in the album's path (folders included), in any case; names that start + /// with it come first; folders themselves aren't results. + #[test] + fn search_finds_albums_by_any_part_of_their_path() { + let v = sample(); + let ids = |q: &str| search(&v, q).into_iter().map(|i| v[i].id).collect::>(); + assert_eq!(ids("excl"), vec![2]); + assert_eq!(ids("UTILS"), vec![2, 3], "a folder's name finds what it holds"); + assert_eq!(ids("utils uncur"), vec![3], "every word must match"); + assert_eq!(ids("t"), vec![4, 6, 2, 3], "names starting with it first (Trip, Tuscany Wedding), then the rest, each in sidebar order"); + assert_eq!(ids(" "), Vec::::new(), "an empty search isn't a search"); + assert_eq!(ids("zzz"), Vec::::new()); + let mut accents = vec![e(7, "Été", None, false, false)]; + set_depths(&mut accents); + assert_eq!(search(&accents, "été").len(), 1); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2661095d..4ed092e5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -765,7 +765,15 @@ mod tests { assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); // the issues, the operators their hints name and the album picker's words, in every language for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - for word in ["Choose an album…", "No albums yet", "Smart Albums", "Fix the marked rules to save this album."] { + for word in [ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + ] { assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); } for issue in Issue::ALL { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f4e0bb2..9a743e91 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod album_picker; pub mod control; pub mod credits; pub mod date_picker; @@ -33,6 +34,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_album_picker; #[cfg(test)] mod tests_curve; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 22768a03..e790d8d3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -251,8 +251,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let env = crate::panels::rules_editor::Env { problems: rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)), today: (app.session.clock)(), - albums: album_choices(&app.session.catalog, *id, false), - smart_albums: album_choices(&app.session.catalog, *id, true), + albums: album_entries(&app.session.catalog, *id), }; egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); @@ -1033,23 +1032,15 @@ fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) Ok(r) } -/// The albums (`smart` false) or smart albums an Album rule in smart album `editing` can test, as -/// (id, "Folder / Name"), sorted by name: no folders, and no smart album that is `editing` itself -/// or tests it (that would loop back). -fn album_choices(cat: &lightcraft_catalog::Catalog, editing: Option, smart: bool) -> Vec<(u64, String)> { +/// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album +/// itself and any smart album that tests it (testing it back would loop) are blocked, with why. +fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { let editing = editing.map(lightcraft_catalog::AlbumId); - let mut albums: Vec<(u64, String)> = cat - .albums() - .filter(|a| !a.folder && a.is_smart() == smart) - .filter(|a| editing.is_none_or(|e| a.id != e && !cat.album_reaches(a.id, e))) - .map(|a| { - // "Folder / Album", so albums of the same name in two folders differ - let folder = a.parent.and_then(|f| cat.album(f)).map(|f| f.name.as_str()); - (a.id.0, folder.map_or_else(|| a.name.clone(), |f| format!("{f} / {}", a.name))) - }) - .collect(); - albums.sort_by_key(|a| a.1.to_lowercase()); - albums + crate::album_picker::entries_from(cat, |a| match editing { + Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), + Some(e) if cat.album_reaches(a.id, e) => Some(crate::i18n::tr("It tests this album, so testing it back would loop").to_string()), + _ => None, + }) } pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 571be601..9b8c994f 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -6,6 +6,7 @@ use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, Problem, TOP_LEVEL_FIELDS, b use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; +use crate::album_picker::{AlbumEntry, AlbumPicker}; use crate::date_picker::{DatePicker, PickedDate}; use crate::theme::Tokens; use crate::widgets::register; @@ -110,48 +111,21 @@ pub struct Env { pub problems: Vec, /// Today (`YYYY-MM-DD…`, the session's clock): where a date picker opens when a rule has no date. pub today: String, - /// The albums an Album rule can test: (id, name shown, with its folder when it is in one), - /// sorted by that name. - pub albums: Vec<(u64, String)>, - /// The smart albums it can test, the same way: not the album being edited, nor one that tests - /// it (that would loop back). - pub smart_albums: Vec<(u64, String)>, + /// The albums, smart albums and folders an Album rule picks from, in the sidebar's order + /// ([`crate::album_picker::entries_from`]); the album being edited and those that would loop + /// back to it are blocked, with the reason. + pub albums: Vec, } -/// The album of `albums` an Album rule's value names, read as the catalog reads it (`3`, `"3"`). -fn chosen_album<'a>(v: &Value, albums: &'a [(u64, String)]) -> Option<&'a (u64, String)> { - let id = lightcraft_catalog::rules::album_rule_id(v)?; - albums.iter().find(|a| a.0 == id.0) -} - -/// An Album rule's value: an album or a smart album, picked by name. +/// An Album rule's value: an album or a smart album, picked from the tree or by typing +/// ([`AlbumPicker`]). The id is read as the catalog reads it (`3`, `"3"`) and written as a number. fn album_value(ui: &mut egui::Ui, v: &mut Value, salt: &str, env: &Env) { - let chosen = chosen_album(v, &env.albums).or_else(|| chosen_album(v, &env.smart_albums)); - let cur = chosen.map(|a| a.0); - let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |a| a.1.clone()); - let r = egui::ComboBox::from_id_salt(format!("{salt}-album")).width(160.0).selected_text(text).show_ui(ui, |ui| { - let mut item = |ui: &mut egui::Ui, id: u64, name: &str| { - let r = ui.selectable_label(cur == Some(id), name); - register(ui.ctx(), format!("ruleAlbumItem:{id}:{salt}"), r.rect); - if r.clicked() { - *v = json!(id); - } - }; - for (id, name) in &env.albums { - item(ui, *id, name); - } - if !env.smart_albums.is_empty() { - ui.separator(); - ui.label(RichText::new(crate::i18n::tr("Smart Albums")).weak().small()); - for (id, name) in &env.smart_albums { - item(ui, *id, name); - } - } - if env.albums.is_empty() && env.smart_albums.is_empty() { - ui.label(crate::i18n::tr("No albums yet")); - } - }); - register(ui.ctx(), format!("ruleAlbum:{salt}"), r.response.rect); + let mut chosen = lightcraft_catalog::rules::album_rule_id(v).map(|a| a.0); + if AlbumPicker::new(salt, &mut chosen, &env.albums).width(200.0).show(ui).changed() + && let Some(id) = chosen + { + *v = json!(id); + } } /// The value editor for one rule. @@ -438,11 +412,6 @@ mod tests { assert_eq!(default_value("captureDate", "is"), json!("2026")); assert_eq!(default_value("captureDate", "between"), json!(["2026-01", "2026-12"])); lightcraft_catalog::rules::set_now(None); - // the picker shows the album a rule names, however the id is written - let albums = vec![(3, "Trip".to_string())]; - assert_eq!(chosen_album(&json!(3), &albums).map(|a| a.1.as_str()), Some("Trip")); - assert_eq!(chosen_album(&json!("3"), &albums).map(|a| a.1.as_str()), Some("Trip")); - assert_eq!(chosen_album(&json!(4), &albums), None); assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"])); } diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs new file mode 100644 index 00000000..fd9121d5 --- /dev/null +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -0,0 +1,116 @@ +//! The album picker in the smart-album rule editor: a tree like the sidebar's, a search that +//! narrows it as you type, keys to pick with, and albums that can't be picked shown greyed. + +use std::time::Duration; + +use lightcraft_catalog::Rule; +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); + +fn exec(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}"); + r["result"].clone() +} + +fn id(v: serde_json::Value) -> u64 { + v["id"].as_u64().expect("an id") +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn value(h: &Headless) -> serde_json::Value { + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Some(Rule::Field { value, .. }) = rules.rules.first() else { panic!("no rule") }; + value.clone() +} + +struct Library { + h: Headless, + utils: u64, + excluded: u64, + uncurated: u64, + trip: u64, +} + +/// [UTILS] > Excluded (smart), Uncurated (smart); Trip; and the rule editor on a new album with one +/// Album rule. +fn library() -> Library { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let utils = id(exec(&mut h, "album.create", json!({"name": "[UTILS]", "folder": true}))); + let excluded = id(exec(&mut h, "album.createSmart", json!({"name": "Excluded", "rules": {"rating": 1}, "parent": utils}))); + let uncurated = id(exec(&mut h, "album.createSmart", json!({"name": "Uncurated", "rules": {"rating": 0}, "parent": utils}))); + let trip = id(exec(&mut h, "album.create", json!({"name": "Trip", "addSelected": false}))); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Travel"})); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules = vec![serde_json::from_value(json!({"field": "album", "op": "isNot", "value": null})).unwrap()]; + h.settle(SETTLE); + Library { h, utils, excluded, uncurated, trip } +} + +/// With nothing typed, the picker shows the sidebar's tree: folders start closed and open on click. +#[test] +fn the_tree_opens_folder_by_folder() { + let Library { mut h, utils, excluded, trip, .. } = library(); + click(&mut h, "albumPicker:rules-0"); + assert!(has(&h, &format!("albumPickerFolder:{utils}:rules-0")) && has(&h, &format!("albumPickerItem:{trip}:rules-0"))); + assert!(!has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "inside a closed folder"); + click(&mut h, &format!("albumPickerFolder:{utils}:rules-0")); + click(&mut h, &format!("albumPickerItem:{excluded}:rules-0")); + assert_eq!(value(&h), json!(excluded)); + assert!(!has(&h, &format!("albumPickerItem:{trip}:rules-0")), "picking closes it"); + // it opens again on the chosen album's folder + click(&mut h, "albumPicker:rules-0"); + assert!(has(&h, &format!("albumPickerItem:{excluded}:rules-0"))); +} + +/// Typing narrows the albums to those whose path holds every word; Enter picks the first, the +/// arrow keys move through them. +#[test] +fn typing_finds_and_keys_pick() { + let Library { mut h, excluded, uncurated, trip, .. } = library(); + click(&mut h, "albumPicker:rules-0"); + let r = h.request("ui.text", json!({"text": "utils"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(has(&h, &format!("albumPickerItem:{excluded}:rules-0")) && has(&h, &format!("albumPickerItem:{uncurated}:rules-0"))); + assert!(!has(&h, &format!("albumPickerItem:{trip}:rules-0")), "Trip doesn't match"); + for key in ["ArrowDown", "Enter"] { + let r = h.request("ui.key", json!({"key": key}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(value(&h), json!(uncurated), "the second match"); + assert!(h.app.ui.dialog.is_some(), "Enter picked, it didn't confirm the dialog"); +} + +/// Albums that can't be picked (the one being edited, one that would loop) are shown greyed, not +/// hidden, and clicking them does nothing. +#[test] +fn albums_that_would_loop_are_greyed() { + let Library { mut h, excluded, .. } = library(); + let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": excluded}]}}); + let travel = id(exec(&mut h, "album.createSmart", json!({"name": "Travel", "rules": rules}))); + exec(&mut h, "dialog.smartAlbum", json!({"id": excluded})); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules = vec![serde_json::from_value(json!({"field": "album", "op": "is", "value": null})).unwrap()]; + h.settle(SETTLE); + click(&mut h, "albumPicker:rules-0"); + assert!(has(&h, &format!("albumPickerItem:{travel}:rules-0")), "shown"); + click(&mut h, &format!("albumPickerItem:{travel}:rules-0")); + assert_eq!(value(&h), json!(null), "but not picked"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 5cf53d1c..fdf35139 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -392,13 +392,13 @@ fn smart_album_editor_picks_an_album_from_a_list() { rules.rules[0] = serde_json::from_value(json!({"field": "album", "op": "is", "value": 0})).unwrap(); h.settle(SETTLE); assert!(h.app.widgets.iter().any(|(w, _)| w == "ruleProblem:rules-0"), "no album chosen yet"); - let r = h.request("ui.clickWidget", json!({"id": "ruleAlbum:rules-0"}), T); + let r = h.request("ui.clickWidget", json!({"id": "albumPicker:rules-0"}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); - assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0")), "plain albums are offered"); - assert!(has(&h, format!("ruleAlbumItem:{smart}:rules-0")), "smart albums too"); - let r = h.request("ui.clickWidget", json!({"id": format!("ruleAlbumItem:{trip}:rules-0")}), T); + assert!(has(&h, format!("albumPickerItem:{trip}:rules-0")), "plain albums are offered"); + assert!(has(&h, format!("albumPickerItem:{smart}:rules-0")), "smart albums too"); + let r = h.request("ui.clickWidget", json!({"id": format!("albumPickerItem:{trip}:rules-0")}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; @@ -480,8 +480,8 @@ fn sidebar_marks_smart_albums_with_problems() { assert!(marked(&h), "its album is gone"); } -/// Editing "Excluded Photos" while "Travel" tests it: the list offers neither the album itself nor -/// Travel (testing it would loop back), and a rule set to Travel by other means is marked. +/// Editing "Excluded Photos" while "Travel" tests it: the list shows the album itself and Travel +/// greyed (testing Travel would loop back), and a rule set to Travel by other means is marked. #[test] fn smart_album_editor_keeps_albums_from_including_themselves() { let mut h = detail("panel.edit"); @@ -496,13 +496,19 @@ fn smart_album_editor_keeps_albums_from_including_themselves() { h.settle(SETTLE); }; set(&mut h, json!(null)); - let r = h.request("ui.clickWidget", json!({"id": "ruleAlbum:rules-0"}), T); + let r = h.request("ui.clickWidget", json!({"id": "albumPicker:rules-0"}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); - assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0"))); - assert!(!has(&h, format!("ruleAlbumItem:{excluded}:rules-0")), "not itself"); - assert!(!has(&h, format!("ruleAlbumItem:{travel}:rules-0")), "not an album that tests it"); + assert!(has(&h, format!("albumPickerItem:{trip}:rules-0"))); + // itself and an album that tests it are shown, greyed: clicking them picks nothing + for blocked in [excluded, travel] { + let r = h.request("ui.clickWidget", json!({"id": format!("albumPickerItem:{blocked}:rules-0")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + assert!(matches!(&rules.rules[0], Rule::Field { value, .. } if value.is_null()), "{blocked} wasn't picked"); + } let _ = h.request("ui.key", json!({"key": "Escape"}), T); h.settle(SETTLE); set(&mut h, json!(travel));