//! 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, 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, 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) { // 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::() .set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![]))); window .global::() .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 = 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::() .set_library_current_bucket(lit as i32); window .global::() .set_library_current_fraction(fraction); window .global::() .set_library_range_from_fraction(band_from); window .global::() .set_library_range_to_fraction(band_to); window .global::() .set_library_timeline_anchored(current.is_some()); window .global::() .set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into()); window .global::() .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) { 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) -> 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, catalog: &Catalog, ordinal: usize, ) -> Option { { 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 { 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, 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::() .set_library_scroll_to(position as i32); window .global::() .set_library_scroll_token(window.global::().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) { let total = window.global::().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::() .set_library_scroll_to(anchor as i32); window .global::() .set_library_scroll_token(window.global::().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) { 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) { 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) -> Option { use dr_types::{Place, PlaceScope, Screen}; if window.get_active_view() == View::Launch || !window.global::().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, coll_ctl: &Rc, 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::().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::().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::().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, 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) { let filter = ctl.filter.borrow().clone(); window .global::() .set_library_filter_min_rating(filter.min_rating as i32); window .global::() .set_library_filter_max_rating(filter.max_rating.map_or(5, i32::from)); window .global::() .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::() .set_library_filter_flag(match filter.flag { Some(dr_types::FlagState::Pick) => 1, Some(dr_types::FlagState::Reject) => 2, _ => 0, }); window .global::() .set_library_local_only(filter.local_only); ctl.local_only.set(filter.local_only); window .global::() .set_library_filter_eyes_open(filter.eyes_open); window.global::().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, coll: &Rc, focus: Option, ) { let total = window.global::().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::() .set_library_scroll_to(anchor as i32); window .global::() .set_library_scroll_token(window.global::().get_library_scroll_token() + 1); if let Some(at) = focus { coll.set_cursor(Some(at)); window.global::().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, 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) { // Panning the timeline slides the visible span without changing its width. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .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::() .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::() .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::() .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 { 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"); } }