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
+62
View File
@@ -566,6 +566,68 @@ export component GeometryPanel inherits Rectangle {
// and a control behind a lid is one the user does not know the pipeline has.
// The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted.
// TRACES: FR-DEV-6
// Copying this photograph's settings, and pasting settings onto it.
//
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
// a tablet where there is no modifier key to hold and no menu bar to hang the
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
// accelerator for a control that is on screen either way, which is what keeps
// the feature discoverable on both platforms.
//
// The paste button carries what would be pasted rather than the bare word.
// "Paste" alone asks the user to remember what they copied and, crucially,
// whether the crop is coming with it — a question the label answers by
// naming the count the *current* scope would apply.
export component TransferPanel inherits VerticalLayout {
in property <bool> enabled: true;
/// Whether anything has been copied yet. Distinct from the clipboard
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
/// since it clears the target.
in property <bool> armed: false;
/// What a paste would apply — "3 adjustments", or "Neutral".
in property <string> summary;
/// Whether the clipboard holds framing the current scope is dropping.
/// Only then is it worth saying anything about the crop.
in property <bool> framing-withheld: false;
callback copy();
callback paste();
padding: Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
PanelHeading { text: "SETTINGS"; }
Rectangle { horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Copy";
enabled: root.enabled;
horizontal-stretch: 1;
clicked => { root.copy(); }
}
Button {
text: "Paste";
// Enabled on `armed` rather than on the summary being non-empty,
// so pasting a neutral copy — which clears this image — stays
// available. Still needs an image to paste *onto*.
enabled: root.enabled && root.armed;
horizontal-stretch: 1;
clicked => { root.paste(); }
}
}
if root.armed: Caption {
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
}
}
export component AdjustPanel inherits Rectangle {
in property <[ParamRow]> rows;
in property <bool> enabled: true;
+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;
+34
View File
@@ -452,11 +452,16 @@ component HeaderActions inherits HorizontalLayout {
in property <bool> scanning: false;
in property <bool> syncing: false;
in property <bool> scope-pinned: false;
/// TRACES: FR-DEV-6
/// Whether settings have been copied, and what pasting them would apply.
in property <bool> settings-armed: false;
in property <string> settings-summary;
/// Centres each button in a 44px header. Off in the disclosure row, which
/// is sized to its content.
in property <bool> centred: true;
in property <length> row-height: 44px;
callback paste-settings-to-selection();
callback remove-from-collection();
callback change-library();
callback toggle-pin-scope();
@@ -466,6 +471,21 @@ component HeaderActions inherits HorizontalLayout {
spacing: Theme.gap;
// TRACES: FR-DEV-6
// Batch-apply the copied settings. Shown only with both a selection and a
// clipboard, because it is meaningless without either — and because a
// permanently visible button that is usually disabled teaches the user to
// stop reading this row.
//
// The count is in the label rather than in a confirmation: this writes to
// every selected image, and "Paste to 40" said before the click is worth
// more than a dialogue asking the same question after it.
if root.selected-count > 0 && root.settings-armed: Button {
text: "Paste to " + root.selected-count;
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.paste-settings-to-selection(); }
}
// Removing from a collection is only meaningful while the grid is scoped
// to one. Offering it unscoped would invite the reading "remove from the
// library", which nothing here does.
@@ -725,6 +745,14 @@ export component LibraryGrid inherits Rectangle {
/// How many images are selected, for the header's count.
in property <int> selected-count: 0;
// TRACES: FR-DEV-6
// Batch-applying copied develop settings to the selection. The clipboard
// itself belongs to the window — a copy is taken in the develop view and
// pasted here — so the grid only reports what it has and asks.
in property <bool> settings-armed: false;
in property <string> settings-summary;
callback paste-settings-to-selection();
// --- the keyboard cursor ------------------------------------------------
//
// Where the keyboard is in the library, as an **image ordinal** — not a
@@ -861,6 +889,9 @@ export component LibraryGrid inherits Rectangle {
scanning: root.scanning;
syncing: root.syncing;
scope-pinned: root.scope-pinned;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
change-library => { root.change-library(); }
toggle-pin-scope => { root.toggle-pin-scope(); }
@@ -905,6 +936,9 @@ export component LibraryGrid inherits Rectangle {
scanning: root.scanning;
syncing: root.syncing;
scope-pinned: root.scope-pinned;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
change-library => { root.change-library(); }
toggle-pin-scope => { root.toggle-pin-scope(); }
+33
View File
@@ -76,6 +76,9 @@ export component SettingsPage inherits Rectangle {
in-out property <string> thumbnail-budget;
in property <bool> thumbnail-unlimited: false;
in property <bool> keep-opened: true;
/// TRACES: FR-DEV-6
/// Whether copying settings carries the crop and rotation with it.
in property <bool> copy-includes-framing: false;
/// What the cache currently holds. Empty hides the line.
in property <string> cache-usage;
@@ -84,6 +87,7 @@ export component SettingsPage inherits Rectangle {
callback thumbnail-budget-changed(string);
callback thumbnail-unlimited-toggled(bool);
callback keep-opened-toggled(bool);
callback copy-includes-framing-toggled(bool);
// --- export --------------------------------------------------------
in property <[string]> format-labels;
@@ -359,6 +363,35 @@ export component SettingsPage inherits Rectangle {
}
}
// --- develop (FR-DEV-6) ----------------------------------
Rectangle {
width: content.column;
height: develop-panel.preferred-height;
develop-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "DEVELOP"; }
Check {
label: "Copy crop and rotation with settings";
// Says what the *off* state does, because off is
// the default and is the behaviour that needs
// explaining: a user who has never opened this
// page should still be able to predict what a
// paste did to their crops.
hint: "Off, pasting settings changes only colour and "
+ "tone, and each photograph keeps its own "
+ "composition. On, the crop, straightening, "
+ "rotation and flips travel too — which "
+ "re-frames every image pasted onto.";
checked: root.copy-includes-framing;
toggled(on) => { root.copy-includes-framing-toggled(on); }
}
}
}
// --- export ----------------------------------------------
Rectangle {
width: content.column;