Keep the grid's place when another screen covers it

Opening Settings, Import or People and coming back landed at the top of
the library however deep in it you had been.

The grid is gated on an `if` in the markup, so every route away from it
destroys the subtree and rebuilds it. A Flickable being destroyed passes
its viewport through zero on the way out, and that reaches
`on_library_scrolled` looking exactly like the user having flung the grid
to the top. The handler already guarded against it -- but on
`show-library`, which means "the library rather than develop" and stays
true while any of those four screens replaces the window. So the guard
covered the develop route and none of the other three: `resume_at` was
overwritten with 0 on the way out, and the position was gone before
anything could restore it.

The condition the `if` is actually spelled with is now computed once, in
`app.slint`, and Rust reads that. The two cannot drift apart again
because there is only one of them.

That fixes the overwrite. The second half is that nothing replayed the
position on the way back in: `on_back_to_library` does it by hand, and
Settings, Import, People and the launch screen do not go through it.
Rather than teaching three more modules to call it, `scroll-to` is now
kept current on every scroll. It is read by `seek()`, which runs on a
token change and on `init`, so writing it without bumping the token
cannot move the grid on screen -- and is exactly what the next grid reads
when it is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:33:34 +02:00
co-authored by Claude Opus 5
parent 4dc954f01a
commit eed27eb36d
4 changed files with 128 additions and 88 deletions
+12 -12
View File
@@ -54,7 +54,7 @@ The right match confidence is a property of your library, not of the model. "Wha
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found. Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
<sub>`ui/dr-ui/ui/library.slint:1217`</sub> <sub>`ui/dr-ui/ui/library.slint:1292`</sub>
### Add or remove one photograph ### Add or remove one photograph
@@ -63,7 +63,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back. While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
<sub>`ui/dr-ui/ui/library.slint:1226`</sub> <sub>`ui/dr-ui/ui/library.slint:1301`</sub>
### Leave selecting ### Leave selecting
@@ -71,7 +71,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
- **Pointer** — Press Done in the header - **Pointer** — Press Done in the header
- **Keyboard** — Escape - **Keyboard** — Escape
<sub>`ui/dr-ui/ui/library.slint:1234`</sub> <sub>`ui/dr-ui/ui/library.slint:1309`</sub>
### Pick a photograph up to drag it ### Pick a photograph up to drag it
@@ -80,7 +80,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel. A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
<sub>`ui/dr-ui/ui/library.slint:1264`</sub> <sub>`ui/dr-ui/ui/library.slint:1339`</sub>
### Select a range ### Select a range
@@ -89,7 +89,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out. This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
<sub>`ui/dr-ui/ui/library.slint:1329`</sub> <sub>`ui/dr-ui/ui/library.slint:1404`</sub>
### Find photographs with two people in them ### Find photographs with two people in them
@@ -98,7 +98,7 @@ This replaced a double tap, which had no visible state and could take forty phot
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar. "Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
<sub>`ui/dr-ui/ui/library.slint:2011`</sub> <sub>`ui/dr-ui/ui/library.slint:2086`</sub>
### Resize the thumbnails ### Resize the thumbnails
@@ -107,7 +107,7 @@ This replaced a double tap, which had no visible state and could take forty phot
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach. There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
<sub>`ui/dr-ui/ui/library.slint:2664`</sub> <sub>`ui/dr-ui/ui/library.slint:2739`</sub>
### File photographs in a collection ### File photographs in a collection
@@ -116,7 +116,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture. The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:2827`</sub> <sub>`ui/dr-ui/ui/library.slint:2936`</sub>
### Open a photograph ### Open a photograph
@@ -125,7 +125,7 @@ The selection is what the drag carries, which is why selecting several is worth
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush. A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:3095`</sub> <sub>`ui/dr-ui/ui/library.slint:3204`</sub>
### Rate a photograph without opening it ### Rate a photograph without opening it
@@ -135,7 +135,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
A star has to take the press without it also reaching the cell, or every rating throws the user into develop. A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
<sub>`ui/dr-ui/ui/library.slint:3215`</sub> <sub>`ui/dr-ui/ui/library.slint:3324`</sub>
### Drop the selection but keep selecting ### Drop the selection but keep selecting
@@ -144,7 +144,7 @@ A star has to take the press without it also reaching the cell, or every rating
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away. Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
<sub>`ui/dr-ui/ui/library.slint:3879`</sub> <sub>`ui/dr-ui/ui/library.slint:4032`</sub>
### Select everything the grid is showing ### Select everything the grid is showing
@@ -153,4 +153,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it. A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:3896`</sub> <sub>`ui/dr-ui/ui/library.slint:4049`</sub>
+69 -68
View File
File diff suppressed because one or more lines are too long
+30 -7
View File
@@ -2071,7 +2071,7 @@ fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || { timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let anchor = ctl_cb.pending_anchor.take().unwrap_or(0); let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
if !w.get_show_library() { if !w.get_library_visible() {
return; return;
} }
@@ -4131,7 +4131,7 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
// the grid asks the store once per photograph — so // the grid asks the store once per photograph — so
// without this the library the pass just thumbnailed // without this the library the pass just thumbnailed
// stays blank until something else reloads the window. // stays blank until something else reloads the window.
if w.get_show_library() { if w.get_library_visible() {
ctl_cb.requested.borrow_mut().clear(); ctl_cb.requested.borrow_mut().clear();
schedule_reload(&w, &ctl_cb); schedule_reload(&w, &ctl_cb);
} }
@@ -4159,7 +4159,7 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some(w) = weak_after.upgrade() else { let Some(w) = weak_after.upgrade() else {
return; return;
}; };
if bursts > 0 && w.get_show_library() { if bursts > 0 && w.get_library_visible() {
schedule_reload(&w, &ctl_after); schedule_reload(&w, &ctl_after);
} }
}, },
@@ -5042,7 +5042,7 @@ pub fn wire<F>(
let ctl = ctl.clone(); let ctl = ctl.clone();
window.on_library_columns_changed(move || { window.on_library_columns_changed(move || {
if let Some(w) = weak.upgrade() { if let Some(w) = weak.upgrade() {
if !w.get_show_library() { if !w.get_library_visible() {
return; return;
} }
@@ -5069,7 +5069,7 @@ pub fn wire<F>(
let ctl = ctl.clone(); let ctl = ctl.clone();
window.on_library_viewport_cells(move |on_screen| { window.on_library_viewport_cells(move |on_screen| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if !w.get_show_library() { if !w.get_library_visible() {
return; return;
} }
let on_screen = (on_screen.max(0) as usize).max(1); let on_screen = (on_screen.max(0) as usize).max(1);
@@ -5099,7 +5099,7 @@ pub fn wire<F>(
// **A report from a grid that is not on screen is not a scroll.** // **A report from a grid that is not on screen is not a scroll.**
// //
// `show-library` gates an `if`, so opening an image tears the whole // The grid is gated on an `if`, so leaving it tears the whole
// subtree down — and a Flickable being destroyed passes its viewport // subtree down — and a Flickable being destroyed passes its viewport
// through zero on the way out, which arrives here indistinguishable // through zero on the way out, which arrives here indistinguishable
// from the user having flung the grid to the top. Everything below // from the user having flung the grid to the top. Everything below
@@ -5112,9 +5112,19 @@ pub fn wire<F>(
// remote library it also spent a burst of requests on the top of the // remote library it also spent a burst of requests on the top of the
// catalog every single time an image was opened. // 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 // The guard used to cover only `resume_at`, for a narrower version
// of the same reason. It belongs over the whole handler. // of the same reason. It belongs over the whole handler.
if !w.get_show_library() { if !w.get_library_visible() {
return; return;
} }
@@ -5124,6 +5134,19 @@ pub fn wire<F>(
// is only in the Flickable, which is about to be destroyed. // is only in the Flickable, which is about to be destroyed.
ctl.resume_at.set(first_visible); ctl.resume_at.set(first_visible);
// 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.set_library_scroll_to(first_visible as i32);
// Move the timeline marker with the view. Scrolling the grid is a // Move the timeline marker with the view. Scrolling the grid is a
// way of moving through time just as scrubbing is, and a marker // way of moving through time just as scrubbing is, and a marker
// that only ever moved on a scrub sat still while the photographs // that only ever moved on a scrub sat still while the photographs
+17 -1
View File
@@ -270,6 +270,22 @@ export component AppWindow inherits Window {
// Shown after a library is opened, before an image is chosen. Cells are a // Shown after a library is opened, before an image is chosen. Cells are a
// window over the catalog, not the whole of it. // window over the catalog, not the whole of it.
in property <bool> show-library: false; in property <bool> show-library: false;
/// Whether the grid is actually on screen — the condition the `if` below
/// is written with, hoisted so Rust can read the same answer.
///
/// **`show-library` is not that answer.** It says "the library rather than
/// develop", and stays true while Settings, Import, People or the launch
/// screen replaces the window. The grid subtree is torn down in all four
/// cases, and a Flickable being destroyed passes its viewport through zero
/// on the way out — which reaches `on_library_scrolled` as a scroll to the
/// top of the library and overwrote the position the user was at. That is
/// the "I open Settings and come back to the beginning" report.
///
/// So the guard has to be this, and the `if` has to be spelled from it, or
/// the two can drift apart again.
out property <bool> library-visible:
!root.show-import && !root.show-settings && !root.show-identity
&& !root.show-launch && root.show-library;
in-out property <[LibraryCell]> library-cells; in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0; in property <int> library-total: 0;
// --- background activity (FR-CAT-1, FR-NC-6c) --- // --- background activity (FR-CAT-1, FR-NC-6c) ---
@@ -1457,7 +1473,7 @@ in property <bool> panel-visible: true;
// has been opened but no image chosen yet. The collections sidebar and the // has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid, // grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both. // because the drag that connects them has to be owned above both.
if !root.show-import && !root.show-settings && !root.show-identity && !root.show-launch && root.show-library: Rectangle { if root.library-visible: Rectangle {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: Theme.ground; background: Theme.ground;