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