Files
DarkRoom/ui/dr-ui/src/library_ui/grid.rs
T
dtourolle 502023c0f4 Show a thin scroll cue on Android instead of no scrollbar at all
On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
2026-09-27 07:22:09 -04:00

1030 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Wiring the grid itself: the keyboard cursor, cell-size zoom, the reload
//! triggers a scroll or a geometry change schedules, and the routes into and
//! out of the develop view.
//!
//! Everything here is a `window.on_...` registration or a small helper next
//! to the one callback that uses it; the work each callback delegates to —
//! loading a window, moving the timeline, saving a place — lives in the
//! module that owns that concern. See `docs/dev/code-health.md` CH-1.
use std::rc::Rc;
use slint::ComponentHandle;
use crate::AppWindow;
use crate::{GestureRow, Library, View};
use super::controller::{
LibraryController, OpenImage, MAX_CELL_SIZE, MIN_CELL_SIZE, MIN_WINDOW, SCREENFULS,
};
use super::filter_bar::{
wire_filter_dates, wire_filter_ratings_and_people, wire_filter_scope_and_offline,
};
use super::open::{schedule_reload, start_rescan};
use super::ratings_keywords::{start_xmp_reload, wire_keywords, wire_ratings_and_flags};
use super::sync::{start_derived_sync, start_thumbnail_sweep};
use super::timeline::{
capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place,
zoomed_span,
};
use super::window::{bring_window_to, load_window, mark_open, window_move};
/// Walk the keyboard cursor through the library — the arrow keys.
///
/// **The cursor is a library ordinal, not a row of the loaded window.** That is
/// what lets it walk past the window's edge: the window is a few screenfuls
/// around wherever the user is looking, and a cursor held as a row would stop
/// at its end or, worse, keep counting into cells belonging to different
/// photographs. Moving out of the window reloads it around the new position,
/// which is the same thing scrolling does.
///
/// The grid supplies the step, because how far "down" is depends on how many
/// columns the window happens to be showing, and only the grid knows that. It
/// does not clamp: `Home` and `End` arrive as a step longer than the library
/// and are clamped here, where the total is known.
fn move_cursor(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll: &Rc<crate::collections_ui::CollectionsController>,
delta: i32,
extend: bool,
) {
let total = window.global::<Library>().get_library_total().max(0) as usize;
if total == 0 {
return;
}
let Some(from) = coll.cursor() else {
// The first press takes hold of the grid rather than moving in it. A
// key that jumped to image zero would lose wherever the user had
// scrolled to, and one that started from a cell off the top of the
// screen would appear to do nothing but scroll.
//
// The first *visible* ordinal, not the window's start: the loaded
// window deliberately begins a quarter of a screen above the view, so
// its first cell is one the user cannot see.
let at = ctl.resume_at.get().min(total - 1);
place_cursor(window, ctl, coll, at, false);
return;
};
// Saturating in `isize`, so a `Home` expressed as minus the library's
// length does not wrap round to the end.
let next = (from as isize)
.saturating_add(delta as isize)
.clamp(0, total as isize - 1) as usize;
if next == from {
// Already at the end being pressed toward. Nothing to move, and
// reloading the window would be a visible jerk for no movement.
return;
}
place_cursor(window, ctl, coll, next, extend);
}
/// Put the cursor on one image, bringing the window with it.
fn place_cursor(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll: &Rc<crate::collections_ui::CollectionsController>,
at: usize,
extend: bool,
) {
// Bring the window to the cursor if it has walked out of it, which is the
// same thing scrolling does.
let Some(row) = bring_window_to(window, ctl, at) else {
// The window could not be brought to the cursor — an empty or
// shrinking library. Leaving the cursor where it was is better than
// pointing it at nothing.
return;
};
let offset = *ctl.offset.borrow();
let ids = ctl.visible_ids();
if !extend {
// An arrow key collapses the selection onto the cursor. A *click* on
// an already-selected cell deliberately leaves the selection alone —
// that exception exists so a multi-image drag can start from one of
// its members — and there is no drag behind a keystroke.
coll.clear_selection();
}
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
window.global::<Library>().set_library_cursor(at as i32);
}
/// Where a step of `delta` along the roll lands, as a library ordinal, from
/// the photograph open at ordinal `from`; `None` for a step that goes nowhere.
///
/// Clamped at both ends, so holding a key at the last frame stays there rather
/// than reopening it on every repeat. `left` is
/// [`LibraryController::roll_left`]: the open photograph has dropped out of the
/// grid and the next one has moved up into its ordinal, so a step forward
/// lands *on* `from` — the photograph that now follows — and not past it.
fn roll_target(from: usize, delta: i32, total: usize, left: bool) -> Option<usize> {
if total == 0 {
return None;
}
let step = if left && delta > 0 { delta - 1 } else { delta };
let next = (from as isize)
.saturating_add(step as isize)
.clamp(0, total as isize - 1) as usize;
(next != from || left).then_some(next)
}
/// Step along the roll by library ordinal, bringing the window with it.
///
/// **By ordinal, as [`move_cursor`] walks the grid, and not by row.** The keys
/// used to pick the roll's row either side of the marked one. The roll is the
/// loaded window, so a row past its end picked nothing and holding D stopped
/// dead at the edge of whatever was loaded — a few screenfuls into a library
/// of thousands. Stepping from the open photograph's ordinal, and loading the
/// window around the next one when it lies outside, walks the whole library in
/// grid order, one photograph per press, and reads the catalog only when the
/// window has to move.
fn roll_step(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32, on_open: &OpenImage) {
let total = window.global::<Library>().get_library_total().max(0) as usize;
let from = window.get_index().max(0) as usize;
let Some(to) = roll_target(from, delta, total, ctl.roll_left.get()) else {
return;
};
if let Some(row) = bring_window_to(window, ctl, to) {
open_from_roll(window, ctl, row, on_open);
}
}
/// Open the photograph at `row` of the loaded window without leaving develop:
/// a pick from the roll, or a step along it.
///
/// `open_from_library` persists the outgoing edit before it loads the next
/// one, which is what makes this safe to fire as fast as a key repeats.
fn open_from_roll(
window: &AppWindow,
ctl: &Rc<LibraryController>,
row: usize,
on_open: &OpenImage,
) {
let Some(path) = ctl.paths.borrow().get(row).cloned() else {
return;
};
mark_open(window, ctl, row);
on_open(path);
report_position(window, ctl, row);
// TRACES: FR-UI-8
// Debounced, unlike the two entries into develop: the roll is walked
// frame by frame and every step is a different photograph.
note_place(window, ctl);
}
/// Say where in the library the photograph now open sits.
///
/// **After `on_open_image`, never before.** The generic open path resets the
/// readout to "1 of 1" on its way in, because until the photo roll existed the
/// grid really did hand develop a single path with nothing to walk to. It has
/// a window of them now, so the honest answer is the ordinal in the library —
/// not the row in the loaded window, which is an artefact of how much has been
/// paged in and would jump about as the window moves.
fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize) {
let offset = *ctl.offset.borrow();
window.set_index((offset + row) as i32);
window.set_total(window.global::<Library>().get_library_total());
}
/// Whether scrollers show the touch cue rather than the desktop bar.
///
/// A touch-first build always does. A desktop build does when
/// `DR_SCROLL_CUE` is set to anything but empty or `0` — not a setting, but
/// the way to look at the tablet's cue with a mouse: a developer checking it,
/// or a screenshot of it, without an APK and a device.
fn scroll_cue(touch_first: bool, env: Option<std::ffi::OsString>) -> bool {
touch_first || env.is_some_and(|v| !v.is_empty() && v != "0")
}
/// Connect the grid's callbacks.
pub fn wire<F>(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
on_open_image: F,
on_leave_develop: Rc<dyn Fn()>,
) where
F: Fn(String) + 'static,
{
// So a rebuilt window can put the selection ticks back. Weak, or the two
// controllers would hold each other alive for the life of the process.
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
// TRACES: FR-CAT-13
// The reload the settings page offers when a sidecar disagrees.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_settings_xmp_reload(move || {
if let Some(w) = weak.upgrade() {
start_xmp_reload(&w, &ctl);
}
});
}
// TRACES: FR-UI-3 | FR-UI-4
// Whether the rating strip waits to be hovered or stands open.
//
// On Android touch is not evidence to be gathered, it is the platform.
// The grid latches it from the first finger it sees as well, which is what
// covers a touchscreen on the desktop — but that latch needs a press to
// reach a cell, and a quick flick never delivers one because the Flickable
// claims the gesture before the delay it would forward after. Seeding it
// here means the stars are on screen before the first touch rather than
// after it, which is the whole point of showing them.
window
.global::<Library>()
.set_library_touched(cfg!(target_os = "android"));
// TRACES: FR-UI-1 | FR-UI-2
// Scrollbars for a pointer, the thin cue for a finger: the same question,
// asked of the same function, that puts the develop groups in the rail.
// Set here, once, because every scroller that draws a bar reads it and
// none of them can change it.
let cue = scroll_cue(dr_plat::is_touch_first(), std::env::var_os("DR_SCROLL_CUE"));
let scrolling = window.global::<crate::Scrolling>();
scrolling.set_bars(!cue);
scrolling.set_cue(cue);
// TRACES: FR-UI-4
// The gesture reference. Pushed once, here, rather than on demand: the
// table is a compiled-in constant, so there is nothing to be fresh about
// and nothing to recompute — a callback to fill it would only be a way for
// it to be empty the first time the sheet opens.
window
.global::<Library>()
.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
crate::gestures::rows()
.into_iter()
.map(|r| GestureRow {
heading: r.heading.into(),
title: r.title.into(),
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
manual: r.manual.into(),
})
.collect::<Vec<_>>(),
)));
// TRACES: FR-UI-4
// The manual, from the help sheet and from Settings. Opened on this
// thread: all it does is write a one-line page and start a process (or,
// on Android, an activity), which is quicker than handing it to a worker.
// A failure — in practice, a build with no manual installed — goes to the
// status line, since the button the user pressed otherwise did nothing.
{
let weak = window.as_weak();
window
.global::<Library>()
.on_library_open_manual(move |anchor| {
if let Err(e) = crate::manual::open(&anchor) {
log::warn!("manual: {e}");
if let Some(w) = weak.upgrade() {
w.global::<Library>().set_library_status(e.into());
}
}
});
}
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
let on_open_image: OpenImage = Rc::new(on_open_image);
// TRACES: FR-UI-8
// And a third caller, which is not a callback at all: a place recorded in
// develop reopens it at launch. Through the same closure, so the edit-saving
// and identity bookkeeping it does are not something a restore can skip.
*ctl.open_image.borrow_mut() = Some(on_open_image.clone());
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_click = coll_ctl.clone();
let on_open_image = on_open_image.clone();
window
.global::<Library>()
.on_library_cell_clicked(move |i| {
let Some(w) = weak.upgrade() else { return };
// The press stayed put, so it was a tap and not a drag: whatever it
// held back can be applied now. See `collections_ui::Press`.
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
// A ctrl- or shift-click is a selection gesture. Opening the image
// too would throw the user out of the grid mid-selection.
if coll_for_click.press_was_modified() {
return;
}
let path = ctl.paths.borrow().get(i as usize).cloned();
if let Some(path) = path {
// Leave the grid for the develop view. The status bar's
// "‹ Library" button comes back here.
w.set_active_view(View::Develop);
// Which cell the develop view is now showing, so the photo
// roll opens marking it rather than marking nothing.
mark_open(&w, &ctl, i as usize);
// A develop session begins here, so the roll centres on this
// photograph the first time it settles rather than merely
// scrolling it into view at one edge. Raised here and not in
// `on_library_roll_pick`, which is a step *within* a session —
// see `PhotoRoll::centre-request`.
w.global::<Library>().set_library_roll_centre(true);
on_open_image(path);
report_position(&w, &ctl, i as usize);
// TRACES: FR-UI-8
// Written now rather than on a settle: which view you are in is
// the field a debounce is most likely to lose, and quitting
// straight from develop is exactly the case worth getting right.
write_place(&w, &ctl);
}
});
}
// TRACES: FR-UI-4
// A photograph chosen from the photo roll.
//
// The roll draws the loaded window, so what it reports is a row of it —
// the same row-to-path lookup a cell click does, without the selection
// rules: the roll is a way of moving between photographs, not of building
// a set, so there is no modified press to honour and no reason to leave
// develop.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let on_open_image = on_open_image.clone();
window.global::<Library>().on_library_roll_pick(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Ok(row) = usize::try_from(i) {
open_from_roll(&w, &ctl, row, &on_open_image);
}
});
}
// TRACES: FR-UI-5
// One photograph along, from the keyboard in develop.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let on_open_image = on_open_image.clone();
window
.global::<Library>()
.on_library_roll_step(move |delta| {
let Some(w) = weak.upgrade() else { return };
roll_step(&w, &ctl, delta, &on_open_image);
});
}
wire_grid_cursor_and_zoom(window, &ctl, &coll_ctl, &on_open_image);
wire_grid_sync_and_load(window, &ctl);
wire_timeline(window, &ctl);
wire_grid_routes(window, &ctl, &coll_ctl, on_leave_develop);
wire_ratings_and_flags(window, &ctl, &coll_ctl);
wire_keywords(window, &ctl, &coll_ctl);
wire_filter_ratings_and_people(window, &ctl);
wire_filter_dates(window, &ctl);
wire_filter_scope_and_offline(window, &ctl, &coll_ctl);
// Last, and in its own module: the answers to a damaged catalog have
// nothing to do with the library view except that they run before it
// exists.
crate::recovery_ui::wire(window, &ctl, &coll_ctl);
}
/// --- the keyboard cursor (FR-CULL-4) -----------------------------------
///
/// Walking the grid with the arrows, and opening with `Return`. Together
/// with the judgement keys already bound in the grid, this is what makes a
/// culling pass a keyboard job: move, rate, move, open the doubtful one,
/// come back. A cull is thousands of decisions, and reaching for the mouse
/// between each of them is the difference between an hour and an evening.
fn wire_grid_cursor_and_zoom(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
on_open_image: &OpenImage,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window
.global::<Library>()
.on_library_move_cursor(move |delta, extend| {
let Some(w) = weak.upgrade() else { return };
move_cursor(&w, &ctl, &coll, delta, extend);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
let on_open = on_open_image.clone();
window.global::<Library>().on_library_open_cursor(move || {
let Some(w) = weak.upgrade() else { return };
// The cursor is a library ordinal and `paths` is the loaded
// window, so the row is the difference. A cursor outside the
// window cannot happen — moving it loads the window around it —
// but a shrinking library could leave one behind, and opening the
// wrong photograph is worse than opening none.
let Some(cursor) = coll.cursor() else { return };
let offset = *ctl.offset.borrow();
let row = cursor.checked_sub(offset);
let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned());
if let Some(path) = path {
w.set_active_view(View::Develop);
// As on a click: the roll marks what is open, and centres on it
// because this too begins a session.
mark_open(&w, &ctl, row.unwrap_or(0));
w.global::<Library>().set_library_roll_centre(true);
on_open(path);
report_position(&w, &ctl, row.unwrap_or(0));
write_place(&w, &ctl);
}
});
}
// Ctrl+wheel or pinch over the grid resizes the cells.
//
// Geometric steps rather than fixed pixels: the same gesture should feel
// the same at 90px and at 400px, and a linear step is imperceptible at one
// end and violent at the other.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_zoom_cells(move |delta| {
let Some(w) = weak.upgrade() else { return };
let current = w.global::<Library>().get_library_cell_size();
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
}
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.global::<Library>().set_library_cell_size(next);
// No need to forget anything on a class change: the class is part
// of the request key, so cells that now want the large resolution
// simply miss and ask for it, while the 256px ones they already
// hold stay served.
//
// Deferred, like every other geometry change. This one was still
// reloading inline — a full catalog re-read and model rebuild per
// step, which is what a wheel spun through six steps paid six
// times over.
schedule_reload(&w, &ctl);
});
}
// TRACES: FR-UI-4
// A pinch, which is continuous where the wheel is stepped.
//
// Given the ratio since the last update rather than a direction, so the
// grid tracks the fingers instead of jumping a fixed 25% per threshold
// crossing. What the user is setting is the size class — how big they want
// a thumbnail to be — and the drawn cell follows from it by dividing the
// width, so the visible result still lands on whole column counts. Feeding
// a continuous value in is what decides *when* it crosses.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_pinch_cells(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(ratio.is_finite() && ratio > 0.0) {
return;
}
let current = w.global::<Library>().get_library_cell_size();
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.global::<Library>().set_library_cell_size(next);
schedule_reload(&w, &ctl);
});
}
// TRACES: FR-UI-4
// A pinch has begun, so the press that started it was not a press.
//
// A pinch opens as one finger on a cell — which selects it — and only
// becomes a pinch when the second lands. Without this the user is left
// holding a selection they never made, on a photograph they were only
// reaching past.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window
.global::<Library>()
.on_library_pinch_started(move || {
let Some(w) = weak.upgrade() else { return };
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
});
}
}
/// Reload triggers for the grid: an explicit sync or thumbnail sweep, and
/// the geometry and scroll changes that move which rows are loaded.
fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Explicit sync, for when the user wants the exchange now rather than
// after the next sweep.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.global::<Library>().on_library_sync_now(move || {
if let Some(w) = weak.upgrade() {
start_derived_sync(&w, &ctl);
}
});
}
// TRACES: FR-CAT-3 | FR-NC-3
// Thumbnail the whole library, from the settings page. It ends in a sync
// of its own, so this is the long version of the button above.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_thumbnail_all(move || {
if let Some(w) = weak.upgrade() {
start_thumbnail_sweep(&w, &ctl);
}
});
}
// A column-count change moves which cells begin a row, and month headings
// sit on row-leading cells.
//
// Guarded like the scroll below, and for the same reason: a grid being
// taken down reports its geometry collapsing on the way out, and reloading
// the window against that is work done for a page nobody is looking at.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_columns_changed(move || {
if let Some(w) = weak.upgrade() {
if !w.get_library_visible() {
return;
}
// Deferred, and re-anchored when it lands — see
// [`schedule_reload`]. A column change moves every cell in the
// grid, because a cell is drawn at its absolute place in the
// library and the row that resolves to is `index / columns`.
// The viewport does not move with them, so without the
// re-anchor the view is left pointing at rows the loaded window
// no longer covers and the grid draws nothing at all — the
// "gallery randomly goes blank until I scroll" report, whose
// triggers are a resize, the sidebar opening, a zoom step, or
// turning the tablet over.
schedule_reload(&w, &ctl);
}
});
}
// The viewport changed size, so the window it can usefully hold changed
// with it. Reloading only on growth would leave a maximised-then-restored
// window over-fetching, so both directions are honoured.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_viewport_cells(move |on_screen| {
let Some(w) = weak.upgrade() else { return };
if !w.get_library_visible() {
return;
}
let on_screen = (on_screen.max(0) as usize).max(1);
let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW);
if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() {
return;
}
ctl.viewport_cells.set(on_screen);
*ctl.window.borrow_mut() = window_size;
// Coalesced with the column change that almost always accompanies
// it: resizing the cells alters both, and reloading once per report
// meant two full rebuilds per zoom step.
schedule_reload(&w, &ctl);
});
}
// Scrolling moves the loaded window through the library.
//
// The whole catalog is reachable because the Flickable's viewport is sized
// to it; this keeps the 120 loaded rows centred on wherever the view is.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_scrolled(move |first_visible| {
let Some(w) = weak.upgrade() else { return };
let first_visible = first_visible.max(0) as usize;
// **A report from a grid that is not on screen is not a scroll.**
//
// The grid is gated on an `if`, so leaving it tears the whole
// subtree down — and a Flickable being destroyed passes its viewport
// through zero on the way out, which arrives here indistinguishable
// from the user having flung the grid to the top. Everything below
// then ran on the way *into* develop: the loaded window was reset to
// offset zero, the model was rebuilt against the first rows of the
// library, and a thumbnail batch was issued for photographs nobody
// had asked to see. Those rebuilds landed while the grid was still
// being taken apart, which is what flashed the library over the
// develop view for the first few frames after a click — and on a
// remote library it also spent a burst of requests on the top of the
// catalog every single time an image was opened.
//
// **`library-visible`, not `show-library`, and the difference is the
// whole bug.** `show-library` says "the library rather than develop"
// and stays true while Settings, Import, People or the launch screen
// replaces the window — all four of which take the grid down just as
// opening an image does. So the teardown's scroll-to-zero passed this
// guard, `resume_at` was set to 0, and coming back from any of those
// four screens landed at the top of the library however deep in it the
// user had been. The property is computed once in `app.slint` beside
// the `if` it is spelled from, so the two cannot drift apart again.
//
// The guard used to cover only `resume_at`, for a narrower version
// of the same reason. It belongs over the whole handler.
if !w.get_library_visible() {
return;
}
// Remember where the view is, so leaving for the develop view and
// coming back returns here. Latched on every event rather than read
// at departure: by the time the grid is hidden its scroll position
// is only in the Flickable, which is about to be destroyed.
ctl.resume_at.set(first_visible);
// TRACES: FR-UI-8
// And where the *next launch* will look for it. Debounced: a flick
// reports several of these per screenful.
note_place(&w, &ctl);
// The same position where a rebuilt grid will look for it.
//
// `scroll-to` is read by `seek()`, which runs on a `scroll-token`
// change and on `init` — so writing it here without bumping the
// token cannot move the grid that is on screen, and *is* what the
// next one reads when it is built. That is every route back to the
// grid at once: Settings, Import, People and the launch screen all
// tear the subtree down and rebuild it, and none of them goes
// through `on_back_to_library` to have the position replayed by
// hand. Without this they each rebuilt against whatever `scroll-to`
// was last *set* to — a stale scrub, or zero — and landed there.
w.global::<Library>()
.set_library_scroll_to(first_visible as i32);
// Move the timeline marker with the view. Scrolling the grid is a
// way of moving through time just as scrubbing is, and a marker
// that only ever moved on a scrub sat still while the photographs
// beside it advanced by months — the axis said "when you are" and
// was wrong the moment the user touched the wheel.
//
// This runs before the reload guard below, which fires only a few
// times per screenful; the marker has to follow every event or it
// would advance in visible jerks.
//
// Only the marker is moved, not the whole histogram: rebuilding
// the bars means a `GROUP BY strftime` aggregate over the library,
// far too much for every event of a flick. The bars do not change
// as the grid scrolls anyway — only where the marker sits on them.
//
// Re-running the scrub would be wrong for a second reason: it sets
// `scroll-to`, which would drive the grid from its own scroll.
{
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
if let Some(when) = capture_time_at(&ctl, catalog, first_visible) {
*ctl.current_bucket.borrow_mut() = Some(when);
let zoom = *ctl.timeline_zoom.borrow();
let centre = *ctl.timeline_centre.borrow();
if let Some(full) = catalog_span(catalog, &ctl) {
let (from, to) = zoomed_span(full, zoom, centre);
w.global::<Library>()
.set_library_current_bucket(when as i32);
w.global::<Library>().set_library_current_fraction(
((when - from) as f64 / (to - from).max(1) as f64)
.clamp(0.0, 1.0) as f32,
);
w.global::<Library>().set_library_timeline_anchored(true);
}
}
}
}
// Centre the window on the view, so scrolling either way has
// loaded rows ahead of it rather than only below.
let Some(offset) = window_move(
first_visible,
*ctl.offset.borrow(),
*ctl.window.borrow(),
ctl.viewport_cells.get(),
w.global::<Library>().get_library_total().max(0) as usize,
) else {
return;
};
*ctl.offset.borrow_mut() = offset;
load_window(&w, &ctl);
});
}
}
/// Moving between the grid and the screens around it: the launch screen,
/// develop, and a manual rescan.
fn wire_grid_routes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
on_leave_develop: Rc<dyn Fn()>,
) {
// Grid → launch screen. The route that was missing: once past the launch
// screen there was no way back to it, so a library pointed at the wrong
// folder could not be changed without clearing stored state by hand.
{
let weak = window.as_weak();
window.global::<Library>().on_library_change(move || {
if let Some(w) = weak.upgrade() {
w.set_active_view(View::Launch);
}
});
}
// Develop → grid.
//
// Returns to where the user left, on the photograph they were editing,
// rather than to the top. The grid is gated on an `if` in the markup, so it
// is rebuilt from nothing and its Flickable starts at row 0; the position
// has to be replayed explicitly. See [`resume_position`] for which of the
// two positions wins when they disagree.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_ctl = coll_ctl.clone();
window.on_back_to_library(move || {
let Some(w) = weak.upgrade() else { return };
// TRACES: FR-CAT-8
// The edit is persisted on the way out rather than on every
// slider move: a save is a network round-trip, and one per drag
// frame would put an upload inside the gesture NFR-P5 governs.
// This is the moment the image stops being the open one, so it is
// the last moment its edit can be written.
on_leave_develop();
// Where the open photograph sits in the library, which is exactly
// what `report_position` keeps `index` holding — including after
// every step along the photo roll, which is the case that makes
// this differ from `resume_at`.
let open = w.get_index().max(0) as usize;
resume_position(&w, &ctl, &coll_ctl, Some(open));
w.set_active_view(View::Library);
// TRACES: FR-UI-8
// After the view is set, not before: `current_place` reads it to
// say which view the record is of, and this is the moment it
// becomes the grid.
write_place(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_ctl = coll_ctl.clone();
window.global::<Library>().on_library_rescan(move || {
let Some(w) = weak.upgrade() else { return };
start_rescan(&w, &ctl, &coll_ctl);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library_ui::window::{locate_open, window_for, window_start};
#[test]
fn the_cue_is_the_tablets_and_a_desktop_opts_in() {
assert!(scroll_cue(true, None), "a touch-first build always cues");
assert!(!scroll_cue(false, None), "a desktop keeps its bars");
assert!(scroll_cue(false, Some("1".into())));
assert!(!scroll_cue(false, Some("0".into())), "0 is off");
assert!(!scroll_cue(false, Some("".into())), "empty is off");
}
/// A library larger than one loaded window, and the window over it as
/// `load_window` leaves it: offset clamped so the window stays full, and
/// holding the photographs from there on. A photograph's id is its
/// ordinal plus a thousand, so a row can be checked for *which* one it is.
struct Roll {
total: usize,
size: usize,
offset: usize,
}
impl Roll {
fn id(ordinal: usize) -> i64 {
ordinal as i64 + 1000
}
fn ids(&self) -> Vec<i64> {
let end = (self.offset + self.size).min(self.total);
(self.offset..end).map(Self::id).collect()
}
fn load(&mut self, offset: usize) {
self.offset = offset.min(self.total.saturating_sub(self.size.min(self.total)));
}
/// What `roll_step` does: the target ordinal, the window brought to
/// it, and the photograph at the resulting row. `None` for no step.
fn step(&mut self, from: usize, delta: i32, left: bool) -> Option<(usize, i64)> {
let to = roll_target(from, delta, self.total, left)?;
if let Some(o) = window_for(to, self.offset, self.ids().len(), self.size, self.total) {
self.load(o);
}
let row = to
.checked_sub(self.offset)
.filter(|r| *r < self.ids().len())?;
Some((self.offset + row, self.ids()[row]))
}
}
/// Hold D from the first frame: every photograph, once each, in grid
/// order, across every window boundary — and then nothing, at the end.
#[test]
fn stepping_forward_crosses_window_boundaries_one_photograph_per_press() {
let mut lib = Roll {
total: 300,
size: 96,
offset: 0,
};
let mut at = 0;
let mut opened = vec![Roll::id(0)];
let mut moves = 0;
for _ in 0..400 {
let before = lib.offset;
let Some((next, id)) = lib.step(at, 1, false) else {
break;
};
moves += usize::from(lib.offset != before);
assert_eq!(next, at + 1, "one photograph per press");
at = next;
opened.push(id);
}
let expected: Vec<i64> = (0..300).map(Roll::id).collect();
assert_eq!(opened, expected);
assert!(
moves >= 2,
"the walk must have crossed window boundaries ({moves})"
);
assert_eq!(lib.step(at, 1, false), None, "the last frame stays put");
}
/// The same backwards with A, from the last frame, starting from a window
/// placed at the end of the library as a scrub there would place it.
#[test]
fn stepping_backward_crosses_window_boundaries_one_photograph_per_press() {
let mut lib = Roll {
total: 300,
size: 96,
offset: 0,
};
lib.load(window_start(299, 96, 300));
let mut at = 299;
let mut opened = vec![Roll::id(299)];
for _ in 0..400 {
let Some((next, id)) = lib.step(at, -1, false) else {
break;
};
assert_eq!(next + 1, at, "one photograph per press");
at = next;
opened.push(id);
}
let expected: Vec<i64> = (0..300).rev().map(Roll::id).collect();
assert_eq!(opened, expected);
assert_eq!(lib.offset, 0);
}
/// The mark follows the photograph, not the row: after a step that moves
/// the window, the row the open photograph had is someone else's, and
/// the reload has to find it again.
#[test]
fn the_roll_mark_follows_the_open_photograph_across_a_reload() {
let mut lib = Roll {
total: 300,
size: 96,
offset: 0,
};
let open = Roll::id(95);
assert_eq!(locate_open(open, &lib.ids(), lib.offset, 95), Ok(95));
lib.load(window_start(96, 96, 300));
assert_ne!(
lib.ids()[95],
open,
"the old row now holds another photograph"
);
let row = locate_open(open, &lib.ids(), lib.offset, 95).unwrap();
assert_eq!(lib.ids()[row], open);
assert_eq!(lib.offset + row, 95);
// Scrolled well away: not here, and not gone either.
lib.load(200);
assert_eq!(locate_open(open, &lib.ids(), lib.offset, 95), Err(false));
}
/// A judgement under a filter drops the open photograph out of the grid,
/// and the next one moves up into its ordinal. Stepping forward opens that
/// one; stepping back opens the one before, as it always would have.
#[test]
fn a_photograph_that_left_the_grid_steps_onto_its_successor() {
let ids: Vec<i64> = (0..10).filter(|i| *i != 5).map(|i| i + 1000).collect();
assert_eq!(locate_open(1005, &ids, 0, 5), Err(true));
assert_eq!(roll_target(5, 1, 9, true), Some(5));
assert_eq!(roll_target(5, -1, 9, true), Some(4));
// The last frame left: forward lands on the new last one.
assert_eq!(roll_target(9, 1, 9, true), Some(8));
assert_eq!(roll_target(5, 1, 9, false), Some(6));
assert_eq!(roll_target(0, -1, 9, false), None);
assert_eq!(roll_target(0, 1, 0, false), None);
}
/// One zoom step, as the handler applies it.
fn zoom_cell(current: f32, delta: i32) -> f32 {
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
};
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
}
#[test]
fn cell_zoom_steps_geometrically_and_reverses() {
// Geometric so the gesture feels the same at either end; a fixed pixel
// step is imperceptible at 400px and violent at 90px.
let a = zoom_cell(180.0, 1);
assert!((a - 225.0).abs() < 0.01);
assert!(
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
"in then out returns"
);
}
#[test]
fn cell_zoom_stays_within_its_bounds() {
let mut size = 180.0;
for _ in 0..40 {
size = zoom_cell(size, 1);
}
assert_eq!(size, MAX_CELL_SIZE);
for _ in 0..40 {
size = zoom_cell(size, -1);
}
assert_eq!(size, MIN_CELL_SIZE);
}
#[test]
fn zooming_past_the_grid_class_asks_for_the_large_one() {
use dr_thumbs::ThumbSize;
// The point of the second class: past 256px a grid thumbnail is being
// upscaled, and the softness shows.
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
assert_eq!(
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
ThumbSize::Large
);
// And zooming back down does not keep paying for it.
assert_eq!(
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
ThumbSize::Grid
);
}
}