mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
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:
co-authored by
Claude Opus 5.5
parent
1a82be68ea
commit
090f761d71
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user