From 6ae0af3f7263064f4ad1aa3735d9d011419815cb Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 21:10:44 +0200 Subject: [PATCH] Swipe up in develop for the photo roll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Develop opens one photograph. The grid handed over a path and nothing else, so `index` and `total` were pinned to "1 of 1" on the way in and the only route to the next frame was back to the library, find your place, tap again. Fine once; intolerable through a set of forty, which is the situation the develop view exists for. The roll is the grid's already-loaded window along the foot of the canvas. Swipe up to bring it out, swipe down to put it away — the sheet gesture, already in the hands of anyone who has used a phone — and a handle is drawn at the edge so the gesture is discoverable rather than folklore, and so a pointer, which has no swipe to make, has a way in. `SwipeGestureHandler` wraps the strip rather than sitting over or under it, which is what it is built for: it delays a press the way a Flickable does, forwards it to the children if no swipe develops and claims it once one does, so a tap reaches the thumbnail and a drag does not. It covers only the band along the bottom — above that, a drag still belongs to the photograph, for panning and for the crop. Picking goes through the same path a cell click does, so the outgoing edit is persisted before the next image loads. The strip marks what is open and scrolls to keep the mark in view. The position readout now says where in the *library* the open photograph sits rather than "1 of 1". Set after the open rather than before, since the generic open path resets it — and given as the library ordinal, 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. --- ui/dr-ui/src/library_ui.rs | 51 ++++++++++- ui/dr-ui/ui/app.slint | 29 +++++- ui/dr-ui/ui/library.slint | 176 +++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 4 deletions(-) diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 2f86dfa..f59dc1b 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -3452,6 +3452,20 @@ fn place_cursor( window.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, row: usize) { + let offset = *ctl.offset.borrow(); + window.set_index((offset + row) as i32); + window.set_total(window.get_library_total()); +} + /// Connect the grid's callbacks. pub fn wire( window: &AppWindow, @@ -3487,12 +3501,41 @@ pub fn wire( // "‹ Library" button comes back here. if let Some(w) = weak.upgrade() { w.set_show_library(false); + // Which cell the develop view is now showing, so the photo + // roll opens marking it rather than marking nothing. + w.set_library_roll_current(i); } on_open_image(path); + if let Some(w) = weak.upgrade() { + report_position(&w, &ctl, i as usize); + } } }); } + // 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.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.set_library_roll_current(i); + on_open_image(path); + report_position(&w, &ctl, i as usize); + }); + } + // --- the keyboard cursor (FR-CULL-4) ----------------------------------- // // Walking the grid with the arrows, and opening with `Return`. Together @@ -3524,12 +3567,14 @@ pub fn wire( // wrong photograph is worse than opening none. let Some(cursor) = coll.cursor() else { return }; let offset = *ctl.offset.borrow(); - let path = cursor - .checked_sub(offset) - .and_then(|row| ctl.paths.borrow().get(row).cloned()); + 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_show_library(false); + // As on a click: the roll marks what is open. + w.set_library_roll_current(row.unwrap_or(0) as i32); on_open(path); + report_position(&w, &ctl, row.unwrap_or(0)); } }); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 68771f5..07d97ba 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1,7 +1,7 @@ import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint"; import { LaunchScreen } from "launch.slint"; -import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; +import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint"; import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; @@ -510,6 +510,15 @@ export component AppWindow inherits Window { callback library-zoom-cells(int); in property library-scroll-to: 0; in property library-scroll-token: 0; + /// Which row of the loaded window is the photograph currently open in + /// develop, so the roll can mark it. `-1` when it is not in the window. + in property library-roll-current: -1; + /// Whether the photo roll is out. Owned here rather than by the roll, so + /// it survives the develop view being torn down and rebuilt. + in-out property roll-open: false; + /// A photograph was chosen from the roll: the row within the loaded + /// window, which is what a cell click reports too. + callback library-roll-pick(int); callback library-sync-now(); callback library-columns-changed(); callback library-scrolled(int); @@ -1636,6 +1645,24 @@ in property panel-visible: true; } } + // The photo roll, along the foot of the canvas. + // + // Only where a library was opened: with files named on the + // command line there is no window of photographs to roll + // through, which is the same condition the "‹ Library" + // button uses. + // + // Last in the canvas, so it draws over the photograph + // rather than under it. + if root.library-total > 0: PhotoRoll { + width: 100%; + height: 100%; + cells: root.library-cells; + current: root.library-roll-current; + open <=> root.roll-open; + pick(i) => { root.library-roll-pick(i); } + } + // Report size changes so the render target can be resized to // match. Width and height are tracked separately because // Slint has no single "geometry changed" hook. diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index a17f4fa..bfe06c0 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -263,6 +263,182 @@ export struct LibraryCell { flag: int, } +// The photo roll: the grid's loaded window along the foot of the develop view. +// +// # Why this exists +// +// Develop opens *one* photograph. `index` and `total` are pinned to "1 of 1" +// on the way in, because the grid hands over a path and nothing else, so the +// only way to reach the next frame was to go back to the library, find where +// you were, and tap again. That is fine once and intolerable through a set of +// forty — which is precisely the situation the develop view exists for. +// +// # The gesture +// +// A swipe up from the bottom edge brings it out, a swipe down puts it away: +// the sheet gesture, already in the hands of anyone who has used a phone. +// A gesture with no visible counterpart is a feature only its author knows +// about (FR-UI-4), so a handle is drawn at the edge and is a button in its own +// right — which is also what gives a pointer, with no swipe to make, a way in. +// +// The handler wraps the strip rather than sitting over or under it. That is +// what `SwipeGestureHandler` is built for: it delays a press the way a +// Flickable does, forwards it to the children if no swipe develops, and claims +// it once one does. A tap therefore reaches the thumbnail and a drag does not. +// +// It covers only the band along the bottom, never the whole canvas — above +// that band a drag belongs to the photograph, for panning and for the crop. +export component PhotoRoll inherits Rectangle { + in property <[LibraryCell]> cells; + /// Which row of the loaded window is open, so it can be marked. `-1` when + /// the open photograph is not in the window at all, which is the honest + /// answer after a scrub — the roll shows where you are, and sometimes the + /// answer is "not here". + in property current: -1; + in-out property open: false; + + /// A thumbnail was chosen. The row within the loaded window, matching what + /// a cell click reports. + callback pick(int); + + property strip-height: 108px; + /// The band of canvas left grabbable when the roll is away. A thumb's + /// worth, and no more: it is taken off the bottom of the photograph. + property reach: 28px; + property thumb: 92px; + property pad: 6px; + + background: transparent; + + swipe := SwipeGestureHandler { + width: 100%; + height: root.strip-height + root.reach; + y: parent.height - self.height; + + handle-swipe-up: !root.open; + handle-swipe-down: root.open; + + // Direction decides, not distance: the handler has already applied its + // own threshold by the time this fires, and re-testing the travel here + // would mean a swipe that qualified as a swipe still did nothing. + swiped => { + root.open = self.current-position.y < self.pressed-position.y; + } + + // --- the strip ------------------------------------------------ + // + // Slid out of view rather than removed. An `if` would have it appear + // fully formed at the bottom of the screen instead of arriving from + // the edge, and would leave nothing for the animation to act on. + strip := Rectangle { + width: 100%; + height: root.strip-height; + y: root.open ? root.reach : parent.height; + animate y { duration: 180ms; easing: ease-out; } + background: Theme.surface; + clip: true; + + Rectangle { + width: 100%; + height: 1px; + background: Theme.rule; + } + + roll := Flickable { + width: 100%; + height: 100%; + viewport-height: self.height; + viewport-width: + max(self.width, + root.cells.length * (root.thumb + root.pad) + root.pad); + + // Bring the open photograph into view — on opening, and when + // the roll itself moves the selection along. Without it a pick + // near the end of the window scrolls back to the start on the + // next reveal, and the mark the strip exists to show is off + // the edge of it. + function reveal() { + if (root.current < 0) { + return; + } + let left = root.pad + root.current * (root.thumb + root.pad); + let shown = -self.viewport-x; + let right = max(0px, self.viewport-width - self.width); + if (left < shown) { + self.viewport-x = -min(right, left); + } else if (left + root.thumb > shown + self.width) { + self.viewport-x = + -min(right, left + root.thumb - self.width); + } + } + + property mark: root.current; + changed mark => { self.reveal(); } + property shown: root.open; + changed shown => { + if (self.shown) { self.reveal(); } + } + + for cell[i] in root.cells: Rectangle { + x: root.pad + i * (root.thumb + root.pad); + y: root.pad; + width: root.thumb; + height: parent.height - 2 * root.pad; + background: Theme.ground; + border-radius: Theme.radius; + // The one that is open, marked the way the grid marks a + // selection so the two read as the same idea. + border-width: i == root.current ? 2px : 0px; + border-color: Theme.selected-ring; + clip: true; + + Image { + width: 100%; + height: 100%; + source: cell.thumbnail; + image-fit: contain; + visible: cell.has-thumb; + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.pick(i); } + } + } + } + } + + // --- the handle ----------------------------------------------- + // + // Rides on the strip's top edge, so it is in the same place relative to + // the roll whether it is in or out, and it is the affordance that stops + // the swipe being folklore. + Rectangle { + width: 84px; + height: root.reach; + x: (parent.width - self.width) / 2; + y: strip.y - self.height; + animate y { duration: 180ms; easing: ease-out; } + background: Theme.surface; + border-radius: Theme.radius; + + Rectangle { + width: 32px; + height: 3px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.ink-dim; + border-radius: 2px; + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.open = !root.open; } + } + } + } +} + // A row of five stars, readable at a glance and clickable to set a rating. // // **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids