Files
lightcraft/crates/ui-egui/src/dialogs/rules_editor.rs
T

444 lines
22 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;
pub mod rules_move;
/// 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",
};
let combo = crate::widgets::combo_box(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)));
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Match"));
}
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, 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 {
// n as the catalog reads it: a number, or one written as text ("7")
Value::Object(o) => (
o.get("n").and_then(|n| n.as_f64().or_else(|| n.as_str().and_then(|t| t.trim().parse().ok()))),
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();
let combo = crate::widgets::combo_box(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;
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Unit"));
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 if k == Kind::Date {
// 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);
} else {
// a field without "between" (the check marks it): its two values, as text
text_value(ui, lo, 86.0, "", &format!("{salt}-a"));
ui.label(crate::i18n::tr("and"));
text_value(ui, hi, 86.0, "", &format!("{salt}-b"));
}
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();
let combo = crate::widgets::combo_box(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);
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr(field_label(field).unwrap_or(field)));
}
(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());
let combo = crate::widgets::combo_box(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);
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr(field_label(field).unwrap_or(field)));
}
(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 = crate::widgets::combo_box(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, 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; the
// list's style already drops the resting border)
ui.visuals_mut().widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
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 = crate::widgets::menu_button(ui, text, |ui| {
for id in *ids {
item(ui, id);
}
});
register(ui, format!("ruleFieldGroup:{group}:{salt}"), r.response.rect);
}
pick
});
crate::widgets::name_combo(&r.response, crate::i18n::tr("Field"));
register(ui, 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);
}
/// How wide `ui.small_button(text)` is: its text and the button's padding.
fn small_button_width(ui: &egui::Ui, text: &str) -> f32 {
let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
galley.size().x + 2.0 * ui.spacing().button_padding.x
}
/// 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, 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();
let combo =
crate::widgets::combo_box(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);
}
}
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Operator"));
value_editor(ui, field, op, value, &rsalt, env);
// − and + at the end of the row, after the value however long it is: a
// right-to-left layout put them over the value when the row had no room left
// (#2353), and the dialog widens to the row instead
let buttons = small_button_width(ui, "−") + ui.spacing().item_spacing.x + small_button_width(ui, "+");
ui.add_space((ui.available_width() - buttons).max(0.0));
let del = ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this rule"));
register(ui, format!("button:ruleRemove-{rsalt}"), del.rect);
if del.clicked() {
remove = Some(i);
}
let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)"));
register(ui, 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() },
));
}
});
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, 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"]));
}
}