Colour labels could be read from a Lightroom sidecar and queried by the selector, but nothing drew one or set one, so the only labels a library held were ones another program had written. Every mark carries its label's initial on its colour — R, Y, G, B, P — so a label is read without telling red from green, which is what NFR-A11Y-3 asks of colour labels by name. A grid cell shows the mark before its filename. In the grid, 6, 7, 8 and 9 set red, yellow, green and blue as Lightroom's keys do, on the photograph under the pointer or on the selection by the rule the star keys follow; the same key again takes the label off, and over a mixed selection it sets it on all. The selection bar gains Label, which opens the six choices — each a mark and a name — and purple, which has no key, is there. In develop the top bar says "Label: Green" beside the mark, opens the same choices, and 6-9 label the open photograph. Each gesture is one catalog transaction, then the grid, the counts and both sidecars are written as a rating's are. The filter bar gains a chip per label, its mark and its name with a count, one at a time; the filter is one SQL term, travels in the place record, and "All" clears it.
1543 lines
64 KiB
Rust
1543 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, 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.scope.borrow();
|
||
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.scope.borrow(), &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.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scroll_to(position as i32);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
/// 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);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scroll_to(anchor as i32);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||
}
|
||
|
||
/// 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) {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_roll_current(row as i32);
|
||
}
|
||
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.scope.borrow(),
|
||
&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.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scroll_to(anchor as i32);
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scroll_token(window.global::<Library>().get_library_scroll_token() + 1);
|
||
|
||
if let Some(at) = focus {
|
||
coll.set_cursor(Some(at));
|
||
window.global::<Library>().set_library_cursor(at as i32);
|
||
}
|
||
|
||
// 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");
|
||
}
|
||
}
|