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:
+94
-9
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user