Take the whole run a shift-click names, not the part that happens to be loaded

Shift-clicking two photographs selected only the cells between them that were
in the loaded window. The grid is a window of a hundred or so over a library of
twenty thousand, and `apply_press` resolved the range against that window —
`ids[lo_row..=hi_row]`, clamped to what was there. Everything else in the run
had no id anywhere in the UI, so it was silently dropped. The user cannot see
that: the selection count is off screen along with the photographs, and the
gesture only announces itself when the drop files a dozen images instead of two
hundred.

The two ends are *ordinals*, and only the catalog knows what lies between them.
`read_ids_span` asks it, through the same predicates, the same rating filter and
the same ordering the window itself is read with — an ordinal names a photograph
only relative to an ordering, so a run taken through any other one is a run
through a different library. That ordering is now a constant, `GRID_ORDER`,
shared by the window, the trash's own order beside it, and the run: capture time
first, with the file name breaking ties and nothing more. A card written by two
cameras interleaves names that have nothing to do with each other, and what
"everything between these two" means to a photographer is a stretch of an
afternoon.

The query is reached through a closure handed to `CollectionsController` at
wiring time rather than a catalog handle, because the scope and the filter that
bound the run belong to the grid's controller. `apply_press` stays a pure
function of what it is given, which is what keeps the selection rules testable
with no library open — and the tests pass a run that reads a plain slice. Where
there is nothing to ask, the loaded window is still used: a poorer answer than
the catalog's and a far better one than a gesture that appears to do nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 19:50:21 +02:00
co-authored by Claude Opus 5
parent 22325415b1
commit 54f9cb54fb
4 changed files with 405 additions and 70 deletions
+166 -3
View File
@@ -176,6 +176,30 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL";
/// restore the same frame twice.
const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL";
/// TRACES: FR-CAT-4
/// The order the grid lists photographs in: when they were taken.
///
/// The file name breaks ties and nothing more — two frames of one burst, or a
/// RAW beside the JPEG the camera wrote with it. What a photographer looks for
/// is the afternoon, not what the camera called the file, and a grid ordered by
/// name interleaves every camera and every card that ever wrote into the same
/// folder.
///
/// Shared rather than spelled out per query, for the same reason [`VISIBLE`] is:
/// the window, the count and the run a shift-click resolves are three answers
/// about one list, and an ordering that drifted between them would select
/// photographs the user never saw without any of it looking wrong.
///
/// Undated images sort last in either direction. EXIF is read as thumbnails
/// load, so a freshly scanned library would otherwise open on the images it
/// knows least about.
const GRID_ORDER: &str = "ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC";
/// TRACES: FR-CAT-15
/// [`GRID_ORDER`] for the trash, which is ordered by when a thing was deleted —
/// see [`read_trashed_cells`] for why that view answers a different question.
const TRASH_ORDER: &str = "ORDER BY i.trashed_at DESC, i.source_ref ASC";
/// TRACES: FR-CAT-6 | FR-CULL-4
/// What the grid is narrowed to by the rating filter bar.
///
@@ -2919,7 +2943,7 @@ pub fn read_cells_scoped(
WHERE {VISIBLE}{rated}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC
{GRID_ORDER}
LIMIT ? OFFSET ?"
);
@@ -2950,7 +2974,7 @@ fn read_cells_all(
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE {VISIBLE}{rated}
ORDER BY i.captured_at IS NULL, i.captured_at ASC, i.source_ref ASC
{GRID_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
let rows = stmt
@@ -2982,7 +3006,7 @@ pub fn read_trashed_cells(
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE {TRASHED}
ORDER BY i.trashed_at DESC, i.source_ref ASC
{TRASH_ORDER}
LIMIT ?1 OFFSET ?2"
))?;
let rows = stmt
@@ -3005,6 +3029,68 @@ pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogErro
Ok(n as usize)
}
/// TRACES: FR-CAT-5
/// The ids of grid rows `first..=last`, in the order the grid lists them.
///
/// What a shift-click actually means. The gesture names two *ordinals* and the
/// photographs between them are mostly not loaded — the grid is a window of a
/// hundred or so over a library of twenty thousand — so a range answered from
/// the window selected the handful on screen and silently dropped the rest.
/// The catalog knows the whole run, and with the ordering indexed it is one
/// seek rather than a scan.
///
/// Bounded by the same predicates, the same filter and the same [`GRID_ORDER`]
/// the window itself is read with. An ordinal only names a photograph relative
/// to an ordering, so a range taken through any other one is a range through a
/// different library.
///
/// `trash` picks the trash view's list, which is the other thing the grid can
/// be showing and is ordered by deletion time rather than capture time.
///
/// An empty result means the run is empty or the query failed; callers treat
/// the two the same, because both leave the selection where it was.
pub fn read_ids_span(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
filter: &RatingFilter,
trash: bool,
first: usize,
last: usize,
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
let Some(count) = (last + 1).checked_sub(first) else {
return Ok(Vec::new());
};
let (sql, mut params) = if trash {
(
format!("SELECT i.id FROM images i WHERE {TRASHED} {TRASH_ORDER} LIMIT ? OFFSET ?"),
Vec::new(),
)
} else {
let (clause, params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
(
format!(
"SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{clause}
{GRID_ORDER}
LIMIT ? OFFSET ?"
),
params,
)
};
params.push(rusqlite::types::Value::Integer(count as i64));
params.push(rusqlite::types::Value::Integer(first as i64));
let mut stmt = catalog.connection().prepare(&sql)?;
let ids = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
let path: String = r.get(1)?;
@@ -4214,6 +4300,83 @@ mod tests {
assert!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().is_empty());
}
#[test]
fn a_span_reads_the_whole_run_whether_or_not_it_is_loaded() {
// The shift-click this exists for. The grid holds a window of five and
// the user names a run of twelve, so seven of them have no cell and no
// id anywhere in the UI — but they are still what was asked for, and
// the catalog is what knows them.
let catalog = scanned(12);
let filter = RatingFilter::default();
let loaded = read_cells_all(&catalog, &filter, 0, 5).unwrap();
assert_eq!(loaded.len(), 5, "the window is smaller than the run");
let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50)
.unwrap()
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
let span = read_ids_span(&catalog, None, &filter, false, 0, 11).unwrap();
assert_eq!(span.len(), 12);
assert_eq!(span, whole, "the run is the grid's own list, in its order");
}
#[test]
fn a_span_starts_and_ends_where_it_was_asked_to() {
// Ordinals index the grid's list, so a run has to be exactly the slice
// of it the two ends name — one off at either end selects a
// photograph the user did not point at.
let catalog = scanned(12);
let filter = RatingFilter::default();
let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50)
.unwrap()
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
let span = read_ids_span(&catalog, None, &filter, false, 4, 6).unwrap();
assert_eq!(span, whole[4..=6], "ordinals 4..=6, inclusive at both ends");
}
#[test]
fn a_span_is_ordered_by_capture_time_rather_than_by_name() {
// A card written by two cameras interleaves names that have nothing to
// do with each other. What a photographer means by "everything between
// these two" is a stretch of an afternoon, so the run has to be taken
// through capture time — the ordering the grid draws them in.
let catalog = scanned(3);
let conn = catalog.connection();
for (n, at) in [(1, 9_000), (2, 5_000), (3, 1_000)] {
conn.execute(
"UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1",
rusqlite::params![format!("%IMG_000{n}%"), at],
)
.unwrap();
}
let span = read_ids_span(&catalog, None, &RatingFilter::default(), false, 0, 2).unwrap();
let names: Vec<String> = span
.iter()
.map(|id| {
conn.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[id.0 as i64],
|r| r.get::<_, String>(0),
)
.unwrap()
})
.collect();
assert!(
names[0].ends_with("IMG_0003.CR2")
&& names[1].ends_with("IMG_0002.CR2")
&& names[2].ends_with("IMG_0001.CR2"),
"earliest first, which here is the reverse of the file names: {names:?}"
);
}
#[test]
fn the_histogram_ignores_the_range_it_is_used_to_choose() {
// Drawing the axis through the chosen range would collapse it onto the