LRC-LIB-SMARTCOLL: date rules get a calendar picker

Each date field in the smart-album rule editor has a DatePicker beside
its text field. It opens on the rule's date at its precision (or on
today, from the session's clock, passed in rules_editor::Env), and
writes what is picked as 2026, 2026-08 or 2026-08-14. Typing still
works, and opening never changes a rule. In a "between", each date's
picker stops at the other, so the two stay in order.

Headless tests pick a day, a month and a year, move between months and
year pages, keep a "between" in order from both ends, and open an
unreadable date on today without rewriting it.

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 1a82be68ea
commit 090f761d71
4 changed files with 116 additions and 1 deletions
+2
View File
@@ -36,6 +36,8 @@ pub mod widgets;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_date_picker;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
+1
View File
@@ -245,6 +245,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
// checked before drawing, so each row marks its own problem
let env = crate::panels::rules_editor::Env {
problems: rules.check(&app.session.catalog),
today: (app.session.clock)(),
albums: {
let cat = &app.session.catalog;
let mut albums: Vec<(u64, String)> = cat
+23 -1
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::date_picker::{DatePicker, PickedDate};
use crate::theme::Tokens;
use crate::widgets::register;
@@ -89,11 +90,25 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) {
}
}
/// 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`.
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 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)>,
@@ -169,9 +184,13 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
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;
@@ -206,7 +225,10 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
}
});
}
(Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt),
(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),
}
}
+90
View File
@@ -0,0 +1,90 @@
//! The date picker in the smart-album rule editor: picking days, months and years, moving between
//! months, and the bounds a "between" puts on each of its two dates.
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 editor(rules: serde_json::Value) -> Headless {
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 r = h.request("engine.execute", json!({"command": "dialog.smartAlbum", "params": {"name": "Dates"}}), T);
assert_eq!(r["ok"], true, "{r}");
let Some(crate::state::Dialog::SmartRules { rules: r, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") };
*r = serde_json::from_value(json!({"rules": rules})).unwrap();
h.settle(SETTLE);
h
}
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, i: usize) -> 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.get(i) else { panic!("no rule {i}") };
value.clone()
}
/// A day, then a month, then a year: each writes the date at that precision and closes the picker.
#[test]
fn pick_a_day_a_month_and_a_year() {
let mut h = editor(json!([{"field": "captureDate", "op": "is", "value": "2026-01-20"}]));
click(&mut h, "datePicker:rules-0");
assert!(has(&h, "datePickerCell:2026-01-20:rules-0"), "opens on the rule's month");
click(&mut h, "datePickerNext:rules-0");
click(&mut h, "datePickerCell:2026-02-14:rules-0");
assert_eq!(value(&h, 0), json!("2026-02-14"));
assert!(!has(&h, "datePickerCell:2026-02-14:rules-0"), "picking closes it");
click(&mut h, "datePicker:rules-0");
click(&mut h, "datePickerPrecision:month:rules-0");
click(&mut h, "datePickerCell:2026-08:rules-0");
assert_eq!(value(&h, 0), json!("2026-08"));
click(&mut h, "datePicker:rules-0");
assert!(has(&h, "datePickerCell:2026-08:rules-0"), "a month opens at month precision");
click(&mut h, "datePickerPrecision:year:rules-0");
click(&mut h, "datePickerPrev:rules-0");
click(&mut h, "datePickerCell:2015:rules-0");
assert_eq!(value(&h, 0), json!("2015"));
}
/// Each date of a "between" can't pass the other: the earlier one can't be picked after the later.
#[test]
fn between_dates_stay_in_order() {
let mut h = editor(json!([{"field": "captureDate", "op": "between", "value": ["2026-03", "2026-06"]}]));
click(&mut h, "datePicker:rules-0-a");
click(&mut h, "datePickerCell:2026-09:rules-0-a");
assert_eq!(value(&h, 0), json!(["2026-03", "2026-06"]), "after the later date: not taken");
click(&mut h, "datePickerCell:2026-05:rules-0-a");
assert_eq!(value(&h, 0), json!(["2026-05", "2026-06"]));
click(&mut h, "datePicker:rules-0-b");
click(&mut h, "datePickerCell:2026-04:rules-0-b");
assert_eq!(value(&h, 0), json!(["2026-05", "2026-06"]), "before the earlier date: not taken");
click(&mut h, "datePickerCell:2026-12:rules-0-b");
assert_eq!(value(&h, 0), json!(["2026-05", "2026-12"]));
}
/// A rule without a readable date opens on today's month (the session's clock), and opening it
/// leaves the rule as written.
#[test]
fn an_unreadable_date_opens_on_this_month() {
let mut h = editor(json!([{"field": "captureDate", "op": "after", "value": "banana"}]));
h.app.session.clock = Box::new(|| "2026-10-09T12:00:00".to_string());
h.settle(SETTLE);
click(&mut h, "datePicker:rules-0");
assert!(has(&h, "datePickerCell:2026-10-09:rules-0"));
assert_eq!(value(&h, 0), json!("banana"), "opening doesn't change the rule");
}