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:
2026-08-09 21:58:32 +02:00
co-authored by Claude Opus 5
parent 75ce3846c3
commit d49b4b41de
13 changed files with 1020 additions and 156 deletions
+302 -31
View File
@@ -35,6 +35,15 @@ const INITIAL_WINDOW: usize = 60;
/// re-query on every scroll tick.
const MIN_WINDOW: usize = 24;
/// Cell-size bounds for the grid's zoom.
///
/// The lower end is where a thumbnail stops being recognisable; the upper is
/// where one screen holds so few that the grid stops being a grid. Past 256px
/// the large thumbnail class is fetched, so the top of this range is sharp
/// rather than upscaled.
const MIN_CELL_SIZE: f32 = 90.0;
const MAX_CELL_SIZE: f32 = 420.0;
/// Library state for the running window.
pub struct LibraryController {
/// Shared with [`crate::collections_ui`], which edits collections against
@@ -93,6 +102,12 @@ pub struct LibraryController {
/// view as you zoom.
timeline_zoom: RefCell<i32>,
timeline_centre: RefCell<Option<i64>>,
/// Pinch ratio accumulated since the last zoom step was taken.
///
/// A pinch is continuous and zoom levels are discrete, so the ratio is
/// held until it reaches a doubling. Without it a slow spread would either
/// do nothing or, if each update were rounded, leap several levels.
pinch_accum: RefCell<f32>,
/// The instant the grid is showing. `None` until the user has moved the
/// timeline, which is what leaves the marker resting at the middle.
current_bucket: RefCell<Option<i64>>,
@@ -144,6 +159,7 @@ impl LibraryController {
viewing_trash: std::cell::Cell::new(false),
timeline_zoom: RefCell::new(0),
timeline_centre: RefCell::new(None),
pinch_accum: RefCell::new(1.0),
current_bucket: RefCell::new(None),
filter: RefCell::new(library::RatingFilter::default()),
sidecar_timer: RefCell::new(None),
@@ -848,6 +864,8 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
};
let wanted: Vec<library::ThumbnailRequest> = {
// The drawn cell size decides which class to ask for.
let cell_pixels = window.get_library_cell_size().max(1.0) as u32;
let paths = ctl.paths.borrow();
let file_ids = ctl.file_ids.borrow();
let sizes = ctl.sizes.borrow();
@@ -859,6 +877,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
.enumerate()
.filter(|(i, _)| requested.insert(*i))
.map(|(i, p)| library::ThumbnailRequest {
// Chosen from how large the cell is actually drawn, so a
// zoomed grid asks for detail a 256px thumbnail cannot give
// and a wall of small cells does not pay for it.
thumb_size: dr_thumbs::ThumbSize::for_cell(cell_pixels),
row: i,
path: p.clone(),
file_id: file_ids.get(i).copied().flatten(),
@@ -1013,6 +1035,35 @@ fn drain_thumbnails(
*ctl.thumb_timer.borrow_mut() = Some(timer);
}
/// Move the timeline's zoom by whole levels.
///
/// Shared by the wheel and the pinch so the two cannot drift apart in how they
/// clamp, or in where they choose to centre.
fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// Bounded: past ~2^12 the window is minutes wide and every bucket is
// empty, which reads as a broken axis rather than a deep zoom.
let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12);
if next == *ctl.timeline_zoom.borrow() {
return;
}
*ctl.timeline_zoom.borrow_mut() = next;
// Zooming fully out forgets the centre, so the axis returns to describing
// the whole library rather than a remembered position.
if next == 0 {
*ctl.timeline_centre.borrow_mut() = None;
} else if ctl.timeline_centre.borrow().is_none() {
// First zoom centres on wherever the grid is, else the middle.
*ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow();
}
refresh_timeline(window, catalog, ctl);
}
/// Push shards and the catalog to the server, and take what it has.
///
/// Fired after the sweep completes, when there is a finished index worth
@@ -1378,13 +1429,26 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
let Some(catalog) = borrow.as_ref() else {
return;
};
// How many dated images precede this instant, in the same order the
// grid uses. That ordinal *is* the scroll offset.
// How many rows precede this instant **in the grid's own ordering**.
// That ordinal is the scroll offset, so any disagreement with the
// grid's query lands the view somewhere else entirely.
//
// The earlier version counted only dated images. With 2,400 of 19,841
// dated, a click near the end of the axis produced an ordinal of ~2,400
// against a grid of 19,841 rows — the view landed near the top however
// far down the axis the pointer went.
//
// Undated images sort last (`captured_at IS NULL` first in the ORDER
// BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them.
catalog
.connection()
.query_row(
"SELECT count(*) FROM images
WHERE captured_at IS NOT NULL AND captured_at < ?1",
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
[when],
|r| r.get::<_, i64>(0),
)
@@ -1493,6 +1557,42 @@ where
});
}
// Ctrl+wheel or pinch over the grid resizes the cells.
//
// Geometric steps rather than fixed pixels: the same gesture should feel
// the same at 90px and at 400px, and a linear step is imperceptible at one
// end and violent at the other.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_zoom_cells(move |delta| {
let Some(w) = weak.upgrade() else { return };
let current = w.get_library_cell_size();
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
}
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.set_library_cell_size(next);
// Crossing the class boundary means the visible cells now want a
// resolution the store may not hold, and the window's capacity
// changed with the cell size. Both are answered by reloading.
let was = dr_thumbs::ThumbSize::for_cell(current as u32);
let now = dr_thumbs::ThumbSize::for_cell(next as u32);
if was != now {
ctl.requested.borrow_mut().clear();
}
load_window(&w, &ctl);
});
}
// Explicit sync, for when the user wants the exchange now rather than
// after the next sweep.
{
@@ -1576,65 +1676,103 @@ where
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pan(move |buckets| {
window.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else { return };
let Some(full) = catalog_span(catalog) else { return };
// Shift the centre by whole buckets of the *current* span, so a
// pan moves by what the user can see rather than a fixed duration.
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
// small movement produces a small shift rather than nothing.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
let step = ((to - from) / 40).max(1);
let shift = ((to - from) as f64 * fraction as f64) as i64;
if shift == 0 {
return;
}
let centre = ctl
.timeline_centre
.borrow()
.unwrap_or((from + to) / 2)
+ step * buckets as i64;
+ shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
}
// The wheel zooms the axis: a sidebar is a scale, not a list.
//
// Shares `apply_zoom` with the pinch handler so the two cannot drift apart
// in how they clamp or where they centre.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_zoom(move |delta| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else { return };
// Bounded: past ~2^12 the window is minutes wide and every bucket
// is empty, which reads as a broken axis rather than a deep zoom.
let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12);
if next == *ctl.timeline_zoom.borrow() {
return;
if let Some(w) = weak.upgrade() {
apply_zoom(&w, &ctl, delta);
}
*ctl.timeline_zoom.borrow_mut() = next;
// Zooming fully out forgets the centre, so the axis returns to
// describing the whole library rather than a remembered position.
if next == 0 {
*ctl.timeline_centre.borrow_mut() = None;
} else if ctl.timeline_centre.borrow().is_none() {
// First zoom centres on wherever the grid is, else the middle.
*ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow();
}
refresh_timeline(&w, catalog, &ctl);
});
}
// Dragging the histogram moves the grid through time.
//
// The fraction is interpolated across the visible span rather than snapped
// to a bucket edge, so a slow drag advances continuously instead of sitting
// still and then jumping a whole month.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_scrub(move |when| {
if let Some(w) = weak.upgrade() {
scrub_to(&w, &ctl, when as i64);
window.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else { return };
let Some(full) = catalog_span(catalog) else { return };
let (from, to) = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
let when = from + ((to - from) as f64 * f.clamp(0.0, 1.0) as f64) as i64;
drop(borrow);
scrub_to(&w, &ctl, when);
});
}
// Pinch, for tablet: no wheel there, so this is the only way to reach the
// axis's zoom with a finger.
//
// A continuous ratio against discrete zoom levels, so the accumulated
// ratio is held and a level is taken each time it passes a doubling. That
// keeps a slow spread from either doing nothing or leaping several levels.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pinch(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(0.01..=100.0).contains(&ratio) {
return;
}
let mut accum = ctl.pinch_accum.borrow_mut();
*accum *= ratio;
// Whole doublings out of the accumulated ratio, remainder carried.
//
// `log2` rather than repeated halving: one update can carry a
// large ratio — a fast spread, or a trackpad reporting coarsely —
// and stepping once per update would turn an 8× pinch into a
// single level instead of three.
let steps = accum.log2().trunc() as i32;
if steps == 0 {
return;
}
*accum /= (2.0f32).powi(steps);
drop(accum);
apply_zoom(&w, &ctl, steps);
});
}
@@ -1818,6 +1956,139 @@ mod tests {
use super::*;
/// The instant a scrub fraction names within a span, as the handler
/// computes it.
fn instant_at(span: (i64, i64), f: f32) -> i64 {
span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64
}
#[test]
fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance
// continuously rather than sitting still until it crosses a bucket
// edge and then jumping a whole month.
let span = (0, 1_000);
assert_eq!(instant_at(span, 0.0), 0);
assert_eq!(instant_at(span, 0.5), 500);
assert_eq!(instant_at(span, 1.0), 1_000);
// Distinct fractions inside one bucket must give distinct instants.
assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11));
}
#[test]
fn a_scrub_fraction_outside_the_axis_is_clamped() {
// A drag that leaves the widget still reports a position; it must land
// at an end rather than off the timeline.
let span = (100, 200);
assert_eq!(instant_at(span, -3.0), 100);
assert_eq!(instant_at(span, 9.9), 200);
}
/// One pinch update, as the handler applies it: fold the ratio in, take
/// out whole doublings, carry the remainder.
fn pinch_step(accum: &mut f32, ratio: f32) -> i32 {
*accum *= ratio;
let steps = accum.log2().trunc() as i32;
if steps != 0 {
*accum /= (2.0f32).powi(steps);
}
steps
}
#[test]
fn a_pinch_accumulates_until_it_reaches_a_doubling() {
// A continuous gesture against discrete levels. Small spreads must
// accumulate rather than each being rounded to a step.
let mut accum = 1.0f32;
let mut steps = 0;
for _ in 0..12 {
steps += pinch_step(&mut accum, 1.1);
}
// 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with
// the rest carried rather than discarded or rounded up.
assert_eq!(steps, 1);
assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}");
}
#[test]
fn one_large_pinch_yields_every_level_it_crossed() {
// The bug this replaced: stepping at most once per update turned an
// 8x spread — three doublings — into a single zoom level, so a fast
// gesture lost most of its travel.
let mut accum = 1.0f32;
assert_eq!(pinch_step(&mut accum, 8.0), 3);
assert!((accum - 1.0).abs() < 0.001);
let mut accum = 1.0f32;
assert_eq!(pinch_step(&mut accum, 0.125), -3);
}
#[test]
fn pinching_in_and_back_out_returns_to_where_it_started() {
let mut accum = 1.0f32;
let steps: i32 = [2.0f32, 2.0, 0.5, 0.5]
.iter()
.map(|r| pinch_step(&mut accum, *r))
.sum();
assert_eq!(steps, 0);
assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}");
}
#[test]
fn a_pinch_below_a_doubling_takes_no_step() {
// Otherwise the axis would flicker between levels on the smallest
// finger movement.
let mut accum = 1.0f32;
assert_eq!(pinch_step(&mut accum, 1.4), 0);
assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0");
}
/// One zoom step, as the handler applies it.
fn zoom_cell(current: f32, delta: i32) -> f32 {
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
};
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
}
#[test]
fn cell_zoom_steps_geometrically_and_reverses() {
// Geometric so the gesture feels the same at either end; a fixed pixel
// step is imperceptible at 400px and violent at 90px.
let a = zoom_cell(180.0, 1);
assert!((a - 225.0).abs() < 0.01);
assert!((zoom_cell(a, -1) - 180.0).abs() < 0.01, "in then out returns");
}
#[test]
fn cell_zoom_stays_within_its_bounds() {
let mut size = 180.0;
for _ in 0..40 {
size = zoom_cell(size, 1);
}
assert_eq!(size, MAX_CELL_SIZE);
for _ in 0..40 {
size = zoom_cell(size, -1);
}
assert_eq!(size, MIN_CELL_SIZE);
}
#[test]
fn zooming_past_the_grid_class_asks_for_the_large_one() {
use dr_thumbs::ThumbSize;
// The point of the second class: past 256px a grid thumbnail is being
// upscaled, and the softness shows.
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
assert_eq!(ThumbSize::for_cell(zoom_cell(225.0, 1) as u32), ThumbSize::Large);
// And zooming back down does not keep paying for it.
assert_eq!(ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), ThumbSize::Grid);
}
#[test]
fn zoom_zero_is_the_whole_library() {
let full = (1_000, 2_000);