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
+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 … } }`