Files
lightcraft/crates/ui-egui/src/panels/rules_editor.rs
T
Nathaniel LedfordandClaude Opus 5.5 94de255e74 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>
2026-10-09 10:54:57 -04:00

419 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The smart-album rule editor: match all / any / none of a list of rules (field, operator,
//! value), with nested groups. Field and operator lists come from `lightcraft_catalog::rules`.
use egui::RichText;
use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, Problem, TOP_LEVEL_FIELDS, bool_value, field_kind, field_label, ops_for};
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;
/// A sensible starting value for `field` / `op`.
pub fn default_value(field: &str, op: &str) -> Value {
match (field_kind(field), op) {
(_, "isEmpty" | "isNotEmpty") => Value::Null,
(Some(Kind::Date), "inLast" | "notInLast") => json!({"n": 30, "unit": "days"}),
// this year (the catalog's clock): a date that means something, not an empty one that
// matches nothing ("is") or every date ("between")
(Some(Kind::Date), "between") => {
let year = this_year();
json!([format!("{year}-01"), format!("{year}-12")])
}
(Some(Kind::Date), _) => json!(this_year()),
(Some(Kind::Number), "between") if field == "shutterSpeed" => json!(["1/1000", "1/125"]),
(Some(Kind::Number), _) if field == "shutterSpeed" => json!("1/250"),
(Some(Kind::Number), "between") => json!([0, 0]),
(Some(Kind::Number), _) => json!(if field == "rating" { 3 } else { 0 }),
(Some(Kind::Choice(c)), _) => json!(c.first().map_or("", |c| c.0)),
(Some(Kind::Bool), _) => json!(true),
// picked from the list
(Some(Kind::Album), _) => Value::Null,
_ => json!(""),
}
}
/// The current year, `2026`.
fn this_year() -> String {
lightcraft_catalog::rules::now().get(..4).unwrap_or("2026").to_string()
}
/// A new rule (Rating ≥ 3).
pub fn new_rule() -> Rule {
Rule::Field { field: "rating".into(), op: "gte".into(), value: json!(3) }
}
fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) {
let label = |m: Match| match m {
Match::All => "all",
Match::Any => "any",
Match::None => "none",
};
egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| {
for x in [Match::All, Match::Any, Match::None] {
ui.selectable_value(m, x, crate::i18n::tr(label(x)));
}
});
}
fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &str) {
let mut s = match &*v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
};
let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(crate::i18n::tr(hint)).desired_width(width));
register(ui.ctx(), format!("field:{salt}"), r.rect);
if r.changed() {
*v = json!(s);
}
}
fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) {
let mut n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())).unwrap_or(0.0);
let (lo, hi, speed) = match field {
"rating" => (0.0, 5.0, 0.05),
"iso" => (0.0, 409_600.0, 10.0),
"aperture" => (0.0, 64.0, 0.05),
"keywordCount" | "personCount" => (0.0, 1000.0, 0.05),
"longEdge" | "shortEdge" => (0.0, 100_000.0, 10.0),
_ => (0.0, 100_000.0, 0.5),
};
let whole = matches!(field, "rating" | "iso" | "keywordCount" | "personCount" | "longEdge" | "shortEdge");
// the range limits dragging; a stored value outside it (a rating of 9 from an agent) is shown
// as it is, marked by the check, not quietly clamped to something that matches
let dv = egui::DragValue::new(&mut n).range(lo..=hi).clamp_existing_to_range(false).speed(speed);
let dv = if whole { dv.fixed_decimals(0) } else { dv.max_decimals(2) };
if ui.add(dv).changed() {
*v = if whole { json!(n.round() as i64) } else { json!(n) };
}
}
/// A rule date's calendar ([`DatePicker`]): opens on the date as written (or today), keeps its
/// precision, and writes what is picked as `2026`, `2026-08` or `2026-08-14`. It picks dates, not
/// times: picking a day for "2026-08-14T10:00" leaves "2026-08-14" (a time is typed).
fn date_picker(ui: &mut egui::Ui, v: &mut Value, salt: &str, min: Option<PickedDate>, max: Option<PickedDate>, env: &Env) {
let mut date = v.as_str().and_then(PickedDate::parse);
let today = PickedDate::parse(&env.today);
if DatePicker::new(salt, &mut date).min(min).max(max).today(today).show(ui).changed()
&& let Some(date) = date
{
*v = json!(date.iso());
}
}
/// What the editor shows besides the rules: the problems `RuleSet::check` found in them (each
/// row marks its own) and the albums an Album rule can test.
#[derive(Default)]
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, 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>,
}
/// 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 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.
fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &str, env: &Env) {
match (field_kind(field), op) {
(_, "isEmpty" | "isNotEmpty") => {}
(Some(Kind::Album), _) => album_value(ui, v, salt, env),
(Some(Kind::Date), "inLast" | "notInLast") => {
// shown as stored ({n, unit}, or a plain number of days) and only written when changed:
// drawing never turns "0 days" into 1 or 20,000 into the spinner's 10,000
let (stored_n, stored_unit) = match &*v {
Value::Object(o) => (o.get("n").and_then(Value::as_f64), o.get("unit").and_then(Value::as_str).unwrap_or("days").to_string()),
Value::Number(n) => (n.as_f64(), "days".to_string()),
_ => (None, "days".to_string()),
};
let mut n = stored_n.unwrap_or(30.0);
let mut unit = stored_unit.to_lowercase();
let mut changed =
ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed();
egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| {
for u in ["hours", "days", "weeks", "months", "years"] {
if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() {
unit = u.to_string();
changed = true;
}
}
});
if changed {
*v = json!({"n": n.round(), "unit": unit});
}
}
(Some(k), "between") => {
// a value that isn't a pair is shown as the default pair but kept until one is edited,
// so the check can mark it
let mut pair = v.as_array().filter(|a| a.len() == 2).cloned().map_or_else(|| default_value(field, op), Value::Array);
let before = pair.clone();
let Some([lo, hi]) = pair.as_array_mut().map(Vec::as_mut_slice) else { return };
if field == "shutterSpeed" {
text_value(ui, lo, 60.0, "1/1000", &format!("{salt}-a"));
ui.label(crate::i18n::tr("and"));
text_value(ui, hi, 60.0, "1/125", &format!("{salt}-b"));
} else if k == Kind::Number {
number_value(ui, lo, field);
ui.label(crate::i18n::tr("and"));
number_value(ui, hi, field);
} else {
// each date's picker stops at the other one, so the two stay in order
let (lo_date, hi_date) = (lo.as_str().and_then(PickedDate::parse), hi.as_str().and_then(PickedDate::parse));
text_value(ui, lo, 86.0, "2026-01-01", &format!("{salt}-a"));
date_picker(ui, lo, &format!("{salt}-a"), None, hi_date, env);
ui.label(crate::i18n::tr("and"));
text_value(ui, hi, 86.0, "2026-12", &format!("{salt}-b"));
date_picker(ui, hi, &format!("{salt}-b"), lo_date, None, env);
}
if pair != before {
*v = pair;
}
}
// camera notation: 1/250, 0.5, 2"
(Some(Kind::Number), _) if field == "shutterSpeed" => text_value(ui, v, 70.0, "1/250", salt),
(Some(Kind::Number), _) => number_value(ui, v, field),
(Some(Kind::Choice(c)), _) => {
let cur = v.as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(
ui,
|ui| {
for (id, _) in c.iter() {
if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() {
*v = json!(id);
}
}
},
);
}
(Some(Kind::Bool), _) => {
// the catalog's reading of the value ("false" is no); one that is neither yes nor no is
// shown as written and kept until a choice replaces it, so the dialog reports it
let cur = bool_value(v);
let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string());
egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| {
for b in [true, false] {
if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() {
*v = json!(b);
}
}
});
}
(Some(Kind::Date), _) => {
text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt);
date_picker(ui, v, salt, None, None, env);
}
_ => text_value(ui, v, 140.0, "", salt),
}
}
/// The field menu of one rule: the top-level fields, then one submenu per field group. Picking a
/// field keeps the operator when the new field has it, else takes the field's first operator.
fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String, value: &mut Value) {
let label = field_label(field).unwrap_or(field.as_str()).to_string();
let current = field.clone();
let r =
egui::ComboBox::from_id_salt(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| {
let mut pick = None;
let mut item = |ui: &mut egui::Ui, id: &'static str| {
let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id)));
register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect);
if r.clicked() {
pick = Some(id);
ui.close();
}
};
for id in TOP_LEVEL_FIELDS {
item(ui, id);
}
ui.separator();
// submenu rows look like the items above them; the one holding the current field stands out
// (a transparent frame, not none: a frameless button drops its padding and hover fill)
let inactive = &mut ui.visuals_mut().widgets.inactive;
inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
inactive.bg_stroke = egui::Stroke::NONE;
for (group, ids) in FIELD_GROUPS {
let text = RichText::new(crate::i18n::tr(group));
let text = if ids.contains(&current.as_str()) { text.strong() } else { text };
let r = ui.menu_button(text, |ui| {
for id in *ids {
item(ui, id);
}
});
register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect);
}
pick
});
register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect);
let Some(Some(id)) = r.inner else { return };
if field == id {
return;
}
*field = id.to_string();
let ops = ops_for(field_kind(id).unwrap_or(Kind::Text));
if !ops.iter().any(|o| o.0 == op.as_str()) {
*op = ops.first().map_or("is", |o| o.0).to_string();
}
*value = default_value(field, op);
}
/// A rule's problem, shown under it in the caution colour (`ruleProblem:<salt>` for tests and
/// agents).
fn problem_note(ui: &mut egui::Ui, problem: Option<&Problem>, salt: &str) {
let Some(p) = problem else { return };
let t = Tokens::get(ui.ctx());
let r = ui.label(RichText::new(format!("⚠ {}", crate::i18n::problem_text(p))).color(t.caution).small());
register(ui.ctx(), format!("ruleProblem:{salt}"), r.rect);
}
/// Edit `rs` in place; `salt` keeps widget ids apart between groups, `path` is where `rs` sits
/// (empty at the top) so each row finds its own problem in `env`.
pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: &[usize], env: &Env) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr("Match")).color(t.text_label));
match_combo(ui, salt, &mut rs.mode);
ui.label(RichText::new(crate::i18n::tr("of the following rules:")).color(t.text_label));
});
let mut remove = None;
let mut insert: Option<(usize, Rule)> = None;
for (i, rule) in rs.rules.iter_mut().enumerate() {
let rsalt = format!("{salt}-{i}");
let here: Vec<usize> = path.iter().copied().chain([i]).collect();
let problem = env.problems.iter().find(|p| p.path == here);
match rule {
Rule::Group { group } => {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
if ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this group")).clicked() {
remove = Some(i);
}
ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1, &here, env));
});
problem_note(ui, problem, &rsalt);
});
}
Rule::Field { field, op, value } => {
ui.horizontal(|ui| {
field_menu(ui, &rsalt, field, op, value);
let kind = field_kind(field).unwrap_or(Kind::Text);
let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| {
for (id, l) in ops_for(kind) {
if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id {
let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty");
*op = id.to_string();
if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") {
*value = default_value(field, op);
}
}
}
});
value_editor(ui, field, op, value, &rsalt, env);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)"));
register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect);
if add.clicked() {
let group = ui.input(|i| i.modifiers.alt) && depth < 3;
insert = Some((
i + 1,
if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() },
));
}
let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule"));
register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect);
if del.clicked() {
remove = Some(i);
}
});
});
problem_note(ui, problem, &rsalt);
}
}
}
if let Some(i) = remove {
rs.rules.remove(i);
}
if let Some((i, r)) = insert {
rs.rules.insert(i.min(rs.rules.len()), r);
}
if rs.rules.is_empty() || depth == 0 {
ui.horizontal(|ui| {
let add = ui.small_button(crate::i18n::tr("+ Rule"));
register(ui.ctx(), format!("button:ruleAddEnd-{salt}"), add.rect);
if add.clicked() {
rs.rules.push(new_rule());
}
if depth < 3 && ui.small_button(crate::i18n::tr("+ Group")).clicked() {
rs.rules.push(Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } });
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_catalog::rules::FIELDS;
use lightcraft_catalog::{Catalog, Photo, PhotoId, Source};
/// Every field × operator starts with a known operator and a value of the right shape: none for
/// the empty tests, a pair for "between", one of the choices, a number for number fields (a
/// real time in camera notation for shutter speed); and evaluating it never panics.
#[test]
fn default_values_make_valid_rules() {
let cat = Catalog::new();
let p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-10-01T00:00:00");
for (field, _, kind) in FIELDS {
for (op, _) in ops_for(*kind) {
let rs = RuleSet {
mode: Match::All,
rules: vec![Rule::Field { field: field.to_string(), op: op.to_string(), value: default_value(field, op) }],
};
// only a text rule (nothing to look for yet) and Album (no album picked yet) wait for
// input; every other default is a rule that means something
let problems = rs.check(&cat);
let waits = matches!(kind, Kind::Text | Kind::Keywords) || *field == "album";
assert!(problems.is_empty() || waits && !matches!(*op, "isEmpty" | "isNotEmpty"), "{field} {op}: {problems:?}");
let _ = rs.matches(&p, &cat);
let v = default_value(field, op);
let one = |v: &Value| match kind {
Kind::Choice(c) => v.as_str().is_some_and(|s| c.iter().any(|c| c.0 == s)),
Kind::Number if *field == "shutterSpeed" => v.as_str().and_then(lightcraft_catalog::parse_shutter_seconds).is_some(),
Kind::Number => v.is_number(),
Kind::Bool => v.is_boolean(),
Kind::Album => v.is_null(),
Kind::Text | Kind::Keywords | Kind::Date => v.is_string(),
};
match *op {
"isEmpty" | "isNotEmpty" => assert!(v.is_null(), "{field} {op}: {v}"),
"between" => assert!(v.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(one)), "{field} {op}: {v}"),
"inLast" | "notInLast" => assert!(v["n"].is_number(), "{field} {op}: {v}"),
_ => assert!(one(&v), "{field} {op}: {v}"),
}
}
}
// a new date rule starts at this year, not at "" (and a between doesn't match every date)
lightcraft_catalog::rules::set_now(Some("2026-10-09T12:00:00".into()));
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);
assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250"));
assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"]));
}
}