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));