LRC-LIB-SMARTCOLL: a standalone album picker with a tree and search

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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 10:54:57 -04:00
co-authored by Claude Opus 5.5
parent 872bff96ea
commit 94de255e74
14 changed files with 565 additions and 80 deletions
+371
View File
@@ -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<u64>` 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:<id>` (the button), `albumPickerSearch:<id>`, `albumPickerFolder:<folder>:<id>`
//! and `albumPickerItem:<album>:<id>`.
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<u64>,
/// 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<String>,
}
/// 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<String>) -> Vec<AlbumEntry> {
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<lightcraft_catalog::AlbumId>, usize)> = vec![(None, 0)];
let mut seen = std::collections::HashSet::new();
fn walk(
parent: Option<lightcraft_catalog::AlbumId>,
kids: &std::collections::HashMap<Option<lightcraft_catalog::AlbumId>, Vec<&lightcraft_catalog::Album>>,
blocked: &dyn Fn(&lightcraft_catalog::Album) -> Option<String>,
seen: &mut std::collections::HashSet<lightcraft_catalog::AlbumId>,
out: &mut Vec<AlbumEntry>,
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<usize> = 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<u64> {
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<usize> {
(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<usize> {
let query = query.trim().to_lowercase();
let words: Vec<&str> = query.split_whitespace().collect();
if words.is_empty() {
return Vec::new();
}
let hits: Vec<usize> = (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<usize>, Vec<usize>) = 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<u64>,
/// 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<u64>,
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<String>, value: &'a mut Option<u64>, 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<u64> = 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<u64>| {
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<usize> = 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<u64>, 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<AlbumEntry> {
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<_>>(), 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::<Vec<_>>();
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::<Vec<_>>();
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::<u64>::new(), "an empty search isn't a search");
assert_eq!(ids("zzz"), Vec::<u64>::new());
let mut accents = vec![e(7, "Été", None, false, false)];
set_depths(&mut accents);
assert_eq!(search(&accents, "été").len(), 1);
}
}
+9 -1
View File
@@ -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 {
+3
View File
@@ -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)]
+9 -18
View File
@@ -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<u64>, 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<u64>) -> Vec<crate::album_picker::AlbumEntry> {
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<serde_json::Value, String> {
+13 -44
View File
@@ -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<Problem>,
/// 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<AlbumEntry>,
}
/// 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"]));
}
+116
View File
@@ -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");
}
+16 -10
View File
@@ -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));