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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
+302
-31
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user