Show only the photographs taken in the period you are looking at
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 29s
Build and test / Android (aarch64) (push) Failing after 9m28s

The library could be narrowed by rating, flag and availability, but not
by when a photograph was taken — so finding a fortnight meant scrolling
to it and holding position.

The range rides on `RatingFilter` for the reason `local_only` already
does: every query path threads that one struct, so the count in the
header cannot claim a total the grid does not draw. Undated images are
excluded whenever either end is set — they cannot be inside or outside a
span, and drawing them made the range look as though it had not applied.

Taken from the timeline rather than typed into two date fields. Finding
the period 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.
The histogram keeps drawing the full extent while the range is on, or
there would be nowhere to widen back out from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:26 +02:00
co-authored by Claude Opus 5
parent d2d5d6f22b
commit edcaf42ded
4 changed files with 190 additions and 3 deletions
+121 -3
View File
@@ -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<i64>,
pub captured_to: Option<i64>,
}
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<Vec<dr_catalog::TimeBucket>, 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),
+44
View File
@@ -3645,6 +3645,50 @@ pub fn wire<F>(
});
}
// 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
+5
View File
@@ -387,6 +387,9 @@ export component AppWindow inherits Window {
in-out property <bool> library-local-only: false;
in property <int> library-local-count: 0;
callback library-toggle-local-only();
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> 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 <bool> 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(); }
+20
View File
@@ -846,6 +846,9 @@ export component LibraryGrid inherits Rectangle {
in property <bool> local-only: false;
in property <int> local-count: 0;
callback toggle-local-only();
/// Whether the grid is narrowed to the timeline's visible span.
in property <bool> range-active: false;
callback toggle-date-range();
/// How many images are selected, for the header's count.
in property <int> 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