//! 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, coll: &Rc, delta: i32, extend: bool, ) { let total = window.global::().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, coll: &Rc, 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::().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 { 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, delta: i32, on_open: &OpenImage) { let total = window.global::().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, 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, row: usize) { let offset = *ctl.offset.borrow(); window.set_index((offset + row) as i32); window.set_total(window.global::().get_library_total()); } /// Connect the grid's callbacks. pub fn wire( window: &AppWindow, ctl: Rc, coll_ctl: Rc, on_open_image: F, on_leave_develop: Rc, ) 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::() .set_library_touched(cfg!(target_os = "android")); // TRACES: FR-UI-1 // Scrollbars for a pointer, none 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. window .global::() .set_bars(!dr_plat::is_touch_first()); // 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::() .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::>(), ))); // 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::() .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::().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::() .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::().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::().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::() .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, coll_ctl: &Rc, on_open_image: &OpenImage, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let coll = coll_ctl.clone(); window .global::() .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::().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::().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::() .on_library_zoom_cells(move |delta| { let Some(w) = weak.upgrade() else { return }; let current = w.global::().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::().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::() .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::().get_library_cell_size(); let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE); if (next - current).abs() < 0.5 { return; } w.global::().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::() .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) { // 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::().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::() .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::() .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::() .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::() .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::() .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::() .set_library_current_bucket(when as i32); w.global::().set_library_current_fraction( ((when - from) as f64 / (to - from).max(1) as f64) .clamp(0.0, 1.0) as f32, ); w.global::().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::().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, coll_ctl: &Rc, on_leave_develop: Rc, ) { // 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::().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::().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}; /// 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 { 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 = (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 = (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 = (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 ); } }