Files
DarkRoom/ui/dr-ui/src/library_ui/grid.rs
T
dtourolle 402dcdc24c Move the library grid's cells and selection onto their own Slint global
AppWindow carried the grid's loaded window of cells, the keyboard cursor,
drag and drop, the held-row long-press state, columns and cell size, the
scroll and viewport bookkeeping, the photo roll's pick and centre-request,
and the local-only/reorder/collection-filing gestures that act on a
selection, as properties and callbacks on the root component. That state now
lives in the `Library` global declared in library.slint, next to the structs
(LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other
components already share; Rust reaches it through
window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the
root, the same change collections.slint's `Collections` global made for the
sidebar.

library-visible stays on AppWindow: it is computed from active-page and
active-view, the shell's own routing state, which a global cannot read.
Everything else still prefixed library- — the timeline, the filter bar,
ratings and flags, keywording, and the routes and status lines — stays on
the window for now and moves in the commits that follow.
2026-09-20 21:42:48 +02:00

779 lines
34 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, 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.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.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.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.set_library_current_bucket(when as i32);
w.set_library_current_fraction(
((when - from) as f64 / (to - from).max(1) as f64)
.clamp(0.0, 1.0) as f32,
);
w.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.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.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
);
}
}