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:
+166
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user