Centre the photo roll on the frame it opens with

The roll brought the open photograph into view by the shortest move,
which is right for stepping along it and wrong for the first look: a
frame near either end of the loaded window arrived hard against an edge,
with nothing on that side to give it any context.

It now centres on the first settle of a develop session and steps
minimally after that. A one-shot request that the strip itself clears --
the only thing that knows the request has been honoured is the code
honouring it -- rather than something recomputed on creation, because the
strip is created far more often than a session begins: leaving develop
for Settings and coming back rebuilds it, and re-centring then would undo
a roll the user had scrolled by hand.

Raised on the two ways into develop from the grid, and not on a pick
along the roll, which is a step within a session rather than the start of
one.

Centring is clamped to the ends: the third photograph of a window cannot
be centred without scrolling empty space in beside it, and a strip that
begins with a gap reads as broken rather than as centred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:35:55 +02:00
co-authored by Claude Opus 5
parent 0ff1e01ec3
commit 8bf5e13faf
5 changed files with 175 additions and 83 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:1218`</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:1227`</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:1235`</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:1265`</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:1330`</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:2012`</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:2665`</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:2862`</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:3130`</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:3250`</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:3958`</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:3975`</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
+9 -1
View File
@@ -4980,6 +4980,12 @@ pub fn wire<F>(
// Which cell the develop view is now showing, so the photo // Which cell the develop view is now showing, so the photo
// roll opens marking it rather than marking nothing. // roll opens marking it rather than marking nothing.
w.set_library_roll_current(i); w.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.set_library_roll_centre(true);
on_open_image(path); on_open_image(path);
report_position(&w, &ctl, i as usize); report_position(&w, &ctl, i as usize);
} }
@@ -5044,8 +5050,10 @@ pub fn wire<F>(
let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned()); let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned());
if let Some(path) = path { if let Some(path) = path {
w.set_show_library(false); w.set_show_library(false);
// As on a click: the roll marks what is open. // As on a click: the roll marks what is open, and centres on it
// because this too begins a session.
w.set_library_roll_current(row.unwrap_or(0) as i32); w.set_library_roll_current(row.unwrap_or(0) as i32);
w.set_library_roll_centre(true);
on_open(path); on_open(path);
report_position(&w, &ctl, row.unwrap_or(0)); report_position(&w, &ctl, row.unwrap_or(0));
} }
+9
View File
@@ -511,6 +511,14 @@ export component AppWindow inherits Window {
/// Whether the photo roll is out. Owned here rather than by the roll, so /// Whether the photo roll is out. Owned here rather than by the roll, so
/// it survives the develop view being torn down and rebuilt. /// it survives the develop view being torn down and rebuilt.
in-out property <bool> roll-open: false; in-out property <bool> roll-open: false;
/// Centre the roll on the open photograph the next time the strip settles.
///
/// Raised by Rust when a develop session begins — a photograph opened from
/// the grid — and cleared by the strip itself once honoured. Owned here for
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
/// overlay screen covers it, and a request raised before that must survive
/// it. See `PhotoRoll::centre-request`.
in-out property <bool> library-roll-centre: false;
/// A photograph was chosen from the roll: the row within the loaded /// A photograph was chosen from the roll: the row within the loaded
/// window, which is what a cell click reports too. /// window, which is what a cell click reports too.
callback library-roll-pick(int); callback library-roll-pick(int);
@@ -2235,6 +2243,7 @@ in property <bool> panel-visible: true;
cells: root.library-cells; cells: root.library-cells;
current: root.library-roll-current; current: root.library-roll-current;
open <=> root.roll-open; open <=> root.roll-open;
centre-request <=> root.library-roll-centre;
pick(i) => { root.library-roll-pick(i); } pick(i) => { root.library-roll-pick(i); }
} }
+76 -2
View File
@@ -511,6 +511,24 @@ export component PhotoRoll inherits Rectangle {
in property <int> current: -1; in property <int> current: -1;
in-out property <bool> open: false; in-out property <bool> open: false;
/// Centre the open photograph the next time the strip settles, instead of
/// merely bringing it into view.
///
/// **A one-shot request, cleared here rather than by Rust**, because the
/// only thing that knows the request has been honoured is the code that
/// honours it. Rust raises it when a photograph is opened *from the grid* —
/// the start of a develop session — and from then on every pick along the
/// roll uses `reveal()`, which moves as little as will show the mark. That
/// is the difference the user asked for: arriving at a photograph should
/// show what is either side of it, and stepping along from there should not
/// keep yanking the strip back to centre.
///
/// It has to be a flag the strip consumes rather than something recomputed
/// on creation, because the strip is created more often than a session
/// begins: leaving develop for Settings and coming back rebuilds it, and
/// re-centring then would undo a roll the user had scrolled by hand.
in-out property <bool> centre-request: false;
/// A thumbnail was chosen. The row within the loaded window, matching what /// A thumbnail was chosen. The row within the loaded window, matching what
/// a cell click reports. /// a cell click reports.
callback pick(int); callback pick(int);
@@ -613,13 +631,69 @@ export component PhotoRoll inherits Rectangle {
} }
} }
// Put the open photograph in the middle of the strip, with as
// much of the roll either side of it as there is.
//
// Clamped to the ends rather than centred unconditionally: the
// third photograph of a window cannot be centred without
// scrolling empty space in on the left, and a strip that starts
// with a gap reads as broken rather than as centred.
function centre() {
if (root.current < 0) {
return;
}
let left = root.pad + root.current * (root.thumb + root.pad);
let right = max(0px, self.viewport-width - self.width);
let want = left + root.thumb / 2 - self.width / 2;
self.viewport-x = -min(right, max(0px, want));
}
// Which of the two the strip does, and the only place the
// request is cleared.
//
// **Nothing happens before the strip has a width.** Centring
// divides by it, and `init` runs before layout — so honouring
// the request there would scroll to an arithmetic answer based
// on a width of zero *and* consume the flag, leaving the roll
// wrong with nothing left to correct it. Deferring instead is
// safe because the width arriving is itself a change, and
// `changed width` below settles again once it has.
function settle() {
if (self.width <= 0px) {
return;
}
if (root.centre-request) {
root.centre-request = false;
self.centre();
} else {
self.reveal();
}
}
property <int> mark: root.current; property <int> mark: root.current;
changed mark => { self.reveal(); } changed mark => { self.settle(); }
property <bool> shown: root.open; property <bool> shown: root.open;
changed shown => { changed shown => {
if (self.shown) { self.reveal(); } if (self.shown) { self.settle(); }
} }
// On creation, and again when the strip is first given a
// width. None of the four is redundant.
//
// The strip is rebuilt on every entry to develop, so `mark` and
// `shown` are *initialised* rather than changed and neither
// handler above fires — which is precisely the moment a session
// begins and the request is waiting to be honoured. A roll left
// open from the previous session would otherwise slide in
// showing whatever it showed last.
//
// `init` runs before layout, though, so it usually finds no
// width and defers; `changed width` is what actually honours the
// request a frame later. Both are kept because the order is not
// guaranteed, and a settle that has nothing to do is a no-op.
init => { self.settle(); }
changed width => { self.settle(); }
for cell[i] in root.cells: Rectangle { for cell[i] in root.cells: Rectangle {
x: root.pad + i * (root.thumb + root.pad); x: root.pad + i * (root.thumb + root.pad);
y: root.pad; y: root.pad;