The scan/opening/status/error lines, offline mode and its retry, pinning a collection offline, the sync and thumbnail-sweep state, and the callbacks that route the grid to a rescan, a sync, another library, a panorama merge or an export — the last of what AppWindow still carried under the library- prefix — move onto the `Library` global started earlier on this branch. Rust reaches them through window.global::<Library>() rather than window.set_/get_/on_/invoke_ on the root. library-visible is the one name that stays: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. AppWindow now declares no other library- property or callback.
784 lines
34 KiB
Rust
784 lines
34 KiB
Rust
//! 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, Model as _};
|
||
|
||
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::{load_window, window_move, window_start};
|
||
|
||
/// 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. Centred a
|
||
// quarter in, exactly as a scroll does it, so continuing in the same
|
||
// direction has loaded cells to move into rather than another reload on
|
||
// the very next press.
|
||
let loaded = window.global::<Library>().get_library_cells().row_count();
|
||
let offset = *ctl.offset.borrow();
|
||
if at < offset || at >= offset + loaded {
|
||
let size = *ctl.window.borrow();
|
||
let total = window.global::<Library>().get_library_total().max(0) as usize;
|
||
*ctl.offset.borrow_mut() = window_start(at, size, total);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
// Re-read: `load_window` clamps the offset against the library's end, so
|
||
// the window may not start where it was asked to.
|
||
let offset = *ctl.offset.borrow();
|
||
let ids = ctl.visible_ids();
|
||
let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) 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;
|
||
};
|
||
|
||
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);
|
||
}
|
||
|
||
/// 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());
|
||
}
|
||
|
||
/// 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-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(),
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
)));
|
||
|
||
// 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.
|
||
w.global::<Library>().set_library_roll_current(i);
|
||
// 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 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. `open_from_library` persists the outgoing edit before it loads
|
||
// the next one, which is what makes this safe to fire repeatedly.
|
||
{
|
||
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 };
|
||
let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else {
|
||
return;
|
||
};
|
||
w.global::<Library>().set_library_roll_current(i);
|
||
on_open_image(path);
|
||
report_position(&w, &ctl, i as usize);
|
||
// 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(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
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.
|
||
w.global::<Library>()
|
||
.set_library_roll_current(row.unwrap_or(0) as i32);
|
||
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::*;
|
||
|
||
/// 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
|
||
);
|
||
}
|
||
}
|