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
+94 -9
View File
@@ -249,11 +249,18 @@ fn sync_rows(
) {
use slint::Model as _;
let (current, samples) = match session.borrow().as_ref() {
Some(s) => (s.rows(), s.curve_samples()),
None => (Vec::new(), Vec::new()),
let current = match session.borrow().as_ref() {
Some(s) => s.rows(),
None => Vec::new(),
};
// Whether the drawn curve has to be resampled. Sampling runs the spline 96
// times and builds a fresh model, and `sync_rows` is called on *every*
// parameter event — so doing it unconditionally spent that on every
// exposure or contrast drag, none of which can change the curve's shape.
// Only a moved point can, and the in-place update below is what knows.
let mut curve_moved = false;
if current.len() == rows.row_count() {
for (i, mut row) in current.into_iter().enumerate() {
let existing = rows.row_data(i);
@@ -268,8 +275,13 @@ fn sync_rows(
// compares by identity, so a brand-new points model would make
// every curve row look changed on every event.
if let Some(previous) = existing.as_ref() {
if update_points_in_place(&previous.points, &row.points) {
row.points = previous.points.clone();
match update_points_in_place(&previous.points, &row.points) {
PointsUpdate::Moved => {
curve_moved = true;
row.points = previous.points.clone();
}
PointsUpdate::Unchanged => row.points = previous.points.clone(),
PointsUpdate::Incompatible => {}
}
}
@@ -281,10 +293,21 @@ fn sync_rows(
}
} else {
// A different image, so the control set itself changed. Rebuilding
// is correct here — there is no drag to preserve.
// is correct here — there is no drag to preserve, and the new image's
// curve must be drawn whatever shape it is in.
rows.set_vec(current);
curve_moved = true;
}
if !curve_moved {
return;
}
let samples = match session.borrow().as_ref() {
Some(s) => s.curve_samples(),
None => Vec::new(),
};
// The drawn curve follows the points. Replacing this model wholesale is
// safe where replacing `rows` was not: nothing in it is a drag target.
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
@@ -298,12 +321,13 @@ fn sync_rows(
fn update_points_in_place(
existing: &slint::ModelRc<f32>,
fresh: &slint::ModelRc<f32>,
) -> bool {
) -> PointsUpdate {
use slint::Model as _;
if existing.row_count() != fresh.row_count() {
return false;
return PointsUpdate::Incompatible;
}
let mut moved = false;
for i in 0..fresh.row_count() {
let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else {
continue;
@@ -312,9 +336,28 @@ fn update_points_in_place(
// — the same reasoning as the row-level check above.
if new != old {
existing.set_row_data(i, new);
moved = true;
}
}
true
if moved {
PointsUpdate::Moved
} else {
PointsUpdate::Unchanged
}
}
/// What [`update_points_in_place`] found, which decides two things: whether the
/// existing points model can be kept, and whether the drawn curve needs
/// resampling.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum PointsUpdate {
/// Lengths differ. The caller must take the fresh model wholesale — the
/// control set itself changed and there is no drag worth preserving.
Incompatible,
/// At least one coordinate was written through.
Moved,
/// Every coordinate already matched.
Unchanged,
}
/// TRACES: M-13 | M-14
@@ -1156,4 +1199,46 @@ mod tests {
assert!(!is_supported(Path::new("a.txt")));
assert!(!is_supported(Path::new("noextension")));
}
fn points(values: &[f32]) -> slint::ModelRc<f32> {
slint::ModelRc::new(slint::VecModel::from(values.to_vec()))
}
#[test]
fn an_unmoved_curve_reports_no_change() {
// What spares every non-curve drag the 96-sample spline evaluation.
let existing = points(&[0.0, 0.0, 1.0, 1.0]);
let fresh = points(&[0.0, 0.0, 1.0, 1.0]);
assert_eq!(
update_points_in_place(&existing, &fresh),
PointsUpdate::Unchanged
);
}
#[test]
fn a_moved_point_reports_the_change_and_is_written_through() {
use slint::Model as _;
let existing = points(&[0.0, 0.0, 1.0, 1.0]);
let fresh = points(&[0.0, 0.25, 1.0, 1.0]);
assert_eq!(
update_points_in_place(&existing, &fresh),
PointsUpdate::Moved
);
// Written into the *existing* model: keeping its identity is what
// stops the drag's own TouchArea being destroyed mid-gesture.
assert_eq!(existing.row_data(1), Some(0.25));
}
#[test]
fn a_different_point_count_is_incompatible() {
// A different image, so there is no drag to preserve and the caller
// must take the fresh model wholesale.
let existing = points(&[0.0, 0.0]);
let fresh = points(&[0.0, 0.0, 1.0, 1.0]);
assert_eq!(
update_points_in_place(&existing, &fresh),
PointsUpdate::Incompatible
);
}
}