Let a photograph leave: an export button, and a cache to leave from

dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:58:17 +02:00
co-authored by Claude Opus 5
parent 23f0c4b76a
commit e00c99b864
19 changed files with 2845 additions and 85 deletions
+88 -2
View File
@@ -1,5 +1,5 @@
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint";
import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slint";
import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
@@ -24,10 +24,20 @@ component StatusBar inherits Rectangle {
in property <bool> can-return-to-library: false;
/// Whether the develop column is currently shown, for the toggle's label.
in property <bool> panel-visible: true;
/// What the export button says. Rust owns it because the answer depends
/// on settings this component does not see — the format, and whether the
/// destination is this device or the server.
in property <string> export-label: "Export";
in property <bool> export-busy: false;
/// What the last export did. Sits beside the button rather than in a
/// dialogue: an export that succeeded needs no acknowledging, and one
/// that failed needs its reason where the retry is.
in property <string> export-status;
callback back-to-library();
callback open-settings();
callback toggle-panel();
callback export-image();
// 44px and `surface`, the same bar the library and settings draw.
//
@@ -88,6 +98,23 @@ component StatusBar inherits Rectangle {
clicked => { root.toggle-panel(); }
}
Caption {
text: root.export-status;
vertical-alignment: center;
overflow: elide;
}
// The export itself, beside the settings that shape it. This is the
// screen where a photograph is finished, so it is the screen where
// one is asked for — the grid can export a selection later, but a
// single finished frame is exported from in front of it.
Button {
text: root.export-busy ? "Exporting…" : root.export-label;
enabled: !root.export-busy;
y: (parent.height - self.height) / 2;
clicked => { root.export-image(); }
}
// Reachable from develop as well as from the grid: export defaults are
// most likely to be wanted with a finished photograph on screen, which
// is exactly where this bar is and the library header is not.
@@ -482,6 +509,22 @@ export component AppWindow inherits Window {
callback curve-reset(int);
callback reset-all();
// --- copying settings between photographs (FR-DEV-6) ---
//
// The clipboard is a window-lifetime thing rather than a view's, which is
// why it lives here and not inside the develop column: a copy is taken in
// develop and may be pasted onto a selection back in the library grid.
/// Whether anything has been copied this session.
in property <bool> settings-armed: false;
/// What a paste would apply, at the current scope — "3 adjustments".
in property <string> settings-summary;
/// Whether the copy holds framing that the current setting is dropping.
in property <bool> settings-framing-withheld: false;
callback copy-settings();
callback paste-settings();
/// Apply the clipboard to every selected image in the grid.
callback paste-settings-to-selection();
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
@@ -498,6 +541,7 @@ export component AppWindow inherits Window {
in property <string> settings-thumbnail-budget: "";
in property <bool> settings-thumbnail-unlimited: false;
in property <bool> settings-keep-opened: true;
in property <bool> settings-copy-includes-framing: false;
in property <string> settings-cache-usage: "";
callback settings-original-budget-changed(string);
@@ -505,6 +549,7 @@ export component AppWindow inherits Window {
callback settings-thumbnail-budget-changed(string);
callback settings-thumbnail-unlimited-toggled(bool);
callback settings-keep-opened-toggled(bool);
callback settings-copy-includes-framing-toggled(bool);
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
@@ -571,9 +616,18 @@ export component AppWindow inherits Window {
/// and one they opened on a narrow one stays open. Rust owns both for the
/// reason given above — a property read inside the layout and also feeding
/// it is a binding loop.
in property <bool> panel-visible: true;
in property <bool> panel-visible: true;
/// The export button's label and busy state, and the line it reports to.
/// Rust owns all three: what an export is called depends on the format and
/// on whether the destination is this device or the server, neither of
/// which the toolbar can see.
in property <string> export-label: "Export";
in property <bool> export-busy: false;
in property <string> export-status;
/// Show or hide the develop column.
callback toggle-panel();
/// Export the image on screen, using the settings as they stand.
callback export-image();
/// Show or hide the collections sidebar.
callback toggle-collections();
@@ -652,6 +706,7 @@ export component AppWindow inherits Window {
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
copy-includes-framing: root.settings-copy-includes-framing;
cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
@@ -663,6 +718,9 @@ export component AppWindow inherits Window {
root.settings-thumbnail-unlimited-toggled(on);
}
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
copy-includes-framing-toggled(on) => {
root.settings-copy-includes-framing-toggled(on);
}
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
@@ -833,6 +891,11 @@ export component AppWindow inherits Window {
offset: root.library-offset;
selected-count: root.library-selected-count;
scope-label: root.collection-scope-label;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => {
root.paste-settings-to-selection();
}
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
@@ -929,9 +992,13 @@ export component AppWindow inherits Window {
// grid to return to.
can-return-to-library: root.library-total > 0;
panel-visible: root.panel-visible;
export-label: root.export-label;
export-busy: root.export-busy;
export-status: root.export-status;
back-to-library() => { root.back-to-library(); }
open-settings() => { root.settings-open(); }
toggle-panel() => { root.toggle-panel(); }
export-image() => { root.export-image(); }
}
HorizontalLayout {
@@ -1389,6 +1456,25 @@ export component AppWindow inherits Window {
background: Theme.rule;
}
// Between the framing and the colour work, because it
// acts on both: a paste is about the whole edit, and
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
framing-withheld: root.settings-framing-withheld;
copy => { root.copy-settings(); }
paste => { root.paste-settings(); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
AdjustPanel {
vertical-stretch: 1;
rows: root.adjust-rows;