A 4:1 composite drawn in one square cell is a strip a few pixels high. A photograph about twice as wide as it is tall (1.9 and up) now spans two columns, three from 2.9, with a thumbnail class of its own whose long edge is sized for that width; on the tablet, or where the columns are too few to put it beside anything, it takes the whole row. Rows are computed in one place, library_ui::layout. The grid is a lattice of slots: each cell is drawn at the slot Rust gives it, and a wide cell that would not fit in what is left of a row starts the next one, leaving the gap empty so the grid still reads in capture order. The scrollbar spans the slots, a scroll reports a slot that the layout turns back into an ordinal, and scrubs, restores and the cursor go through the same conversion. Up and down step by rows through the layout rather than by a row's worth of ordinals; left and right, a shift-click's run, burst folding and the timeline are ordinal-based and unchanged. The window's own read carries each photograph's w and h, so the cells know their shape with no query per cell. Where the wide ones sit in the whole list is one query, run when what the grid lists changes or when a window finds the layout out of date, and a library with no panorama answers it from a partial index created on first use (images_wide), not a schema bump. The merge makes the wide thumbnail for a wide composite along with the others.
1528 lines
64 KiB
Rust
1528 lines
64 KiB
Rust
//! The capture-time sidebar — the histogram, its zoom and scrub — and the
|
||
//! photographer's place: where they were, restored on launch or handed over
|
||
//! from another device, and written back down as they move.
|
||
//!
|
||
//! The two are one file because a restored place ends by moving the timeline
|
||
//! marker and a scrub is a restore of one instant, so the functions call
|
||
//! back and forth constantly; keeping them apart would mean most calls
|
||
//! crossing a module boundary rather than a few. See
|
||
//! `docs/dev/code-health.md` CH-1.
|
||
|
||
use std::rc::Rc;
|
||
|
||
use dr_catalog::Catalog;
|
||
use slint::ComponentHandle;
|
||
|
||
use crate::library;
|
||
use crate::{AppWindow, Library, TimelineBar, View};
|
||
|
||
use super::controller::LibraryController;
|
||
use super::filter_bar::{push_people_chips, refilter};
|
||
use super::window::{load_window, mark_open, seek_to, show_cursor, window_start};
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// Rebuild the timeline histogram from the catalog.
|
||
///
|
||
/// Bucket size follows the span of the library, the way darktable's does:
|
||
/// a decade of photographs buckets by year, a single trip by day. Picking it
|
||
/// from the data rather than fixing it means the histogram is informative at
|
||
/// both scales instead of one flat bar or ten thousand slivers.
|
||
pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryController>) {
|
||
// Where the grid sits, if nothing has said so yet.
|
||
//
|
||
// **The marker used to rest greyed at mid-track until the first scroll or
|
||
// scrub**, on the reasoning that anchoring it would imply a choice the user
|
||
// had not made. That reads the marker as reporting an intention, and it does
|
||
// not: the sidebar's whole claim is to say *when* you are, and the answer is
|
||
// known from the first frame — the grid is at the top of the library, or at
|
||
// whatever position was restored into it. A launch that opened with the
|
||
// marker halfway down an axis whose photographs were all from the wrong end
|
||
// was simply wrong, and dimmed rather than absent, which made it look like a
|
||
// reading rather than the absence of one.
|
||
//
|
||
// Seeded here rather than at the end of the scan because this is the one
|
||
// place that decides what the marker says, and it runs on every route that
|
||
// builds the axis. Only when nothing has claimed it: a scroll or a scrub
|
||
// sets it directly, and this must never overwrite them.
|
||
let unanchored = ctl.current_bucket.borrow().is_none();
|
||
if unanchored {
|
||
if let Some(when) = capture_time_at(ctl, catalog, ctl.resume_at.get()) {
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
}
|
||
}
|
||
|
||
// Scoped to whatever the grid is showing. A collection's histogram drawn
|
||
// over the whole library's span said almost nothing: every bar for a
|
||
// fortnight in Arosa landed in one column of a fifteen-year axis.
|
||
let scope = ctl.grid_scope();
|
||
let filter = ctl.filter.borrow().clone();
|
||
// Passed whole, date range included, and both queries below lift that
|
||
// range themselves — see `span_scoped` and `timeline_uniform`. Every other
|
||
// term still applies, because a histogram of the five-star frames is a
|
||
// fair question; the range does not, because the axis is *how a range is
|
||
// chosen* and drawing through it would empty every bin outside the band
|
||
// and leave nothing to widen back into.
|
||
let span = match library::span_scoped(catalog, scope, &filter) {
|
||
Some(s) => s,
|
||
None => {
|
||
// No dated images yet. An empty histogram is honest — EXIF is read
|
||
// as thumbnails load, so this populates as the user browses.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
|
||
window
|
||
.global::<Library>()
|
||
.set_library_timeline_label(slint::SharedString::new());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// A range is drawn *over* this span as a band, not substituted for it —
|
||
// see the band below. The scale a narrow range deserves is reached by
|
||
// zooming the axis instead, which is a gesture a finger has (pinch) and
|
||
// does not disturb the range while it is being adjusted.
|
||
//
|
||
// Zoom narrows the span around wherever the view sits rather than around
|
||
// the library's midpoint, so zooming in keeps what you were looking at.
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
|
||
|
||
// A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The
|
||
// count is the user's — see `LibrarySettings::timeline_bars` for why the
|
||
// calendar units it replaced made zooming in draw a coarser picture.
|
||
let bins = ctl.timeline_bars.get().max(1);
|
||
// Calendar units survive as a *label* vocabulary: what a bin is called
|
||
// depends on how long it is, not on how the counting was done.
|
||
let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64);
|
||
let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
log::debug!("timeline: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Normalise against the tallest bar. Counts vary by orders of magnitude
|
||
// between a quiet month and a wedding, so a linear scale against the total
|
||
// would render most buckets invisible.
|
||
let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1);
|
||
let mut previous: Option<(i64, i64)> = None;
|
||
|
||
let bars: Vec<TimelineBar> = buckets
|
||
.iter()
|
||
.map(|b| {
|
||
let (y, m, _, _) = civil_from_unix(b.start);
|
||
// Label only where a period begins, so a month-bucketed axis reads
|
||
// "2024 … Mar … Apr" rather than repeating the year on every bar.
|
||
let period = match previous {
|
||
None => format!("{y}"),
|
||
Some((py, _)) if py != y => format!("{y}"),
|
||
Some((_, pm)) if pm != m && granularity_labels_months(granularity) => {
|
||
month_abbrev(m).to_string()
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
previous = Some((y, m));
|
||
|
||
TimelineBar {
|
||
// Square root rather than linear: it keeps a 3-image day
|
||
// visible beside a 400-image one without a log scale's
|
||
// misleading flatness.
|
||
//
|
||
// A bin holding nothing draws nothing. The floor exists so a
|
||
// quiet day is not rounded away, and empty bins are common now
|
||
// that every one of them is emitted — a sliver on each would
|
||
// draw a library that has photographs in months it does not.
|
||
height: if b.count == 0 {
|
||
0.0
|
||
} else {
|
||
((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0)
|
||
},
|
||
start: b.start as i32,
|
||
count: b.count as i32,
|
||
label: format_bucket(b.start, granularity).into(),
|
||
period_label: period.into(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// Where the grid currently sits, as a fraction of the visible span.
|
||
//
|
||
// This is the exact inverse of what a click produces: the widget maps a y
|
||
// to a fraction of its track and `on_library_scrub_fraction` interpolates
|
||
// `from + (to - from) * f`, so the marker must invert that same expression
|
||
// or it lands somewhere other than the pointer.
|
||
//
|
||
// The earlier version sent a *bar index* instead. Bars occupy one equal
|
||
// slot each regardless of how much time they cover, and `rposition` snaps
|
||
// to the bucket's start edge, so the marker sat at the top of whichever
|
||
// slot contained the instant — near enough on a dense uniform axis, plainly
|
||
// wrong on a sparse one, and never under the click.
|
||
let current = *ctl.current_bucket.borrow();
|
||
let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0);
|
||
|
||
// The chosen range in those same coordinates, for the band. Both ends or
|
||
// neither: half a band is a line the user cannot tell from a handle.
|
||
let (band_from, band_to) = match (filter.captured_from, filter.captured_to) {
|
||
(Some(a), Some(b)) => (fraction_at((from, to), a), fraction_at((from, to), b)),
|
||
_ => (-1.0, -1.0),
|
||
};
|
||
|
||
// Which bar to light: the *bin* holding the grid's instant, not the
|
||
// instant itself. A bar's start is its bin's left edge — one of a fixed
|
||
// set of positions — so comparing it against an arbitrary capture time
|
||
// would light nothing, where before every bar started on a photograph.
|
||
//
|
||
// Taken from the bars themselves rather than recomputed, because the edges
|
||
// are integer division and a second spelling of `from + i * span / bins`
|
||
// is a second chance to be a second out.
|
||
let lit = current
|
||
.and_then(|t| {
|
||
let i = bin_of((from, to), t, bins);
|
||
buckets.get(i).map(|b| b.start)
|
||
})
|
||
.unwrap_or(0);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_current_bucket(lit as i32);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_current_fraction(fraction);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_range_from_fraction(band_from);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_range_to_fraction(band_to);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_timeline_anchored(current.is_some());
|
||
window
|
||
.global::<Library>()
|
||
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
|
||
window
|
||
.global::<Library>()
|
||
.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
|
||
}
|
||
|
||
/// Whether this bucket size is fine enough for month labels to mean anything.
|
||
///
|
||
/// A year-bucketed axis labelled by month would put twelve labels on one bar.
|
||
/// Month-sized bins are excluded too, now that the axis has a fixed number of
|
||
/// them: a bin of about a month changes month at nearly every bar, so the rule
|
||
/// below would write a label on all sixty-four of them and the right-hand
|
||
/// strip would be a column of overlapping text. Below a month the labels are
|
||
/// occasional again, which is what makes them readable.
|
||
fn granularity_labels_months(g: dr_catalog::Granularity) -> bool {
|
||
matches!(
|
||
g,
|
||
dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour
|
||
)
|
||
}
|
||
|
||
/// Full month name, for the grid's headings.
|
||
pub(super) fn month_name(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"January",
|
||
"February",
|
||
"March",
|
||
"April",
|
||
"May",
|
||
"June",
|
||
"July",
|
||
"August",
|
||
"September",
|
||
"October",
|
||
"November",
|
||
"December",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
fn month_abbrev(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
/// The instant a fraction along a span names.
|
||
///
|
||
/// One home for `from + (to - from) * f`, because three things now depend on
|
||
/// it agreeing with itself: a scrub, the marker drawn where that scrub landed,
|
||
/// and the ends of the date range dragged along the same axis. A second copy
|
||
/// is a second chance for a click and what it produces to disagree.
|
||
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
|
||
}
|
||
|
||
/// Where an instant sits along a span, 0..1. The exact inverse of
|
||
/// [`instant_at`], clamped so an instant off the end of the axis is drawn at
|
||
/// the end it went past rather than off the widget.
|
||
fn fraction_at(span: (i64, i64), t: i64) -> f32 {
|
||
((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32
|
||
}
|
||
|
||
/// Which of `bins` equal bins across `span` holds `t`.
|
||
///
|
||
/// The same arithmetic `library::timeline_uniform` groups by, in the same
|
||
/// order — integer division on the offset from the start — so the bar this
|
||
/// names is the bar that counted the photograph. Clamped, because the grid's
|
||
/// instant can sit outside a zoomed axis.
|
||
fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize {
|
||
let width = (span.1 - span.0).max(1);
|
||
let i = (t - span.0).clamp(0, width) * bins as i64 / width;
|
||
i.clamp(0, bins as i64 - 1) as usize
|
||
}
|
||
|
||
/// Midnight UTC at or before `t`.
|
||
///
|
||
/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive
|
||
/// holds photographs from before 1970 — would otherwise truncate *towards*
|
||
/// zero and name the following midnight, putting the day off by one.
|
||
fn day_start(t: i64) -> i64 {
|
||
t - t.rem_euclid(86_400)
|
||
}
|
||
|
||
/// The range a drag of the band's two ends names, as it is stored.
|
||
///
|
||
/// Whole days at both ends, which does three things. It is what the typed
|
||
/// fields mean, so a dragged range reads back out of them as the two days it
|
||
/// covers rather than as two instants nobody chose. It gives the band a floor:
|
||
/// two handles dragged onto each other name one day, not an empty grid. And it
|
||
/// makes the gesture reproducible — the same day is the same range however
|
||
/// close the finger came to a bucket edge.
|
||
///
|
||
/// Ends dragged past each other are put back in order rather than refused: the
|
||
/// user has described the same two edges either way round.
|
||
pub(super) fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) {
|
||
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||
let start = day_start(instant_at(span, from));
|
||
// Inclusive of the closing day. `parse_date` gives midnight, and a range
|
||
// ending there would exclude every photograph taken on the day named.
|
||
let end = day_start(instant_at(span, to)) + 86_400 - 1;
|
||
(start, end)
|
||
}
|
||
|
||
/// Narrow a span by a zoom level, centred on `centre`.
|
||
///
|
||
/// Each step halves or doubles the visible duration. Clamped to the library's
|
||
/// own extent so zooming out cannot wander past the first or last photograph.
|
||
pub(super) fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option<i64>) -> (i64, i64) {
|
||
let (lo, hi) = full;
|
||
if zoom <= 0 {
|
||
return (lo, hi);
|
||
}
|
||
let duration = (hi - lo).max(1);
|
||
// 2^zoom, saturating: a very deep zoom must not shift the duration to zero.
|
||
let factor = 1i64 << zoom.min(20);
|
||
let window = (duration / factor).max(3600);
|
||
|
||
let mid = centre.unwrap_or(lo + duration / 2);
|
||
let half = window / 2;
|
||
let (mut from, mut to) = (mid - half, mid + half);
|
||
|
||
// Slide rather than shrink at the ends, so the window keeps its size.
|
||
if from < lo {
|
||
to += lo - from;
|
||
from = lo;
|
||
}
|
||
if to > hi {
|
||
from -= to - hi;
|
||
to = hi;
|
||
}
|
||
(from.max(lo), to.min(hi))
|
||
}
|
||
|
||
/// Earliest and latest capture time in the catalog.
|
||
/// The timeline's extent, over exactly the images the grid is showing.
|
||
///
|
||
/// Scrubbing and zooming must measure the same span the histogram is drawn
|
||
/// over. When this read the whole library while the bars were scoped to a
|
||
/// collection, a scrub landed at an instant the collection did not contain and
|
||
/// the view jumped somewhere the user had not asked for.
|
||
pub(super) fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> {
|
||
// `span_scoped` reports the extent with the date range lifted, which is
|
||
// what every caller here wants: they measure the axis that is on screen,
|
||
// and that axis spans the library rather than the chosen range. Measured
|
||
// through the range, narrowing it would move the ground under the very
|
||
// handles doing the narrowing — each drag re-scaling the axis, so the next
|
||
// one meant something else.
|
||
library::span_scoped(catalog, ctl.grid_scope(), &ctl.filter.borrow())
|
||
}
|
||
|
||
/// TRACES: NFR-P5
|
||
/// Capture time of the image at row `ordinal` in the grid's own ordering.
|
||
///
|
||
/// `None` for an ordinal that lands among the undated tail, which sorts last
|
||
/// and has no place on a capture-time axis.
|
||
///
|
||
/// # Answered from the loaded window, not from the catalog
|
||
///
|
||
/// This moves the timeline marker, so it is called on **every scroll event** —
|
||
/// every row the view crosses, by design, or the marker would advance in jerks
|
||
/// while the photographs beside it moved smoothly.
|
||
///
|
||
/// It used to be a query, and `LIMIT 1 OFFSET n` is not a seek: SQLite reaches
|
||
/// row `n` by producing and discarding the `n` before it. So its cost grew with
|
||
/// how far down the library the user had scrolled — 0.02 ms near the top, 0.7 ms
|
||
/// at twenty thousand — and it was paid per row crossed, on the thread drawing
|
||
/// the frame. A flick down a long library therefore got *choppier the further
|
||
/// it went*, which is a strange enough symptom to be worth naming.
|
||
///
|
||
/// The window the grid has already read holds this answer. `window_move` keeps
|
||
/// the whole view inside that window, so the lookup is a vector index at the
|
||
/// position the ordinal has in it.
|
||
///
|
||
/// # The fallback, and why it is also more correct than what it replaces
|
||
///
|
||
/// Outside the loaded window — briefly, after a scrub or a keyboard jump, before
|
||
/// the load lands — it falls back to the query. That query counts in a
|
||
/// *dated-only* ordering while `ordinal` is a grid row, so the two disagree
|
||
/// wherever undated images sit in between; it is kept because it is close
|
||
/// enough for a marker that is about to be corrected, and because being wrong
|
||
/// there is what it always did. The window path has no such disagreement: it
|
||
/// reads the very cell the row belongs to.
|
||
pub(super) fn capture_time_at(
|
||
ctl: &Rc<LibraryController>,
|
||
catalog: &Catalog,
|
||
ordinal: usize,
|
||
) -> Option<i64> {
|
||
{
|
||
let offset = *ctl.offset.borrow();
|
||
let loaded = ctl.captured_at.borrow();
|
||
if ordinal >= offset {
|
||
if let Some(when) = loaded.get(ordinal - offset) {
|
||
return *when;
|
||
}
|
||
}
|
||
}
|
||
capture_time_from_catalog(catalog, ordinal)
|
||
}
|
||
|
||
/// The fallback of [`capture_time_at`], for a row outside the loaded window.
|
||
fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
&format!(
|
||
"SELECT captured_at FROM images
|
||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||
AND {}
|
||
ORDER BY captured_at
|
||
LIMIT 1 OFFSET ?1",
|
||
dr_catalog::bursts::not_collapsed_away("images")
|
||
),
|
||
[ordinal as i64],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
/// Jump the grid to the first image at or after `when`.
|
||
///
|
||
/// This is the scrub: the window moves, the library does not narrow. Every
|
||
/// image stays reachable, which is why this is a position rather than a
|
||
/// filter.
|
||
fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
||
let position = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// 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.
|
||
//
|
||
// A burst folded up occupies one row of the grid, so it must occupy one
|
||
// row of this count as well: an ordinal taken over the unfolded library
|
||
// would overshoot by every frame hidden earlier in it.
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
&format!(
|
||
"SELECT count(*) FROM images
|
||
WHERE shadowed_by IS NULL
|
||
AND captured_at IS NOT NULL
|
||
AND captured_at < ?1
|
||
AND {}",
|
||
dr_catalog::bursts::not_collapsed_away("images")
|
||
),
|
||
[when],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.unwrap_or(0) as usize
|
||
};
|
||
|
||
// Record where the grid now sits, which anchors the timeline marker.
|
||
// `refresh_timeline` seeds this from the view's own position when nothing
|
||
// has claimed it, so by the time a scrub arrives it is an update rather than
|
||
// the first word.
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
*ctl.offset.borrow_mut() = position;
|
||
// Move the viewport as well as the window. Cells are drawn at their
|
||
// absolute place in the library, so loading rows around image 15,000 while
|
||
// the viewport sits at row 0 shows an empty grid until the user scrolls.
|
||
load_window(window, ctl);
|
||
// After the load, which is what places the cells: the grid scrolls by
|
||
// slot, and a panorama before `position` moves its slot along.
|
||
seek_to(window, ctl, position);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-7
|
||
/// Put the view back where the photographer was after the library changed
|
||
/// under it.
|
||
///
|
||
/// Deleting is the case this exists for. The grid draws each cell at its
|
||
/// absolute place in the library — `(i + offset) / columns` — and a delete does
|
||
/// two things at once: the library gets shorter, and `offset` is re-clamped
|
||
/// downward so the loaded window still fills. Neither touches `viewport-y`, so
|
||
/// the view ends up pointing at a different part of the library, or past the
|
||
/// end of it entirely. That is the "goes blank and jumps somewhere random".
|
||
///
|
||
/// Anchored on the *ordinal the view was showing*, clamped into what is left.
|
||
/// Not on the deleted image's own position, which no longer exists, and not on
|
||
/// the top of the library, which would throw away the scroll position on every
|
||
/// delete — after removing one frame from a wall of twenty thousand, the next
|
||
/// one you want is the one that just moved into its place.
|
||
pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let total = window.global::<Library>().get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
// The first *visible* ordinal, not the loaded window's start.
|
||
//
|
||
// Anchoring on `offset` was the first attempt and it still jumped, because
|
||
// the two are deliberately different: the window begins about a quarter of
|
||
// a screen above the view so scrolling up has rows ready, and by this point
|
||
// in `load_window` it has also been re-clamped against the new, smaller
|
||
// total. Seeking to it therefore moved the view to somewhere the user was
|
||
// not — a smaller jump than before, but the same fault.
|
||
//
|
||
// `resume_at` is what the grid last reported as its first visible image,
|
||
// which is the thing the photographer is actually looking at.
|
||
let anchor = ctl.resume_at.get().min(total - 1);
|
||
seek_to(window, ctl, anchor);
|
||
}
|
||
|
||
/// Format a bucket start for the histogram's hover label.
|
||
fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
|
||
let (y, m, d, h) = civil_from_unix(t);
|
||
match g {
|
||
dr_catalog::Granularity::Year => format!("{y}"),
|
||
dr_catalog::Granularity::Month => format!("{y}-{m:02}"),
|
||
dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"),
|
||
dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"),
|
||
}
|
||
}
|
||
|
||
/// A capture instant as `YYYY-MM-DD`.
|
||
///
|
||
/// Shared with the exporter, which resolves the `{date}` token from the same
|
||
/// reading so a filename and the timeline cannot disagree about what day a
|
||
/// photograph was taken.
|
||
pub fn format_date(t: i64) -> String {
|
||
dr_types::format_date(t)
|
||
}
|
||
|
||
/// Unix seconds to a civil date, in the tuple shape this module reads it in.
|
||
///
|
||
/// The algorithm moved to `dr_types::time` when import folder templates
|
||
/// (FR-CAT-10) became a third caller. Three readings of the same instant that
|
||
/// could drift apart is one too many: an image filed under a date the timeline
|
||
/// does not show it on is a file the user cannot find.
|
||
pub(super) fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
||
let c = dr_types::civil_from_unix(t);
|
||
(c.year, c.month, c.day, c.hour)
|
||
}
|
||
|
||
// --- where the photographer was (FR-UI-8) --------------------------------
|
||
|
||
/// How long a movement has to settle before it is written down.
|
||
///
|
||
/// A flick reports a scroll several times per screenful, and a place is a file
|
||
/// write — one per event would put disk I/O inside the gesture NFR-P5 governs,
|
||
/// for a record only the *last* of which is ever read. Longer than
|
||
/// [`GEOMETRY_SETTLE`] because nothing on screen waits for this: the reload a
|
||
/// geometry change schedules is visible, and this is not.
|
||
const PLACE_SETTLE: std::time::Duration = std::time::Duration::from_millis(750);
|
||
|
||
/// The photographer moved. Write it down, once they stop.
|
||
///
|
||
/// Replacing the timer rather than adding one is what makes this a debounce:
|
||
/// a `slint::Timer` is stopped by being dropped, so the previous pending write
|
||
/// goes with the old handle.
|
||
pub(super) fn note_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
if ctl.applying_place.get() {
|
||
// A restore is not a movement — see `applying_place`.
|
||
return;
|
||
}
|
||
|
||
// The handover latch closes on the first movement — see `place_untouched`.
|
||
ctl.place_untouched.set(false);
|
||
|
||
if ctl.place_store.borrow().is_none() {
|
||
return;
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let held = ctl.clone();
|
||
timer.start(slint::TimerMode::SingleShot, PLACE_SETTLE, move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
write_place(&w, &held);
|
||
}
|
||
});
|
||
*ctl.place_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Write the place out now, without waiting for a settle.
|
||
///
|
||
/// For the moments there may be no "later": leaving the grid for develop, and
|
||
/// coming back. Both are single deliberate acts rather than runs of events, and
|
||
/// both change the one field a debounce is most likely to lose — which view the
|
||
/// user is in.
|
||
pub(super) fn write_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
if ctl.applying_place.get() {
|
||
return;
|
||
}
|
||
// Opening a photograph is as much "the photographer has started" as
|
||
// scrolling is, and this is the path that records it — so the handover
|
||
// latch closes here too, not only in `note_place`. Without it a record
|
||
// arriving from another device could pull someone out of the image they had
|
||
// just opened.
|
||
ctl.place_untouched.set(false);
|
||
|
||
let Some(place) = current_place(window, ctl) else {
|
||
return;
|
||
};
|
||
if let Some(store) = ctl.place_store.borrow().as_ref() {
|
||
store.save(&place);
|
||
}
|
||
}
|
||
|
||
/// Where the photographer is, as a record that can be written down.
|
||
///
|
||
/// `None` where there is nothing worth recording: no library open, or the
|
||
/// launch screen in front of everything. A place is never written from the
|
||
/// launch screen — the user is *choosing* a library there, and recording "you
|
||
/// were at the launch screen" would make the next launch skip the position it
|
||
/// had just been asked to keep.
|
||
fn current_place(window: &AppWindow, ctl: &Rc<LibraryController>) -> Option<dr_types::Place> {
|
||
use dr_types::{Place, PlaceScope, Screen};
|
||
|
||
if window.get_active_view() == View::Launch || !window.global::<Library>().get_library_open() {
|
||
return None;
|
||
}
|
||
|
||
// Which view, and — the same question in both — which photograph.
|
||
//
|
||
// In the grid it is the first one visible, which `resume_at` already
|
||
// tracks; in develop it is the open one, which `report_position` keeps
|
||
// `index` holding. Both are library ordinals against the same ordering, so
|
||
// the lookup below is one piece of code rather than two.
|
||
let in_library = window.get_active_view() == View::Library;
|
||
let at = if in_library {
|
||
ctl.resume_at.get()
|
||
} else {
|
||
window.get_index().max(0) as usize
|
||
};
|
||
|
||
// Resolved through the loaded window rather than by a query: the model has
|
||
// the path and the capture time side by side already, and this runs on a
|
||
// settle after scrolling — the one moment a query is least welcome.
|
||
let offset = *ctl.offset.borrow();
|
||
let row = at.checked_sub(offset);
|
||
let path = row
|
||
.and_then(|r| ctl.paths.borrow().get(r).cloned())
|
||
.unwrap_or_default();
|
||
let captured_at = row.and_then(|r| ctl.captured_at.borrow().get(r).copied().flatten());
|
||
|
||
// The scope, in the vocabulary both devices share. A collection this device
|
||
// cannot name is recorded as the whole library rather than as a name
|
||
// nothing can resolve — see `collections::uuid_of`.
|
||
let scope = if ctl.viewing_trash.get() {
|
||
PlaceScope::Trash
|
||
} else {
|
||
match *ctl.scope.borrow() {
|
||
None => PlaceScope::Library,
|
||
Some(id) => {
|
||
let borrow = ctl.catalog.borrow();
|
||
let uuid = borrow
|
||
.as_ref()
|
||
.and_then(|c| dr_catalog::collections::uuid_of(c.connection(), id).ok())
|
||
.flatten();
|
||
match uuid {
|
||
Some(uuid) => PlaceScope::Collection { uuid },
|
||
None => PlaceScope::Library,
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
Some(Place {
|
||
at: library::now_secs(),
|
||
device: ctl.place_device.borrow().clone(),
|
||
screen: if in_library {
|
||
Screen::Library
|
||
} else {
|
||
Screen::Develop
|
||
},
|
||
scope,
|
||
path,
|
||
captured_at,
|
||
filter: (&*ctl.filter.borrow()).into(),
|
||
})
|
||
}
|
||
|
||
/// Put the photographer back where a record says they were.
|
||
///
|
||
/// # The order is the whole function
|
||
///
|
||
/// Scope, then filter, then position, then — last of all — the view. Each step
|
||
/// changes what an ordinal *means*: the grid is a window over a query, and a
|
||
/// row resolved before the scope was narrowed names a photograph in a different
|
||
/// list. Resolving the path last is what makes the restored position address the
|
||
/// grid the user is actually about to see.
|
||
///
|
||
/// # Nothing here is allowed to fail loudly
|
||
///
|
||
/// Every step degrades to "the state we would have been in anyway". A collection
|
||
/// this device has not merged yet leaves the scope at the whole library; a
|
||
/// photograph that has been deleted falls back to when it was taken; a capture
|
||
/// time that resolves to nothing leaves the grid at the top. A place is a
|
||
/// convenience, and an application that would not open because one was stale
|
||
/// would be trading something that matters for something that does not.
|
||
pub(crate) fn apply_place(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
place: &dr_types::Place,
|
||
) {
|
||
use dr_types::{PlaceScope, Screen};
|
||
|
||
ctl.applying_place.set(true);
|
||
|
||
// --- the scope --------------------------------------------------------
|
||
//
|
||
// Through the same callback the sidebar fires, rather than by setting the
|
||
// two controllers' fields by hand. Both of them hold a scope, and so does
|
||
// the header's label and the reorderable flag; there is exactly one piece
|
||
// of code that keeps those four in step and this is not the place to write
|
||
// a second.
|
||
let selected = match &place.scope {
|
||
PlaceScope::Library => 0,
|
||
PlaceScope::Trash => -1,
|
||
PlaceScope::Collection { uuid } => {
|
||
let borrow = ctl.catalog.borrow();
|
||
let found = borrow
|
||
.as_ref()
|
||
.and_then(|c| dr_catalog::collections::id_for_uuid(c.connection(), uuid).ok())
|
||
.flatten();
|
||
drop(borrow);
|
||
match found {
|
||
Some(id) => id.0 as i32,
|
||
// Recorded on another device, and this one has not merged the
|
||
// collection yet. The whole library is the honest fallback: an
|
||
// empty grid under a name nothing can resolve reads as data
|
||
// loss.
|
||
None => {
|
||
log::info!("the stored place names a collection this device does not have yet");
|
||
0
|
||
}
|
||
}
|
||
}
|
||
};
|
||
window.invoke_collection_select(selected);
|
||
|
||
// --- the filter -------------------------------------------------------
|
||
*ctl.filter.borrow_mut() = (&place.filter).into();
|
||
push_filter(window, ctl);
|
||
|
||
// --- the position -----------------------------------------------------
|
||
let resolved = resolve_place(window, ctl, place);
|
||
|
||
// The filter and the scope have both moved the window to the top; this is
|
||
// what puts it back, and it has to run after them for that reason.
|
||
let total = window.global::<Library>().get_library_total().max(0) as usize;
|
||
let resolved = resolved.filter(|(at, _)| *at < total);
|
||
match resolved {
|
||
Some((at, _)) => {
|
||
ctl.resume_at.set(at);
|
||
// Moves the capture-time marker with it — see `resume_position`.
|
||
resume_position(window, ctl, coll_ctl, None);
|
||
}
|
||
None => refilter(window, ctl),
|
||
}
|
||
|
||
// --- and the view -----------------------------------------------------
|
||
//
|
||
// Only where the photograph itself was found. A capture time that merely
|
||
// landed in the right week is a good answer for the grid and no answer at
|
||
// all for a canvas: develop would open on a path that no longer resolves
|
||
// and show a filename over an empty frame.
|
||
if place.screen == Screen::Develop {
|
||
if let Some((at, Found::Photograph)) = resolved {
|
||
if let Some(open) = ctl.open_image.borrow().clone() {
|
||
window.set_active_view(View::Develop);
|
||
window.global::<Library>().set_library_roll_centre(true);
|
||
let offset = *ctl.offset.borrow();
|
||
if let Some(row) = at.checked_sub(offset) {
|
||
mark_open(window, ctl, row);
|
||
}
|
||
open(place.path.clone());
|
||
// After the open, which resets the readout to "1 of 1" on its
|
||
// way in — the same ordering `report_position` documents.
|
||
window.set_index(at as i32);
|
||
window.set_total(window.global::<Library>().get_library_total());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Last, so nothing between here and the top is mistaken for the
|
||
// photographer having moved.
|
||
ctl.applying_place.set(false);
|
||
}
|
||
|
||
/// The library ordinal a record names, or `None` if it names nothing here.
|
||
///
|
||
/// Two ways, and the second is why a place survives its subject. The path is
|
||
/// exact and is tried first. Failing that — the photograph was deleted, moved,
|
||
/// or filtered out by the very filter this place restored — the capture time
|
||
/// puts the grid in the right week, which is close enough that the user
|
||
/// recognises where they are.
|
||
fn resolve_place(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
place: &dr_types::Place,
|
||
) -> Option<(usize, Found)> {
|
||
let borrow = ctl.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
|
||
if !place.path.is_empty() {
|
||
match library::ordinal_of_path(
|
||
catalog,
|
||
ctl.grid_scope(),
|
||
&ctl.filter.borrow(),
|
||
ctl.viewing_trash.get(),
|
||
&place.path,
|
||
) {
|
||
Ok(Some(at)) => return Some((at, Found::Photograph)),
|
||
Ok(None) => {}
|
||
Err(e) => log::debug!("resolving the stored place: {e}"),
|
||
}
|
||
}
|
||
|
||
let when = place.captured_at?;
|
||
drop(borrow);
|
||
// Through the scrubber's own route, so "an instant, as an ordinal" has one
|
||
// spelling. It sets the viewport and the loaded window together, which is
|
||
// exactly what a restore needs, and it anchors the timeline marker on the
|
||
// way.
|
||
scrub_to(window, ctl, when);
|
||
// Where it landed, read back rather than recomputed: `scrub_to` sets the
|
||
// offset to the ordinal it resolved and `load_window` then clamps it against
|
||
// the end of the library, so the clamped value is the one the grid is
|
||
// actually showing.
|
||
Some((*ctl.offset.borrow(), Found::Thereabouts))
|
||
}
|
||
|
||
/// How exactly a place was resolved.
|
||
///
|
||
/// The distinction is what stops a restore reopening develop on the wrong
|
||
/// photograph. Landing in the right week is a perfectly good answer for a grid
|
||
/// — the user recognises where they are — and no answer at all for a canvas,
|
||
/// which would open on a path that no longer resolves and show a filename over
|
||
/// an empty frame.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum Found {
|
||
/// The photograph itself is still in this grid, at this ordinal.
|
||
Photograph,
|
||
/// It is not, so this is where its capture time falls instead.
|
||
Thereabouts,
|
||
}
|
||
|
||
/// Put the whole rating filter on screen.
|
||
///
|
||
/// The filter bar is drawn from half a dozen properties, each of which is set
|
||
/// where its own control is handled — which is right while the user is moving
|
||
/// them one at a time, and leaves nothing that can restore all of them at once.
|
||
/// A restored place that narrowed the grid without moving the stars would show
|
||
/// a filtered library under a bar claiming it was not filtered, and the user's
|
||
/// first act would be to try to clear a filter the bar says is off.
|
||
fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let filter = ctl.filter.borrow().clone();
|
||
window
|
||
.global::<Library>()
|
||
.set_library_filter_min_rating(filter.min_rating as i32);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_filter_max_rating(filter.max_rating.map_or(5, i32::from));
|
||
window
|
||
.global::<Library>()
|
||
.set_library_filter_unjudged(filter.unjudged);
|
||
// The bar's own numbering, from `on_library_filter_flag_changed`. It has no
|
||
// code for `Unflagged` because it cannot produce one — "nothing has judged
|
||
// this" is the `unjudged` chip, over both stars and flags — so a place can
|
||
// never carry one either, and it falls in with "no flag constraint".
|
||
window
|
||
.global::<Library>()
|
||
.set_library_filter_flag(match filter.flag {
|
||
Some(dr_types::FlagState::Pick) => 1,
|
||
Some(dr_types::FlagState::Reject) => 2,
|
||
_ => 0,
|
||
});
|
||
window
|
||
.global::<Library>()
|
||
.set_library_local_only(filter.local_only);
|
||
ctl.local_only.set(filter.local_only);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_filter_eyes_open(filter.eyes_open);
|
||
window.global::<Library>().set_library_filter_label(
|
||
filter
|
||
.label
|
||
.map_or(0, |l| dr_catalog::rating::label_code(l) as i32),
|
||
);
|
||
push_people_chips(window, ctl);
|
||
}
|
||
|
||
/// Bring the grid back to where the photographer left it, and — coming out of
|
||
/// develop — put the keyboard cursor on the photograph they were editing.
|
||
///
|
||
/// # Two positions, not one
|
||
///
|
||
/// `resume_at` is where the *grid* was when it was left. `focus` is the
|
||
/// photograph develop was showing, which after a walk along the photo roll can
|
||
/// be a thousand rows away from it. Restoring only the first is what made
|
||
/// leaving develop feel like losing the frame you had just finished: the grid
|
||
/// came back to a screenful that no longer had it in.
|
||
///
|
||
/// So both are honoured, and which one wins is decided by whether they
|
||
/// disagree. If the open photograph is inside the screenful the grid was left
|
||
/// showing — the ordinary case, where develop was opened and closed on the same
|
||
/// frame — the remembered position is used unchanged and nothing appears to
|
||
/// move. If it is not, the grid goes to the photograph, because that is the one
|
||
/// the user was last looking at.
|
||
///
|
||
/// # Why the cursor rather than the selection
|
||
///
|
||
/// Deliberately not [`place_cursor`], which also rewrites the selection. A
|
||
/// selection of forty photographs assembled in the grid, then one of them
|
||
/// opened to check it, must survive the trip back — discarding it is the kind
|
||
/// of silent loss that stops people using the develop view mid-cull. The
|
||
/// cursor is a position, not a judgement, so it is safe to move.
|
||
///
|
||
/// The viewport is left to the grid's own `reveal()`, which moves as little as
|
||
/// will bring the cursor's row into view. That is what makes the two cases
|
||
/// above one rule rather than two: a cursor already on screen moves nothing.
|
||
pub(super) fn resume_position(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
focus: Option<usize>,
|
||
) {
|
||
let total = window.global::<Library>().get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
// Files named on the command line: there is no grid behind them, and
|
||
// an ordinal read off the readout would name a row of nothing.
|
||
return;
|
||
}
|
||
|
||
let resume = ctl.resume_at.get().min(total - 1);
|
||
let focus = focus.map(|at| at.min(total - 1));
|
||
|
||
// A screenful, as the grid last measured itself. The comparison has to be
|
||
// against what is *visible*, not against the loaded window, which is
|
||
// several screenfuls wide — an open photograph loaded but scrolled past is
|
||
// still one the user cannot see.
|
||
let viewport = ctl.viewport_cells.get().max(1);
|
||
let anchor = match focus {
|
||
Some(at) if at >= resume && at < resume + viewport => resume,
|
||
Some(at) => at,
|
||
None => resume,
|
||
};
|
||
|
||
// Centred like `on_library_scrolled` does it, so scrolling either way from
|
||
// the restored position has loaded rows to move into.
|
||
let window_size = *ctl.window.borrow();
|
||
*ctl.offset.borrow_mut() = window_start(anchor, window_size, total);
|
||
load_window(window, ctl);
|
||
|
||
// Before the grid is shown, not after: the markup gates it on an `if`, and
|
||
// the rebuilt Flickable reads `scroll-to` in its `init`. Setting these
|
||
// afterwards would leave that init to run against the previous position.
|
||
seek_to(window, ctl, anchor);
|
||
|
||
if let Some(at) = focus {
|
||
coll.set_cursor(Some(at));
|
||
show_cursor(window, ctl, at);
|
||
}
|
||
|
||
// The view has moved, so the capture-time marker has to move with it.
|
||
//
|
||
// `load_window` will not do it: it rebuilds the axis only when the scope,
|
||
// the filter or the total has changed, and none of them has — this is the
|
||
// same library seen from a different row. Without this the marker stayed
|
||
// wherever it was when develop was entered, which after a walk along the
|
||
// photo roll can be months away from the photographs now on screen.
|
||
reanchor_timeline(window, ctl, anchor);
|
||
}
|
||
|
||
/// Put the capture-time marker where the view now is.
|
||
///
|
||
/// Clearing the anchor and re-running the axis rather than computing a fraction
|
||
/// here: `refresh_timeline` seeds itself from the view's position when nothing
|
||
/// has claimed one, and it owns every other thing the sidebar draws — the bars,
|
||
/// the range band, the lit bucket. A second place that positioned the marker
|
||
/// would be a second chance to disagree with the bars under it, which is the
|
||
/// bug the `current-fraction` note in `library.slint` records.
|
||
fn reanchor_timeline(window: &AppWindow, ctl: &Rc<LibraryController>, at: usize) {
|
||
ctl.resume_at.set(at);
|
||
*ctl.current_bucket.borrow_mut() = None;
|
||
let borrow = ctl.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
}
|
||
|
||
/// The capture-time sidebar: panning, zooming and scrubbing the axis that
|
||
/// the grid's loaded window follows.
|
||
pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
// Panning the timeline slides the visible span without changing its width.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.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, &ctl) else {
|
||
return;
|
||
};
|
||
|
||
// 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 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) + 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
|
||
.global::<Library>()
|
||
.on_library_timeline_zoom(move |delta| {
|
||
if let Some(w) = weak.upgrade() {
|
||
apply_zoom(&w, &ctl, delta);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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
|
||
.global::<Library>()
|
||
.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, &ctl) else {
|
||
return;
|
||
};
|
||
|
||
let (from, to) = zoomed_span(
|
||
full,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
);
|
||
let when = instant_at((from, to), f);
|
||
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
|
||
.global::<Library>()
|
||
.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);
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[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 the_marker_lands_on_the_fraction_that_was_clicked() {
|
||
// The bug this guards: the marker was placed from a *bar index* while
|
||
// the click was a fraction of the track, so it never appeared under
|
||
// the pointer. Marker and click must be inverses.
|
||
let span = (1_000, 9_000);
|
||
for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] {
|
||
let round_trip = fraction_at(span, instant_at(span, f));
|
||
assert!(
|
||
(round_trip - f).abs() < 1e-3,
|
||
"clicked {f}, marker drawn at {round_trip}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() {
|
||
// Zooming in leaves the grid's instant outside the axis. The marker
|
||
// belongs at the edge it went past, not off the widget.
|
||
let span = (1_000, 2_000);
|
||
assert_eq!(fraction_at(span, 0), 0.0);
|
||
assert_eq!(fraction_at(span, 5_000), 1.0);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// A fortnight in March 2024, in whole days, as the axis would span it.
|
||
const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800);
|
||
|
||
#[test]
|
||
fn a_dragged_range_covers_whole_days_at_both_ends() {
|
||
// What makes a dragged range the same kind of thing as a typed one:
|
||
// `show_range` reads the closing day back out of the stored second, so
|
||
// an end left mid-afternoon would be shown as the day before.
|
||
let (from, to) = dragged_range(MARCH, 0.13, 0.61);
|
||
assert_eq!(day_start(from), from, "opens at a midnight");
|
||
assert_eq!(
|
||
day_start(to + 1),
|
||
to + 1,
|
||
"closes at the last second of a day"
|
||
);
|
||
assert_eq!(dr_types::format_date(from), "2024-03-02");
|
||
assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09");
|
||
}
|
||
|
||
#[test]
|
||
fn ends_dragged_past_each_other_name_the_same_range() {
|
||
// Pulling the early end below the late one is how a range that was
|
||
// narrowed too far gets corrected; refusing it would make starting
|
||
// again the easier move.
|
||
assert_eq!(
|
||
dragged_range(MARCH, 0.6, 0.2),
|
||
dragged_range(MARCH, 0.2, 0.6)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn both_ends_dragged_together_name_one_day() {
|
||
// The floor the day snapping buys. Two handles on the same pixel is a
|
||
// gesture that happens; an empty grid is not what it means.
|
||
let (from, to) = dragged_range(MARCH, 0.5, 0.5);
|
||
assert_eq!(to - from, 86_399);
|
||
}
|
||
|
||
#[test]
|
||
fn the_band_is_drawn_where_the_handle_was_dropped() {
|
||
// The band is placed by `fraction_at` and the range comes from
|
||
// `instant_at`. If those two ever disagreed the band would settle
|
||
// somewhere other than where the finger let go, which reads as the
|
||
// drag having been ignored.
|
||
for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
|
||
let (from, _) = dragged_range(MARCH, f, f);
|
||
let drawn = fraction_at(MARCH, from);
|
||
// Within a day of the span, which is the snapping and nothing else.
|
||
let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32;
|
||
assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_instant_on_the_axis_lands_in_a_bin() {
|
||
// The bar under a date and the bar that counted it must be the same
|
||
// bar: `bin_of` is the UI's copy of the division the SQL groups by.
|
||
let span = (1_000, 9_000);
|
||
assert_eq!(bin_of(span, 1_000, 8), 0);
|
||
assert_eq!(bin_of(span, 1_999, 8), 0);
|
||
assert_eq!(bin_of(span, 2_000, 8), 1);
|
||
// The last instant belongs to the last bin rather than to one past it.
|
||
assert_eq!(bin_of(span, 9_000, 8), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() {
|
||
// Zooming in leaves the grid's position outside the span. The lit bar
|
||
// belongs at the edge, not at an index that does not exist.
|
||
let span = (1_000, 9_000);
|
||
assert_eq!(bin_of(span, -50_000, 8), 0);
|
||
assert_eq!(bin_of(span, 50_000, 8), 7);
|
||
// And a degenerate axis still answers with a bin.
|
||
assert_eq!(bin_of((5, 5), 5, 32), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_day_before_1970_starts_at_its_own_midnight() {
|
||
// `%` truncates towards zero, which on a negative timestamp names the
|
||
// *following* midnight — a scanned archive of negatives would have
|
||
// every range off by a day at the early end.
|
||
assert_eq!(day_start(-1), -86_400);
|
||
assert_eq!(day_start(-86_400), -86_400);
|
||
assert_eq!(day_start(-86_401), -172_800);
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_zero_is_the_whole_library() {
|
||
let full = (1_000, 2_000);
|
||
assert_eq!(zoomed_span(full, 0, None), full);
|
||
// A remembered centre must not narrow the span at zoom 0.
|
||
assert_eq!(zoomed_span(full, 0, Some(1_500)), full);
|
||
}
|
||
|
||
#[test]
|
||
fn each_zoom_step_halves_the_span() {
|
||
let day = 86_400;
|
||
let full = (0, 64 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(32 * day));
|
||
assert_eq!(b - a, 32 * day);
|
||
let (a, b) = zoomed_span(full, 2, Some(32 * day));
|
||
assert_eq!(b - a, 16 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_centres_on_the_given_instant() {
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(60 * day));
|
||
assert_eq!((a + b) / 2, 60 * day, "centred where asked");
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_at_the_edge_slides_rather_than_shrinking() {
|
||
// Clamping both ends would silently halve the span near the start of
|
||
// the library, so the axis would show less detail there than in the
|
||
// middle for the same zoom level.
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(0));
|
||
assert_eq!(a, 0, "cannot start before the library does");
|
||
assert_eq!(b - a, 50 * day, "keeps its width");
|
||
|
||
let (a, b) = zoomed_span(full, 1, Some(100 * day));
|
||
assert_eq!(b, 100 * day);
|
||
assert_eq!(b - a, 50 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn a_deep_zoom_does_not_collapse_to_nothing() {
|
||
// A zero-width span would make every bucket empty, which reads as a
|
||
// broken axis rather than a deep zoom.
|
||
let full = (0, 86_400);
|
||
let (a, b) = zoomed_span(full, 12, Some(43_200));
|
||
assert!(b > a, "span stays positive");
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_instant_library_does_not_divide_by_zero() {
|
||
let full = (1_700_000_000, 1_700_000_000);
|
||
let (a, b) = zoomed_span(full, 5, None);
|
||
assert!(a <= b);
|
||
}
|
||
|
||
#[test]
|
||
fn month_names_cover_the_year_and_clamp() {
|
||
assert_eq!(month_name(1), "January");
|
||
assert_eq!(month_name(12), "December");
|
||
assert_eq!(month_abbrev(3), "Mar");
|
||
// A corrupt month must not panic the grid.
|
||
assert_eq!(month_name(0), "January");
|
||
assert_eq!(month_name(99), "December");
|
||
}
|
||
|
||
#[test]
|
||
fn civil_dates_round_trip_at_boundaries() {
|
||
// Era-based date maths is silently wrong at year and leap boundaries
|
||
// if the algorithm is transcribed slightly off, and the symptom is an
|
||
// image landing in the wrong histogram bucket.
|
||
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0));
|
||
assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14));
|
||
// Leap day.
|
||
assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0));
|
||
// Last second of a year, and the first of the next.
|
||
assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23));
|
||
assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn dates_before_the_epoch_do_not_wrap() {
|
||
// Scanned film carries capture dates well before 1970; a negative
|
||
// timestamp must floor rather than truncate toward zero.
|
||
let (y, _, _, _) = civil_from_unix(-1);
|
||
assert_eq!(y, 1969);
|
||
}
|
||
|
||
/// A bar is named after how long it is.
|
||
///
|
||
/// The axis no longer buckets by calendar unit — it cuts the visible span
|
||
/// into a fixed number of equal bins — so the unit survives only as a
|
||
/// vocabulary for *labelling* one. What this guards is that zooming in
|
||
/// never produces a coarser label: the complaint it came from was a
|
||
/// fortnight drawn as one column of a year-per-bar axis.
|
||
#[test]
|
||
fn a_bin_is_labelled_at_its_own_scale() {
|
||
use dr_catalog::Granularity;
|
||
const DAY: i64 = 86_400;
|
||
|
||
// Fifteen years across 32 bars is nearly six months a bar, which on a
|
||
// ratio measure is nearer a year than a month — so the bars are
|
||
// labelled by year, which is what a fifteen-year axis wants anyway.
|
||
assert_eq!(
|
||
Granularity::for_bucket(15 * 365 * DAY / 32),
|
||
Granularity::Year
|
||
);
|
||
|
||
// A fortnight across the same 32 bars is about ten hours a bar, which
|
||
// is nearer a day than an hour and is labelled as a date.
|
||
assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day);
|
||
|
||
// Zoom on until a bar is an hour or two, and it is named by the hour.
|
||
assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour);
|
||
|
||
// The property rather than the thresholds: a shorter bin never gets a
|
||
// coarser label than a longer one.
|
||
let mut previous = i64::MAX;
|
||
for days in [3650, 730, 180, 60, 14, 3, 1] {
|
||
let g = Granularity::for_bucket(days * DAY / 32);
|
||
let bucket = match g {
|
||
Granularity::Year => 365 * DAY,
|
||
Granularity::Month => 30 * DAY,
|
||
Granularity::Day => DAY,
|
||
Granularity::Hour => 3600,
|
||
};
|
||
assert!(
|
||
bucket <= previous,
|
||
"{days} days chose a coarser bin label than the span above it"
|
||
);
|
||
previous = bucket;
|
||
}
|
||
}
|
||
|
||
/// Deleting must not move the view somewhere the user did not ask to be.
|
||
///
|
||
/// The grid draws each cell at its absolute place in the library, and a
|
||
/// delete shortens the library *and* re-clamps the loaded window's offset.
|
||
/// Neither touches the viewport, so before this the grid went blank — the
|
||
/// view left pointing past the end of the content — or appeared to jump,
|
||
/// the same scroll position now addressing different photographs.
|
||
///
|
||
/// The anchor is the last *visible* ordinal the grid reported, clamped into
|
||
/// what is left — not the loaded window's `offset`, which sits about a
|
||
/// quarter of a screen above the view and is itself re-clamped by the
|
||
/// delete. Anchoring on that was the first attempt and still jumped.
|
||
#[test]
|
||
fn a_shorter_library_anchors_the_view_instead_of_losing_it() {
|
||
// The rule `restore_position` applies, stated where it can be checked.
|
||
let anchor = |offset: usize, total: usize| -> Option<usize> {
|
||
if total == 0 {
|
||
None
|
||
} else {
|
||
Some(offset.min(total - 1))
|
||
}
|
||
};
|
||
|
||
// The everyday case: one frame removed from the middle of a wall of
|
||
// twenty thousand. The view does not move — the photograph that slid
|
||
// into the gap is the one you want next.
|
||
assert_eq!(anchor(12_000, 19_999), Some(12_000));
|
||
|
||
// Near the end, which is where the blank came from: the view was
|
||
// showing ordinals past what now exists, so it lands on the last.
|
||
assert_eq!(anchor(19_990, 5), Some(4));
|
||
|
||
// Emptied entirely. Seeking into an empty library would be the same
|
||
// fault in the other direction, so there is nothing to do.
|
||
assert_eq!(anchor(500, 0), None);
|
||
|
||
// The boundary: an offset equal to the new total is one past the end.
|
||
assert_eq!(anchor(10, 10), Some(9));
|
||
}
|
||
|
||
#[test]
|
||
fn bucket_labels_match_their_granularity() {
|
||
use dr_catalog::Granularity;
|
||
let t = 1_786_285_800; // 2026-08-09 14:30 UTC
|
||
assert_eq!(format_bucket(t, Granularity::Year), "2026");
|
||
assert_eq!(format_bucket(t, Granularity::Month), "2026-08");
|
||
assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09");
|
||
assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00");
|
||
}
|
||
}
|