diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 2ea5dbe..ba89f69 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -203,12 +203,46 @@ pub struct RatingFilter { /// the count disagreeing with the cells is the specific bug this type's /// "filter in SQL" rule exists to prevent. pub local_only: bool, + /// Show only photographs captured within this range, as UTC seconds. + /// + /// Half-open ends are meaningful: a `from` with no `to` reads as + /// "everything since". Undated images are excluded whenever either end is + /// set — they cannot be placed on the axis the user is narrowing, and + /// showing them anyway makes the range look broken. + /// + /// Here rather than a parallel parameter for the reason `local_only` gives + /// above: the count and the cells must be narrowed by the same thing. + pub captured_from: Option, + pub captured_to: Option, } impl RatingFilter { /// Whether this narrows anything, so the caller can skip the join. pub fn is_unfiltered(&self) -> bool { - self.min_rating == 0 && !self.unjudged && self.flag.is_none() && !self.local_only + self.min_rating == 0 + && !self.unjudged + && self.flag.is_none() + && !self.local_only + && self.captured_from.is_none() + && self.captured_to.is_none() + } + + /// Whether a date range is narrowing the grid. + pub fn has_date_range(&self) -> bool { + self.captured_from.is_some() || self.captured_to.is_some() + } + + /// The same filter with the date range lifted. + /// + /// The timeline uses this: the histogram is how the range is *chosen*, so + /// drawing it through the range would collapse the axis onto the current + /// selection and leave nowhere to widen it back out from. + pub fn without_date_range(&self) -> Self { + Self { + captured_from: None, + captured_to: None, + ..*self + } } /// The SQL predicate, against an `images` aliased as `i`. @@ -234,6 +268,21 @@ impl RatingFilter { )); } + // Integers this code owns, formatted straight in like the rating terms + // above — no caller text reaches the SQL. + if let Some(from) = self.captured_from { + terms.push(format!("i.captured_at >= {from}")); + } + if let Some(to) = self.captured_to { + terms.push(format!("i.captured_at <= {to}")); + } + if self.has_date_range() { + // An undated image cannot be inside or outside a range. Excluding + // it is the honest answer; the comparisons above would drop it + // anyway, and saying so keeps that from looking accidental. + terms.push("i.captured_at IS NOT NULL".to_string()); + } + if self.unjudged { // Both axes: a frame that was picked but never starred has been // judged, and re-presenting it would undo the user's decision to @@ -2670,7 +2719,8 @@ pub fn span_scoped( filter: &RatingFilter, ) -> Option<(i64, i64)> { let (clause, params) = scope_clause(catalog, scope).ok()?; - let rated = filter.sql(); + // Full extent, not the chosen range — see `without_date_range`. + let rated = filter.without_date_range().sql(); let sql = format!( "SELECT min(i.captured_at), max(i.captured_at) FROM images i WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}" @@ -2698,7 +2748,7 @@ pub fn timeline_scoped( to: i64, ) -> Result, dr_catalog::CatalogError> { let (clause, mut params) = scope_clause(catalog, scope)?; - let rated = filter.sql(); + let rated = filter.without_date_range().sql(); let sql = format!( "SELECT min(i.captured_at) AS start, count(*) AS n FROM images i WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause} @@ -3607,6 +3657,74 @@ mod tests { assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 1); } + #[test] + fn a_date_range_narrows_the_grid_and_the_count_together() { + // The whole reason the range lives on `RatingFilter`: every query path + // threads that one struct, so the header cannot claim a total the grid + // does not draw. + let catalog = scanned(3); + let conn = catalog.connection(); + for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] { + conn.execute( + "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", + rusqlite::params![format!("%IMG_000{n}%"), at], + ) + .unwrap(); + } + + let ranged = RatingFilter { + captured_from: Some(4_000), + captured_to: Some(6_000), + ..Default::default() + }; + assert_eq!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().len(), 1); + assert_eq!(total_images_filtered(&catalog, &ranged).unwrap(), 1); + } + + #[test] + fn an_undated_image_is_not_shown_inside_a_date_range() { + // It cannot be in or out of a span. Drawing it anyway makes a range the + // user just chose look as though it had not applied. + let catalog = scanned(2); + catalog + .connection() + .execute("UPDATE images SET captured_at = NULL", []) + .unwrap(); + + let ranged = RatingFilter { + captured_from: Some(0), + captured_to: Some(i64::MAX), + ..Default::default() + }; + assert!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().is_empty()); + } + + #[test] + fn the_histogram_ignores_the_range_it_is_used_to_choose() { + // Drawing the axis through the chosen range would collapse it onto the + // selection, leaving nowhere to widen back out from. + let catalog = scanned(3); + let conn = catalog.connection(); + for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] { + conn.execute( + "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", + rusqlite::params![format!("%IMG_000{n}%"), at], + ) + .unwrap(); + } + + let ranged = RatingFilter { + captured_from: Some(4_000), + captured_to: Some(6_000), + ..Default::default() + }; + assert_eq!( + span_scoped(&catalog, None, &ranged), + Some((1_000, 9_000)), + "the axis must keep describing the whole extent" + ); + } + fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry { RemoteEntry { id: RemoteId::Stable(file_id), diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 89fa881..468481d 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -3645,6 +3645,50 @@ pub fn wire( }); } + // Narrow the grid to the period the histogram is showing. + // + // The range is taken from the timeline rather than typed into two date + // fields: finding the fortnight is what the histogram is *for*, and having + // found it the user should not have to read the dates off the axis and key + // them back in. Zoom, then say "only that". + // + // Toggling off clears both ends rather than remembering them — a range you + // cannot see the extent of is a filter that looks like an empty library. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_toggle_date_range(move || { + let Some(w) = weak.upgrade() else { return }; + let on = !ctl.filter.borrow().has_date_range(); + if on { + // The span the axis is currently drawn over, which is what the + // user is looking at when they ask for "this range". + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(span) = catalog_span(catalog, &ctl) else { + return; + }; + let (from, to) = zoomed_span( + span, + *ctl.timeline_zoom.borrow(), + *ctl.timeline_centre.borrow(), + ); + drop(borrow); + let mut f = ctl.filter.borrow_mut(); + f.captured_from = Some(from); + f.captured_to = Some(to); + } else { + let mut f = ctl.filter.borrow_mut(); + f.captured_from = None; + f.captured_to = None; + } + w.set_library_range_active(on); + refilter(&w, &ctl); + }); + } + // TRACES: FR-CAT-9 // "On this device" — the images openable without a server. Composes with // the rating terms rather than replacing them: "five-star frames I can diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 9f7129a..4fbdd41 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -387,6 +387,9 @@ export component AppWindow inherits Window { in-out property library-local-only: false; in property library-local-count: 0; callback library-toggle-local-only(); + /// Whether the grid is narrowed to the timeline's visible span. + in-out property library-range-active: false; + callback library-toggle-date-range(); callback library-retry-connection(); // --- pinning a collection offline (FR-NC-6a) --- @@ -997,6 +1000,8 @@ in property panel-visible: true; local-only: root.library-local-only; local-count: root.library-local-count; toggle-local-only() => { root.library-toggle-local-only(); } + range-active: root.library-range-active; + toggle-date-range() => { root.library-toggle-date-range(); } 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 acf8125..1a8a305 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -846,6 +846,9 @@ export component LibraryGrid inherits Rectangle { in property local-only: false; in property local-count: 0; callback toggle-local-only(); + /// Whether the grid is narrowed to the timeline's visible span. + in property range-active: false; + callback toggle-date-range(); /// How many images are selected, for the header's count. in property selected-count: 0; @@ -1240,6 +1243,23 @@ export component LibraryGrid inherits Rectangle { clicked => { root.toggle-local-only(); } } + Rectangle { width: Theme.gap; } + + // Narrow the grid to the span the timeline is showing. The + // histogram is already how you find a period, so the range is + // taken from it rather than typed: zoom to the fortnight you + // want, then say "only that". Clicking again lifts it. + // + // The histogram deliberately keeps drawing the full extent + // while this is on, or there would be nothing left to widen + // back out from. + FilterChip { + label: root.range-active ? "Date range ×" : "Limit to range"; + active: root.range-active; + y: (parent.height - self.height) / 2; + clicked => { root.toggle-date-range(); } + } + Rectangle { horizontal-stretch: 1; } // What the filter is currently hiding. Without this a narrowed