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:
+88
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user