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:25:10 -04:00
parent 872bff96ea
commit 94de255e74
No known key found for this signature in database
14 changed files with 565 additions and 80 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": "このアルバムを条件にしているため、逆に指定すると循環します"
}

View File

@ -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"
}

View File

@ -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": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл"
}

View File

@ -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": "它引用了此相册,反过来引用会形成循环"
}

View File

@ -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": "它引用了此相簿,反過來引用會形成循環"
}

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

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 {

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

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> {

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"]));
}

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

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