Say a photograph is downloading, and how far, instead of failing

The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
This commit is contained in:
2026-09-26 11:02:11 -04:00
parent 3b97195b37
commit 4bec01eaf1
9 changed files with 380 additions and 63 deletions
+27
View File
@@ -376,6 +376,33 @@ impl RemoteBackend for NextcloudBackend {
Ok(body)
}
async fn get_reporting(
&self,
id: &RemoteId,
progress: &(dyn Fn(u64, Option<u64>) + Send + Sync),
) -> Result<Vec<u8>, RemoteError> {
let url = self.url_for_id(id)?;
let mut resp = self
.client
.get(&url)
.basic_auth(&self.login, Some(&self.password))
.send()
.await
.map_err(map_send_error)?;
map_status(resp.status(), &url)?;
// Read chunk by chunk rather than with `bytes()`, which is the same
// transfer with nothing to say until it ends.
let declared = resp.content_length();
let mut body = Vec::with_capacity(declared.unwrap_or(0) as usize);
progress(0, declared);
while let Some(chunk) = resp.chunk().await.map_err(map_send_error)? {
body.extend_from_slice(&chunk);
progress(body.len() as u64, declared);
}
Ok(body)
}
async fn put(
&self,
path: &RemotePath,
+20
View File
@@ -98,6 +98,26 @@ pub trait RemoteBackend: Send + Sync {
/// either way; [`Capabilities::range_reads`] says whether it was cheap.
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError>;
/// Fetch a whole object, saying how much of it has arrived as it arrives.
///
/// `progress` is called with the bytes received so far and the length the
/// server declared, if it declared one. For the one transfer a person
/// watches: an original opened in develop is tens of megabytes, and a
/// view that can only say "downloading" for that long reads as stuck.
///
/// The default fetches with [`get`](Self::get) and reports once, at the
/// end — right for a backend whose `get` is a local read, where there is
/// no wait to report on.
async fn get_reporting(
&self,
id: &RemoteId,
progress: &(dyn Fn(u64, Option<u64>) + Send + Sync),
) -> Result<Vec<u8>, RemoteError> {
let body = self.get(id, None).await?;
progress(body.len() as u64, Some(body.len() as u64));
Ok(body)
}
/// Upload, optionally guarded by a precondition.
///
/// Backends handle chunking internally based on body size — chunked
File diff suppressed because one or more lines are too long
+44
View File
@@ -415,6 +415,31 @@ pub fn describe_bytes(bytes: u64) -> String {
}
}
/// TRACES: FR-NC-6a
/// A download someone is watching, as the line under its bar and how full the
/// bar is: `received` of `total` bytes, and a fraction in 0..1 — or below
/// zero when there is no total, which the bar draws as indeterminate rather
/// than as a position it would have to invent.
pub fn describe_download(received: u64, total: Option<u64>) -> (String, f32) {
match total.filter(|&t| t > 0) {
// Nothing yet: the size alone says what the wait is for, where
// "0 kB of 38.0 MB" would read as a transfer that has stalled.
Some(t) if received == 0 => (format!("Downloading {}", describe_bytes(t)), 0.0),
// A file that grew since the scan measured it can overrun the
// catalog's length; the bar stops full rather than past its end.
Some(t) => (
format!(
"Downloading — {} of {}",
describe_bytes(received),
describe_bytes(t.max(received))
),
(received as f64 / t as f64).min(1.0) as f32,
),
None if received > 0 => (format!("Downloading — {}", describe_bytes(received)), -1.0),
None => ("Downloading…".to_string(), -1.0),
}
}
/// A running job, held by whatever is reporting on it.
///
/// Every method is idempotent and every one is a no-op once the job has
@@ -498,6 +523,25 @@ impl Drop for Activity {
mod tests {
use super::*;
#[test]
fn a_download_reads_as_what_it_knows() {
let mb = 1024 * 1024;
assert_eq!(describe_download(0, None), ("Downloading…".to_string(), -1.0));
assert_eq!(
describe_download(0, Some(38 * mb)),
("Downloading 38.0 MB".to_string(), 0.0)
);
let (text, fraction) = describe_download(19 * mb, Some(38 * mb));
assert_eq!(text, "Downloading — 19.0 MB of 38.0 MB");
assert_eq!(fraction, 0.5);
let (text, fraction) = describe_download(12 * mb, None);
assert_eq!(text, "Downloading — 12.0 MB");
assert!(fraction < 0.0, "no total, no position");
let (text, fraction) = describe_download(40 * mb, Some(38 * mb));
assert_eq!(text, "Downloading — 40.0 MB of 40.0 MB");
assert_eq!(fraction, 1.0, "an overrun stops full");
}
#[test]
fn a_running_job_makes_the_bar_busy() {
let log = ActivityLog::new();
+40 -2
View File
@@ -411,6 +411,12 @@ fn is_supported(p: &Path) -> bool {
/// rect chosen for the first — or, since local masking became a mode, would
/// open the next photograph with a mask stack it does not have.
fn reset_view_state(window: &AppWindow) {
// Whatever the last open was waiting for, this one is not — until the
// remote path says otherwise.
window.set_load_pending(false);
window.set_load_waiting("".into());
window.set_load_fraction(-1.0);
window.set_has_load_preview(false);
window.global::<Develop>().set_view_mode(ViewMode::Photo);
window.set_zoom(1.0);
window.set_zoomed(false);
@@ -2769,7 +2775,17 @@ fn wire_remote_open(
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
// TRACES: FR-NC-6a
// The grid's thumbnail stands in from this moment, so the step
// lands on this photograph rather than on the last one's pixels
// or an empty frame. Whether to say anything about a download is
// decided below, once one is actually running.
let (preview, size) = library.preview_for_path(&w, &path);
if let Some(p) = preview {
w.set_load_preview(p);
w.set_has_load_preview(true);
}
w.set_load_pending(true);
let rx = library::spawn_full_fetch(conn, path.clone(), cache);
@@ -2803,7 +2819,26 @@ fn wire_remote_open(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Ok(got) = rx.try_recv() else { return };
let Ok(got) = rx.try_recv() else {
// TRACES: FR-NC-6a
// Still coming. Said only once the original is on the
// wire — a read from the cache lands before there is
// a transfer to find, and so never flashes a headline.
// Read by path, because the transfer is as often the
// prefetcher's as this open's own.
if current.get() == mine {
if let (Some(w), Some((received, declared))) =
(weak.upgrade(), library::transfer_progress(&path))
{
let (text, fraction) =
activity::describe_download(received, declared.or(size));
w.set_load_waiting(text.as_str().into());
w.set_load_fraction(fraction);
job.detail(text);
}
}
return;
};
// Landed — this timer has done its job.
held.stop();
let Some(w) = weak.upgrade() else { return };
@@ -2819,6 +2854,9 @@ fn wire_remote_open(
log::debug!("{name}: landed after the view moved on");
return;
}
w.set_load_pending(false);
w.set_load_waiting("".into());
w.set_has_load_preview(false);
let bytes = match got {
Ok(b) => b,
+67 -5
View File
@@ -588,9 +588,31 @@ pub fn spawn_full_fetch(
/// because that is the one name every caller has.
static IN_FLIGHT: std::sync::LazyLock<InFlight> = std::sync::LazyLock::new(InFlight::default);
/// TRACES: FR-NC-6a
/// How far one original's transfer has got: bytes received, and the length
/// the server declared (zero until it has, or if it never does).
///
/// Held in the registry beside the claim rather than handed to the caller,
/// because the caller watching is often not the one downloading: a step along
/// the roll usually lands on a frame the [`Prefetcher`] is already fetching,
/// and the click waits on that transfer instead of starting its own.
#[derive(Default)]
pub(super) struct Transfer {
received: std::sync::atomic::AtomicU64,
declared: std::sync::atomic::AtomicU64,
}
/// TRACES: FR-NC-6a
/// Bytes received so far and bytes expected, for an original being fetched
/// right now by anyone. `None` when nothing is fetching `path` — it is in the
/// cache, or the transfer has not reached the network yet, or it has ended.
pub fn transfer_progress(path: &str) -> Option<(u64, Option<u64>)> {
IN_FLIGHT.progress(path)
}
#[derive(Default)]
pub(super) struct InFlight {
busy: std::sync::Mutex<std::collections::HashSet<String>>,
busy: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<Transfer>>>,
freed: std::sync::Condvar,
/// Threads parked in [`InFlight::claim`], counted under the lock so a
/// test can release the holder only once a waiter is really waiting.
@@ -607,21 +629,32 @@ impl InFlight {
/// stored what the caller was about to download.
fn claim(&self, path: &str) -> Option<InFlightGuard<'_>> {
let mut busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
if busy.insert(path.to_string()) {
if !busy.contains_key(path) {
let transfer = std::sync::Arc::new(Transfer::default());
busy.insert(path.to_string(), transfer.clone());
return Some(InFlightGuard {
of: self,
path: path.to_string(),
transfer,
});
}
#[cfg(test)]
self.waiting
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
while busy.contains(path) {
while busy.contains_key(path) {
busy = self.freed.wait(busy).unwrap_or_else(|e| e.into_inner());
}
None
}
fn progress(&self, path: &str) -> Option<(u64, Option<u64>)> {
use std::sync::atomic::Ordering::Relaxed;
let busy = self.busy.lock().unwrap_or_else(|e| e.into_inner());
let t = busy.get(path)?;
let declared = t.declared.load(Relaxed);
Some((t.received.load(Relaxed), (declared > 0).then_some(declared)))
}
fn release(&self, path: &str) {
self.busy
.lock()
@@ -636,6 +669,7 @@ impl InFlight {
pub(super) struct InFlightGuard<'a> {
of: &'a InFlight,
path: String,
transfer: std::sync::Arc<Transfer>,
}
impl Drop for InFlightGuard<'_> {
@@ -678,7 +712,7 @@ pub(super) fn fetch_original(
// Miss, claim, and if the claim had to wait, look again: the thread that
// held the path has finished with it, and what it fetched is on disk.
let _claim = loop {
let claim = loop {
if let Some(bytes) = from_cache() {
return Ok(bytes);
}
@@ -693,7 +727,14 @@ pub(super) fn fetch_original(
let backend = crate::remote::connect(&conn).map_err(FetchFailure::local)?;
let id = RemoteId::Path(RemotePath::new(path));
let bytes = backend.get(&id, None).await?;
let transfer = &claim.transfer;
let bytes = backend
.get_reporting(&id, &|received, declared| {
use std::sync::atomic::Ordering::Relaxed;
transfer.received.store(received, Relaxed);
transfer.declared.store(declared.unwrap_or(0), Relaxed);
})
.await?;
// Store before returning, so the bytes are on disk by the time the
// image is on screen. Doing it after would leave a window where
@@ -936,6 +977,27 @@ mod tests {
);
}
/// Whoever is watching a path reads the holder's progress through the
/// registry, and loses it when the transfer ends — a step onto a frame
/// the prefetcher is fetching shows that transfer's bar.
#[test]
fn progress_is_readable_by_path_while_claimed() {
use std::sync::atomic::Ordering::Relaxed;
let registry = InFlight::default();
assert_eq!(registry.progress("a.CR2"), None);
let claim = registry.claim("a.CR2").unwrap();
assert_eq!(registry.progress("a.CR2"), Some((0, None)));
claim.transfer.received.store(1024, Relaxed);
claim.transfer.declared.store(4096, Relaxed);
assert_eq!(registry.progress("a.CR2"), Some((1024, Some(4096))));
assert_eq!(registry.progress("b.CR2"), None);
drop(claim);
assert_eq!(registry.progress("a.CR2"), None);
}
/// Different photographs never wait on each other.
#[test]
fn distinct_paths_are_claimed_independently() {
+28
View File
@@ -614,6 +614,34 @@ impl LibraryController {
.map(|id| dr_types::ImageId(*id as u64))
}
/// TRACES: FR-NC-6a
/// What the grid already holds for `path`: the thumbnail its cell is
/// drawing, if it has one yet, and the file's length as the scan recorded
/// it.
///
/// For the develop view while the original comes down. The thumbnail is
/// the one already decoded for the grid — no store read, no decode — and
/// the length gives the progress bar a denominator when the server sends
/// none of its own.
pub fn preview_for_path(
&self,
window: &crate::AppWindow,
path: &str,
) -> (Option<slint::Image>, Option<u64>) {
use slint::{ComponentHandle as _, Model as _};
let Some(row) = self.paths.borrow().iter().position(|p| p == path) else {
return (None, None);
};
let size = self.sizes.borrow().get(row).copied().filter(|&s| s > 0);
let thumbnail = window
.global::<crate::Library>()
.get_library_cells()
.row_data(row)
.filter(|c| c.has_thumb)
.map(|c| c.thumbnail);
(thumbnail, size)
}
/// TRACES: FR-NC-6a | FR-UI-4
/// The photographs within `depth` of `path` on the roll, closest first
/// and working outwards: next, previous, next-but-one, previous-but-one…
+56 -15
View File
@@ -9,7 +9,7 @@ import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
import { GestureRow, GestureSheet } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow, Scrolling, ScrollBar } from "widgets.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, WaitingState, ProgressBar, ActivityRow, Scrolling, ScrollBar } from "widgets.slint";
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
@@ -150,6 +150,32 @@ export component AppWindow inherits Window {
in property <int> total: 0;
in property <string> load-error: "";
/// TRACES: FR-NC-6a
/// A photograph has been asked for and is not on the canvas yet — it is
/// being read from the cache, or downloaded, or decoded. The previous
/// frame is gone from view by then; the grid's thumbnail stands in.
in property <bool> load-pending: false;
/// And it is not on this device: what has arrived, as a sentence
/// ("12.4 of 38.0 MB"). Empty while it is only being read from disk, which
/// is too quick to be worth a headline.
///
/// Its own property rather than a message in `load-error`, which is where
/// it used to live — and so the view headed a download that was going
/// fine with "Could not load image", on every step, cached or not.
in property <string> load-waiting: "";
/// How much of it, 0..1, or below zero while the size is not known yet.
in property <float> load-fraction: -1;
/// The grid's thumbnail of the photograph being fetched, shown dimmed
/// behind the bar, so stepping along the roll moves from picture to
/// picture rather than through an empty frame.
in property <image> load-preview;
in property <bool> has-load-preview: false;
/// A photograph is on the canvas and is the one named: not an empty
/// folder, not a failure, not a download still coming in. Everything that
/// acts on the picture is gated on this.
property <bool> has-photo: root.total > 0 && root.load-error == "" && !root.load-pending;
/// TRACES: FR-CULL-3
/// The focus marks themselves, and whether they describe *this* frame.
/// Rust's, because they come from a compute pass — see `peaking.slint` for
@@ -1061,7 +1087,7 @@ in property <bool> panel-visible: true;
root.export-from-sheet();
return accept;
}
if (root.copy-sheet-open && root.total > 0 && root.load-error == "") {
if (root.copy-sheet-open && root.has-photo) {
root.copy-sheet-open = false;
Transfer.copy();
return accept;
@@ -1733,7 +1759,7 @@ in property <bool> panel-visible: true;
x: 0;
y: 0;
height: parent.height;
enabled: root.total > 0 && root.load-error == "";
enabled: root.has-photo;
mode: Develop.view-mode;
picked(m) => { root.mode-picked(m); }
@@ -1792,7 +1818,7 @@ in property <bool> panel-visible: true;
image-rendering: root.magnified
? ImageRendering.pixelated
: ImageRendering.smooth;
visible: root.total > 0 && root.load-error == "";
visible: root.has-photo;
}
// TRACES: FR-DSP-4
@@ -1837,7 +1863,7 @@ in property <bool> panel-visible: true;
// it is a diagnostic and not an edit: it must not reach
// the histogram, an export, or the texture the develop
// pass hands the compositor.
if root.overlay-on && !Masking.overlay-hidden && root.total > 0: Image {
if root.overlay-on && !Masking.overlay-hidden && root.has-photo: Image {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -1867,7 +1893,7 @@ in property <bool> panel-visible: true;
// The focus marks, over the same fitted rect. See
// `peaking.slint` for why they are a layer over the canvas
// rather than a tint in it.
if root.focus-overlay-ready && root.total > 0: FocusMarks {
if root.focus-overlay-ready && root.has-photo: FocusMarks {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -1900,6 +1926,21 @@ in property <bool> panel-visible: true;
: "Pass a folder or file on the command line.";
}
// TRACES: FR-NC-6a
// Waiting for an original. Not an empty state and not an
// error: the photograph is known, and the grid's thumbnail
// of it stands in so a step along the roll lands on *this*
// frame at once — with a bar, once it turns out to be a
// download, saying how long until it can be worked on.
if root.total > 0 && root.load-error == "" && root.load-pending: WaitingState {
headline: "Not on this device yet";
detail: root.load-waiting;
fraction: root.load-fraction;
what: "Downloading " + root.filename;
preview: root.load-preview;
has-preview: root.has-load-preview;
}
// --- zoom and pan ------------------------------------------
//
// Below the crop overlay in z-order so that, in crop mode, the
@@ -1935,7 +1976,7 @@ in property <bool> panel-visible: true;
// Anchored on the midpoint between the fingers, which is
// what makes a pinch feel like it is moving the picture
// rather than the viewport.
if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler {
if root.has-photo: ScaleRotateGestureHandler {
x: 0; y: 0;
width: 100%;
height: 100%;
@@ -1982,7 +2023,7 @@ in property <bool> panel-visible: true;
// it.** `GradientHandles` further down is the other end of
// the same rule, and is why dragging a handle has always
// worked while everything between it and here did not.
if root.total > 0 && root.load-error == "": TouchArea {
if root.has-photo: TouchArea {
x: 0; y: 0;
width: 100%;
height: 100%;
@@ -2073,7 +2114,7 @@ in property <bool> panel-visible: true;
// picking wants a click, and interleaving the two in one
// handler is how a drag ends up selecting a region the
// user was only scrolling past.
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
if root.region-picking && root.has-photo: pick := TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -2111,7 +2152,7 @@ in property <bool> panel-visible: true;
// two want the same press, and `region-picking` is false
// whenever this is armed so only one of them exists at a
// time.
if root.painting && root.total > 0 && root.load-error == "": paint := TouchArea {
if root.painting && root.has-photo: paint := TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -2154,7 +2195,7 @@ in property <bool> panel-visible: true;
// Below the circles declared further down, so a press that
// lands on an existing repair takes hold of it instead of
// making another one on top.
if Develop.repairing && root.total > 0 && root.load-error == "": TouchArea {
if Develop.repairing && root.has-photo: TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -2185,7 +2226,7 @@ in property <bool> panel-visible: true;
// were recorded the stack would fill with a hundred
// temperatures nobody chose. The arming state is what says
// the next click will do something instead.
if root.sampling && root.total > 0 && root.load-error == "": TouchArea {
if root.sampling && root.has-photo: TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
@@ -2211,7 +2252,7 @@ in property <bool> panel-visible: true;
//
// Placed over the fitted image, so the fractions it draws are
// fractions of the picture. See `CropOverlay` in crop.slint.
if Develop.cropping && root.total > 0 && root.load-error == "": CropOverlay {
if Develop.cropping && root.has-photo: CropOverlay {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
@@ -2813,7 +2854,7 @@ in property <bool> panel-visible: true;
// window away from the thing being confirmed is a
// confirmation you take on trust. The rail is now the
// second way out rather than the first.
if root.total > 0 && root.load-error == "": HorizontalLayout {
if root.has-photo: HorizontalLayout {
// **Clear of the photo roll, or these do nothing.**
//
// The roll's swipe handler consumes every press that
@@ -3415,7 +3456,7 @@ in property <bool> panel-visible: true;
scope-kinds: root.copy-scope-kinds;
scope-empty: root.copy-scope-empty;
summary: Transfer.summary;
can-copy: root.total > 0 && root.load-error == "";
can-copy: root.has-photo;
scope-toggled(name) => { root.copy-scope-toggled(name); }
copy => {
root.copy-sheet-open = false;
+57
View File
@@ -987,6 +987,63 @@ export component EmptyState inherits VerticalLayout {
}
}
// What a view shows while the thing it will show is still on its way.
//
// The third answer beside `EmptyState`'s two. A photograph being downloaded is
// neither missing nor broken, and the develop view used to put it under the
// error heading — "Could not load image", then "Downloading…" — which reads as
// a failure followed by a retry. This one shows what is already in hand (the
// grid's thumbnail) so the step lands on *this* photograph at once, and, only
// once there is a real wait (`detail` set), dims it under a headline, how far
// along, and a bar. A read from disk never gets that far, so it never
// flashes text.
export component WaitingState inherits Rectangle {
in property <string> headline;
/// Empty while the wait is too short to talk about.
in property <string> detail;
/// 0..1, or below zero while the size is not known.
in property <float> fraction: -1;
/// What the bar is announced as.
in property <string> what;
in property <image> preview;
in property <bool> has-preview: false;
if root.has-preview: Image {
width: 100%;
height: 100%;
source: root.preview;
image-fit: contain;
opacity: root.detail != "" ? 0.4 : 1;
}
if root.detail != "": VerticalLayout {
alignment: center;
spacing: Theme.gap;
Text {
text: root.headline;
color: Theme.ink;
font-size: Theme.text-lg;
horizontal-alignment: center;
}
Caption {
text: root.detail;
horizontal-alignment: center;
}
HorizontalLayout {
alignment: center;
ProgressBar {
width: min(240px, root.width * 60%);
fraction: root.fraction;
indeterminate: root.fraction < 0;
label: root.what;
}
}
}
}
// A scrolling list that only builds the rows you can see.
//
// # Why this exists rather than `Flickable { VerticalLayout { for … } }`