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