diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7ea24742..8f4e0bb2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 954cd9f59..d540baa9d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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 diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index cae91ac20..a29487ba6 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -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, max: Option, 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, + /// 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), } } diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests_date_picker.rs new file mode 100644 index 000000000..63565b22d --- /dev/null +++ b/crates/ui-egui/src/tests_date_picker.rs @@ -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"); +}