diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 0f8c5b2..7b073ce 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -114,13 +114,78 @@ impl Granularity { } /// A sensible bucket size for a span of seconds, so the UI need not guess. + /// + /// # Chosen by how many bars it produces, not by fixed cut-offs + /// + /// This used to be four thresholds on the span, which reads sensibly and + /// behaves badly under zoom. Each zoom step halves the span, so the bar + /// count halves with it until a threshold is crossed — a fifteen-year + /// library went 15 bars, 8, then 46, 23, 11, and finally *6*. Zooming in + /// made the picture coarser, which is the opposite of what zooming is for. + /// + /// So the choice is made on the axis's terms: of the four bucket sizes, + /// take the one whose bar count comes nearest [`Self::TARGET_BARS`]. The + /// count then stays in the same neighbourhood at every zoom level, and + /// each step in genuinely shows finer structure rather than the same + /// structure drawn wider. + /// + /// Nearest in *ratio*, not in difference: the counts available for a given + /// span are orders of magnitude apart — a span is either about 4 years or + /// about 48 months — and on a linear measure the larger count always looks + /// further away, which would bias every choice towards too few bars. pub fn for_span(seconds: i64) -> Self { + let seconds = seconds.max(1) as f64; + // Finest first, so that when two options are equally far from the + // target the finer one wins: `min_by` keeps the first minimum it saw, + // and more detail is the better failure. + [ + Granularity::Hour, + Granularity::Day, + Granularity::Month, + Granularity::Year, + ] + .into_iter() + .min_by(|a, b| { + let cost = |g: Granularity| { + let bars = seconds / g.approx_seconds() as f64; + // How far off the target, measured multiplicatively: twice as + // many and half as many are equally wrong. + // + // Deliberately not clamped to at least one bar. A span shorter + // than a bucket scores *worse* the coarser the bucket, which is + // what makes an hour of photographs pick hourly bars instead of + // every option tying at "one bar" and the coarsest winning. + (bars / Self::TARGET_BARS as f64).ln().abs() + }; + cost(*a) + .partial_cmp(&cost(*b)) + // Ties cannot arise from real spans, but a NaN would; falling + // back to the coarser option keeps the axis drawable. + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(Granularity::Day) + } + + /// How many bars the timeline wants across its axis. + /// + /// Not a hard count — the bucket sizes are calendar units, so the actual + /// number lands where the calendar puts it. It is the figure the choice + /// aims at: enough bars that a busy fortnight is visibly busier than a + /// quiet one, few enough that each is wide enough to hit with a finger. + const TARGET_BARS: i64 = 40; + + /// Nominal length of one bucket, for choosing between them. + /// + /// Approximate on purpose: months and years vary and it does not matter + /// here, because this only ranks four options that are a factor of ~12 or + /// ~30 apart. The exact boundaries come from `strftime` on the real dates. + fn approx_seconds(self) -> i64 { const DAY: i64 = 86_400; - match seconds { - s if s > 5 * 365 * DAY => Granularity::Year, - s if s > 90 * DAY => Granularity::Month, - s if s > 2 * DAY => Granularity::Day, - _ => Granularity::Hour, + match self { + Granularity::Year => 365 * DAY, + Granularity::Month => 30 * DAY, + Granularity::Day => DAY, + Granularity::Hour => 3600, } } } @@ -422,10 +487,34 @@ mod tests { #[test] fn timeline_granularity_follows_the_span() { const DAY: i64 = 86_400; - assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Year); - assert_eq!(Granularity::for_span(120 * DAY), Granularity::Month); + // Chosen by how many bars it makes, not by fixed cut-offs — see + // `for_span`. Ten years of yearly bars is ten bars, which says almost + // nothing about a library; monthly is 122, which is a shape. + assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Month); + assert_eq!(Granularity::for_span(120 * DAY), Granularity::Day); assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day); assert_eq!(Granularity::for_span(3600), Granularity::Hour); + + // The property the target exists for: zooming in never coarsens the + // axis. Under the old thresholds a fifteen-year library went 15 bars, + // then 8, then 46, 23, 11 — finer spans drawn with wider bars. + let mut span = 15 * 365 * DAY; + let mut previous = Granularity::for_span(span).approx_seconds(); + for _ in 0..10 { + span /= 2; + let bucket = Granularity::for_span(span).approx_seconds(); + assert!( + bucket <= previous, + "halving the span to {span}s coarsened the bucket \ + from {previous}s to {bucket}s" + ); + previous = bucket; + } + + // And a span shorter than any bucket still picks the finest, rather + // than every option tying at one bar and the coarsest winning. + assert_eq!(Granularity::for_span(60), Granularity::Hour); + assert_eq!(Granularity::for_span(1), Granularity::Hour); } #[test] diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index ed69ff5..352fb83 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -19,7 +19,9 @@ pub use settings::{ CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings, ExportTarget, ImportSettings, OutputSharpening, Settings, SizingMode, }; -pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil}; +pub use time::{ + civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil, +}; /// Identifies a granted library location — a directory on Linux, a persisted /// document tree on Android. diff --git a/core/dr-types/src/time.rs b/core/dr-types/src/time.rs index 87c3120..b77de9f 100644 --- a/core/dr-types/src/time.rs +++ b/core/dr-types/src/time.rs @@ -64,6 +64,68 @@ pub fn civil_from_unix_at(t: i64, offset: Option) -> Civil { civil_from_unix(t + offset.unwrap_or(0) as i64 * 60) } +/// TRACES: FR-CAT-6 +/// A civil date back to Unix seconds — midnight UTC on that day. +/// +/// The exact inverse of [`civil_from_unix`]'s date part, by the companion +/// era-based algorithm, so a date formatted by [`format_date`] and read back +/// here lands on the same day it named. Hand-rolled for the same reason the +/// forward direction is: four fields do not justify a date library, and two +/// implementations that disagree would put a photograph on a day the timeline +/// does not show it. +/// +/// Midnight rather than noon because a range is half-open in spirit: "from the +/// 3rd" means from the first instant of the 3rd, and the caller decides what +/// "to the 5th" means by adding a day if it wants the 5th included. +pub fn unix_from_civil(year: i64, month: i64, day: i64) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if month > 2 { month - 3 } else { month + 9 }; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + (era * 146_097 + doe - 719_468) * 86_400 +} + +/// TRACES: FR-CAT-6 +/// `YYYY-MM-DD` to Unix seconds, or `None` if that is not what it is. +/// +/// Strict on purpose. This reads what a person typed into a date field, and +/// the useful answer to "13/2026/08" is that it is not a date — guessing at an +/// order would silently filter a library to a span nobody asked for, and the +/// grid going empty is a poor way to learn your input was misread. +/// +/// The day is checked against the month it is in, so `2026-02-30` is refused +/// rather than quietly becoming the 2nd of March. +pub fn parse_date(text: &str) -> Option { + let text = text.trim(); + let mut parts = text.split('-'); + let year: i64 = parts.next()?.parse().ok()?; + let month: i64 = parts.next()?.parse().ok()?; + let day: i64 = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + if !(1..=12).contains(&month) || day < 1 { + return None; + } + if day > days_in_month(year, month) { + return None; + } + Some(unix_from_civil(year, month, day)) +} + +/// Length of a month, with the full Gregorian leap rule. +fn days_in_month(year: i64, month: i64) -> i64 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29, + 2 => 28, + _ => 0, + } +} + /// A capture instant as `YYYY-MM-DD`. pub fn format_date(t: i64) -> String { let c = civil_from_unix(t); @@ -74,6 +136,51 @@ pub fn format_date(t: i64) -> String { mod tests { use super::*; + /// The two directions must agree, or a photograph is filed on a day the + /// timeline does not show it on — the failure this module exists to stop. + #[test] + fn a_date_survives_the_round_trip() { + for t in [ + 0, // the epoch + 1_760_000_000, // an ordinary recent instant + 951_782_400, // 2000-02-29, the leap day the 100/400 rule keeps + -86_400, // the day before the epoch + ] { + let c = civil_from_unix(t); + let back = unix_from_civil(c.year, c.month, c.day); + assert_eq!( + civil_from_unix(back), + Civil { hour: 0, ..c }, + "{t} -> {c:?} -> {back} disagreed" + ); + } + } + + #[test] + fn parsing_accepts_a_date_and_refuses_everything_else() { + assert_eq!(parse_date("1970-01-01"), Some(0)); + assert_eq!(parse_date(" 2026-08-23 "), parse_date("2026-08-23")); + assert_eq!(format_date(parse_date("2026-08-23").unwrap()), "2026-08-23"); + + // A leap day is real in 2024 and not in 2026. + assert!(parse_date("2024-02-29").is_some()); + assert_eq!(parse_date("2026-02-29"), None); + + // Refused rather than guessed at: silently reordering these would + // filter the library to a span nobody asked for. + assert_eq!( + parse_date("23-08-2026"), + None, + "day-first is not a format here" + ); + assert_eq!(parse_date("2026-13-01"), None, "there is no month 13"); + assert_eq!(parse_date("2026-08-32"), None); + assert_eq!(parse_date("2026-08"), None, "a month is not a date"); + assert_eq!(parse_date("2026-08-23-01"), None); + assert_eq!(parse_date(""), None); + assert_eq!(parse_date("yesterday"), None); + } + #[test] fn the_epoch_is_the_first_of_january_1970() { let c = civil_from_unix(0); diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index fde85db..819a1df 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -3542,6 +3542,20 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc from => (from, to), + _ => span, + }; + // Zoom narrows the span around wherever the view sits rather than around // the library's midpoint, so zooming in keeps what you were looking at. let zoom = *ctl.timeline_zoom.borrow(); @@ -4652,6 +4666,52 @@ pub fn wire( f.captured_to = None; } w.set_library_range_active(on); + show_range(&w, &ctl); + refilter(&w, &ctl); + }); + } + + // TRACES: FR-CAT-6 + // The ends of the range, typed. + // + // The chip alone used to be the whole control and it read its span from + // the timeline's zoom, which is zero until someone zooms — so "limit to + // range" set the range to the entire library and appeared to do nothing. + // The span is shown now, and these two fields are how it is corrected. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_range_edited(move |from, to| { + let Some(w) = weak.upgrade() else { return }; + + // Both ends, or neither. A range with one end parsed and the other + // silently dropped is a filter nobody asked for, and the grid + // going empty is a poor way to find out a date was mistyped. + let (Some(from), Some(to)) = ( + dr_types::parse_date(from.as_str()), + dr_types::parse_date(to.as_str()), + ) else { + w.set_library_range_invalid(true); + return; + }; + + // Typed the other way round is a slip, not an error: the user has + // said which two days they mean and there is exactly one range + // between them. + let (from, to) = if from <= to { (from, to) } else { (to, from) }; + + { + let mut f = ctl.filter.borrow_mut(); + f.captured_from = Some(from); + // Inclusive of the last day. "To the 5th" means the whole of + // the 5th — `parse_date` returns its midnight, and a range + // ending there would exclude every photograph taken on the day + // the user named. + f.captured_to = Some(to + 86_400 - 1); + } + w.set_library_range_invalid(false); + w.set_library_range_active(true); + show_range(&w, &ctl); refilter(&w, &ctl); }); } @@ -5114,6 +5174,46 @@ mod tests { assert_eq!(y, 1969); } + /// The axis follows a chosen range, so its granularity does too. + /// + /// The complaint this fixes: a fortnight selected out of a fifteen-year + /// library was still drawn as one column of a year-per-bar axis, which is + /// the least useful picture of a fortnight available. + #[test] + fn a_chosen_range_decides_the_granularity() { + use dr_catalog::Granularity; + const DAY: i64 = 86_400; + + // Fifteen years unfiltered: a bar per year, fifteen of them. + assert_eq!(Granularity::for_span(15 * 365 * DAY), Granularity::Year); + + // Narrowed to a fortnight, the axis is handed the range rather than + // the extent, and a fortnight is drawn as fourteen days. + assert_eq!(Granularity::for_span(14 * DAY), Granularity::Day); + + // A single day's shoot is drawn by the hour. + assert_eq!(Granularity::for_span(DAY), Granularity::Hour); + + // The property rather than the thresholds, because the thresholds are + // no longer what decides it: a narrower range never produces a coarser + // bucket than a wider one. + let mut previous = i64::MAX; + for days in [3650, 730, 180, 60, 14, 3, 1] { + let g = Granularity::for_span(days * DAY); + let bucket = match g { + Granularity::Year => 365 * DAY, + Granularity::Month => 30 * DAY, + Granularity::Day => DAY, + Granularity::Hour => 3600, + }; + assert!( + bucket <= previous, + "{days} days chose a coarser bucket than the range above it" + ); + previous = bucket; + } + } + #[test] fn bucket_labels_match_their_granularity() { use dr_catalog::Granularity; @@ -5554,3 +5654,21 @@ mod display_name_tests { assert_eq!(without_extension(""), ""); } } + +/// TRACES: FR-CAT-6 +/// Show the range the filter is actually using. +/// +/// Pushed back rather than left as the user typed it, because the filter may +/// have adjusted it — ends given backwards are swapped, and the closing day is +/// extended to include itself. A field showing something other than what is +/// being filtered on is worse than one showing nothing. +fn show_range(window: &AppWindow, ctl: &Rc) { + let f = ctl.filter.borrow(); + let text = |t: Option| -> slint::SharedString { + t.map(dr_types::format_date).unwrap_or_default().into() + }; + window.set_library_range_from(text(f.captured_from)); + // The stored end is the last second of the closing day; naming that day is + // what the user typed and what they should read back. + window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1))); +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 3c37cc4..2b0a9f8 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -455,6 +455,11 @@ export component AppWindow inherits Window { /// Whether the grid is narrowed to the timeline's visible span. in-out property library-range-active: false; callback library-toggle-date-range(); + /// TRACES: FR-CAT-6 + in property library-range-from; + in property library-range-to; + in property library-range-invalid: false; + callback library-range-edited(string, string); callback library-retry-connection(); // --- pinning a collection offline (FR-NC-6a) --- @@ -1314,6 +1319,10 @@ in property panel-visible: true; toggle-local-only() => { root.library-toggle-local-only(); } range-active: root.library-range-active; toggle-date-range() => { root.library-toggle-date-range(); } + range-from: root.library-range-from; + range-to: root.library-range-to; + range-invalid: root.library-range-invalid; + range-edited(a, b) => { root.library-range-edited(a, b); } collections-visible: root.collections-visible; toggle-collections() => { root.toggle-collections(); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 44651e6..f8ccfab 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -1098,6 +1098,19 @@ export component LibraryGrid inherits Rectangle { /// Whether the grid is narrowed to the timeline's visible span. in property range-active: false; callback toggle-date-range(); + /// TRACES: FR-CAT-6 + /// The ends of the range, as `YYYY-MM-DD`, when one is set. + /// + /// Strings rather than instants because this is what the user types and + /// what they read back. Rust parses them and refuses what is not a date; + /// `range-invalid` is how that refusal reaches the field, since a filter + /// that silently ignored a typo would show an empty grid and no reason. + in property range-from; + in property range-to; + in property range-invalid: false; + /// Both ends at once: they are one range, and applying half of an edit + /// would filter to a span the user never asked for. + callback range-edited(string, string); /// How many images are selected, for the header's count. in property selected-count: 0; @@ -1732,6 +1745,51 @@ export component LibraryGrid inherits Rectangle { clicked => { root.toggle-date-range(); } } + // The ends, once there is a range to have ends. + // + // Turning the chip on used to be the whole control, and it + // took its span from the timeline's zoom — which is zero until + // you zoom, so the range came out as the entire library and + // the button appeared to do nothing at all. The span is now + // *shown*, and can be typed, so "limit to range" always + // narrows to something a person chose. + // + // Still seeded from the timeline: zooming to a fortnight and + // pressing the chip is the fast path, and these fields say + // which fortnight it landed on. + if root.range-active: HorizontalLayout { + spacing: Theme.gap-sm; + + Field { + width: 108px; + y: (parent.height - self.height) / 2; + text: root.range-from; + placeholder: "YYYY-MM-DD"; + accepted(t) => { root.range-edited(t, root.range-to); } + } + + Caption { + text: "to"; + vertical-alignment: center; + } + + Field { + width: 108px; + y: (parent.height - self.height) / 2; + text: root.range-to; + placeholder: "YYYY-MM-DD"; + accepted(t) => { root.range-edited(root.range-from, t); } + } + + // Says which end is wrong is beyond a caption, but saying + // that *something* is beats an empty grid with no reason. + if root.range-invalid: Caption { + text: "not a date"; + warn: true; + vertical-alignment: center; + } + } + Rectangle { horizontal-stretch: 1; } // What the filter is currently hiding. Without this a narrowed