Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than one, and exposed a scrub that landed in the wrong place. **Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and `Large` (1024px, ~45 KB), with the class part of the store key so both coexist. Storing everything large would take the reference library from ~200 MB to ~860 MB, and shards sync, so that is transfer cost on every device rather than only disk. A store written before the class existed migrates in place: its entries are all grid-sized, which is what the column defaults to, so nothing already fetched is discarded. `forget` now drops every size for an image. Reading a single row left the other class's bytes on the shard's tally for good, sealing it early on space nothing occupied. **Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in geometric steps, so the gesture feels the same at either end where a fixed pixel step would be imperceptible at 400px and violent at 90px. Crossing 256px switches to the large class, so a zoomed cell is sharp rather than upscaled. Columns and window capacity already derived from cell size, so the grid reflows for free. **The scrub landed about half a library too high.** It counted only dated images while the grid shows all of them — 10,733 dated against 19,841 rows — and ignored `shadowed_by`. Verified against the live catalog: the old formula gave 10,887, the new one 10,732, the true grid position 10,732. The scrub's count and the grid's window must use identical predicates and ordering; a test now fails if they diverge. **Timeline gestures are continuous.** Scrub and pan were quantised to whole buckets, so a slow drag did nothing until it crossed a boundary and then jumped a month. Both work in fractions of the visible span now, and pinch-to-zoom arrives for tablet, where there is no wheel to reach the axis with. The pinch accumulator was wrong on first writing: it took at most one step per update, so an 8x spread — three doublings — yielded one zoom level. `log2().trunc()` now extracts every whole doubling and carries the remainder. The original test asserted the wrong number and defended it in a comment, which is worth remembering: a test can entrench a bug as readily as catch one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+73
-7
@@ -693,6 +693,12 @@ pub struct ThumbnailRequest {
|
||||
pub size: u64,
|
||||
/// Catalog row, so EXIF read from the header can be written back.
|
||||
pub image_id: i64,
|
||||
/// Which resolution this cell needs, from how large it is drawn. A zoomed
|
||||
/// grid asks for the large class; a wall of small cells does not.
|
||||
///
|
||||
/// Named apart from `size`, which is the file's length in bytes — the two
|
||||
/// are unrelated and confusing them would fetch the wrong thing.
|
||||
pub thumb_size: dr_thumbs::ThumbSize,
|
||||
/// Whether this image still needs its EXIF read. Where false the header is
|
||||
/// still fetched — the preview needs it — but nothing is parsed or written.
|
||||
pub needs_metadata: bool,
|
||||
@@ -791,7 +797,7 @@ pub fn spawn_thumbnails(
|
||||
let stored = req
|
||||
.file_id
|
||||
.zip(store.as_ref())
|
||||
.and_then(|(id, s)| s.get(id).ok().flatten());
|
||||
.and_then(|(id, s)| s.get(id, req.thumb_size).ok().flatten());
|
||||
|
||||
match stored.map(|t| dr_thumbs::decode_rgba(&t.bytes)) {
|
||||
Some(Ok((width, height, rgba))) => {
|
||||
@@ -980,7 +986,7 @@ async fn fetch_one(
|
||||
Ok(p) => p,
|
||||
Err(e) => return fail(e.to_string()),
|
||||
};
|
||||
preview.downscale_to(THUMBNAIL_EDGE);
|
||||
preview.downscale_to(req.thumb_size.edge());
|
||||
|
||||
// Persist for next time, and for every other client that syncs the shard.
|
||||
// A store failure is logged and dropped: the pixels are already in hand,
|
||||
@@ -994,7 +1000,7 @@ async fn fetch_one(
|
||||
height: preview.height,
|
||||
bytes: encoded,
|
||||
};
|
||||
if let Err(e) = store.put(file_id, &thumb) {
|
||||
if let Err(e) = store.put(file_id, req.thumb_size, &thumb) {
|
||||
log::debug!("storing thumbnail {file_id}: {e}");
|
||||
}
|
||||
}
|
||||
@@ -1368,6 +1374,9 @@ fn next_outstanding(
|
||||
let rows = stmt
|
||||
.query_map([limit as i64], |r| {
|
||||
Ok(ThumbnailRequest {
|
||||
// The sweep indexes dates, and reads headers only — the size
|
||||
// never reaches a fetch, but it must name something.
|
||||
thumb_size: dr_thumbs::ThumbSize::Grid,
|
||||
// Row index is meaningless here — the sweep touches no grid
|
||||
// cell, so nothing consumes it.
|
||||
row: 0,
|
||||
@@ -1749,6 +1758,65 @@ mod tests {
|
||||
assert_eq!(seen, chunk);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_scrub_ordinal_matches_the_grid_position() {
|
||||
// The scrub's count and the grid's window must use *identical*
|
||||
// predicates and ordering, or the view lands somewhere else. Counting
|
||||
// only dated images against a grid that also shows undated ones put a
|
||||
// click near the end of the axis near the top of the library.
|
||||
let catalog = Catalog::in_memory().unwrap();
|
||||
let c = catalog.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A mix: dated, undated, and one shadowed by a RAW sibling.
|
||||
for (id, name, captured, shadow) in [
|
||||
(1i64, "a.CR2", Some(100i64), None),
|
||||
(2, "b.CR2", Some(200), None),
|
||||
(3, "b.JPG", Some(200), Some(2i64)),
|
||||
(4, "c.CR2", Some(300), None),
|
||||
(5, "d.CR2", None, None),
|
||||
] {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by, added_at)
|
||||
VALUES (?1, 1, ?2, ?3, ?4, 0)",
|
||||
rusqlite::params![id, name, captured, shadow],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The grid's own window, in its own order.
|
||||
let cells = read_cells(&catalog, 0, 100).unwrap();
|
||||
let names: Vec<&str> = cells.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["a.CR2", "b.CR2", "c.CR2", "d.CR2"],
|
||||
"shadowed hidden, undated last"
|
||||
);
|
||||
|
||||
// Scrubbing to each image's instant must give its index in that list.
|
||||
for (when, expected) in [(100i64, 0usize), (200, 1), (300, 2)] {
|
||||
let ordinal: i64 = c
|
||||
.query_row(
|
||||
"SELECT count(*) FROM images
|
||||
WHERE shadowed_by IS NULL
|
||||
AND captured_at IS NOT NULL
|
||||
AND captured_at < ?1",
|
||||
[when],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ordinal as usize, expected,
|
||||
"scrubbing to {when} must land on grid row {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_paths_separate_accounts() {
|
||||
// Two accounts on one machine must not share an index, or one
|
||||
@@ -2076,9 +2144,7 @@ mod tests {
|
||||
let mut store = ThumbStore::open(&dir).unwrap();
|
||||
let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap();
|
||||
store
|
||||
.put(
|
||||
4242,
|
||||
&dr_thumbs::Thumbnail {
|
||||
.put(4242, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail {
|
||||
width: 32,
|
||||
height: 32,
|
||||
bytes,
|
||||
@@ -2088,7 +2154,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let store = ThumbStore::open(&dir).unwrap();
|
||||
let stored = store.get(4242).unwrap().expect("persisted");
|
||||
let stored = store.get(4242, dr_thumbs::ThumbSize::Grid).unwrap().expect("persisted");
|
||||
let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
|
||||
assert_eq!((w, h), (32, 32));
|
||||
// Lossy, so compare approximately — a blue-ish pixel must stay blue.
|
||||
|
||||
Reference in New Issue
Block a user