Merge the batch export, and settle the seven-branch merge

Resolves the last of the parallel work. Two conflicts worth recording,
because both were semantic rather than textual:

`render_for_export` gained a colour space on master while the batch branch
was rewriting the single-image export path around it. Kept both: the batch
request supersedes the synchronous path, and the space still has to be chosen
at render time because the conversion happens in the shader before the clip to
0..1. `render_open_frame` takes it as an argument rather than reaching for a
controller it does not hold.

The map-wait moved into `readback::await_mapping` on one branch while another
was editing the constant it used, so `READBACK_POLL_LIMIT` survived the merge
with no callers. Removed rather than left for clippy to find later.

1164 tests pass, clippy clean, fmt clean. Traceability 53.0% -> 54.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 10:11:44 +02:00
co-authored by Claude Opus 5
8 changed files with 1447 additions and 187 deletions
+19
View File
@@ -674,6 +674,19 @@ in property <bool> panel-visible: true;
in property <string> export-label: "Export";
in property <bool> export-busy: false;
in property <string> export-status;
/// TRACES: FR-EXP-7
/// Whether an export would be queued for the server rather than written to
/// this device. The grid says the same thing as the develop button, but it
/// composes its own label around a selection count — so it is given the
/// fact and not the sentence.
in property <bool> export-to-server: false;
/// Whether a batch is running. The grid's export button becomes its cancel
/// button; there is nowhere else a long batch can be stopped from.
in property <bool> library-exporting: false;
/// Export the grid's selection in the background (FR-EXP-7).
callback library-export-selection();
/// TRACES: NFR-ARCH-3
callback library-cancel-export();
/// Show or hide the develop column.
callback toggle-panel();
/// Export the image on screen, using the settings as they stand.
@@ -957,6 +970,12 @@ in property <bool> panel-visible: true;
root.paste-settings-to-selection();
}
// TRACES: FR-EXP-7
exporting: root.library-exporting;
export-to-server: root.export-to-server;
export-selection => { root.library-export-selection(); }
cancel-export => { root.library-cancel-export(); }
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
+54
View File
@@ -456,11 +456,17 @@ component HeaderActions inherits HorizontalLayout {
/// Whether settings have been copied, and what pasting them would apply.
in property <bool> settings-armed: false;
in property <string> settings-summary;
/// TRACES: FR-EXP-7
/// Whether a batch is running, and where its files are going.
in property <bool> exporting: false;
in property <bool> export-to-server: false;
/// 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 export-selection();
callback cancel-export();
callback paste-settings-to-selection();
callback remove-from-collection();
callback change-library();
@@ -486,6 +492,37 @@ component HeaderActions inherits HorizontalLayout {
clicked => { root.paste-settings-to-selection(); }
}
// TRACES: FR-EXP-7 | NFR-ARCH-3
// Export the selection, and stop the batch that is running.
//
// One button doing both, because they are the same thought a moment apart
// and a separate cancel would have to appear from somewhere — shifting the
// row under the pointer at the exact moment the user is reaching for it.
//
// It stays while a batch runs whatever the selection has since become: the
// run is what the button now refers to, and a batch of three hundred that
// could only be stopped by not changing the selection would be a trap.
//
// The count is in the label rather than behind a confirmation, exactly as
// the paste above puts it there: "Export 40" read before the click is worth
// more than a dialogue asking the same question after it.
if root.selected-count > 0 || root.exporting: Button {
text: root.exporting
? "Cancel export"
: (root.export-to-server
? "Export " + root.selected-count + " to Nextcloud"
: "Export " + root.selected-count);
active: root.exporting;
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => {
if (root.exporting) {
root.cancel-export();
} else {
root.export-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.
@@ -753,6 +790,15 @@ export component LibraryGrid inherits Rectangle {
in property <string> settings-summary;
callback paste-settings-to-selection();
// TRACES: FR-EXP-7
// Exporting the selection. The grid owns neither the settings that decide
// where the files go nor the worker that writes them — it reports what is
// selected and asks, exactly as it does for a paste.
in property <bool> exporting: false;
in property <bool> export-to-server: false;
callback export-selection();
callback cancel-export();
// --- the keyboard cursor ------------------------------------------------
//
// Where the keyboard is in the library, as an **image ordinal** — not a
@@ -891,6 +937,10 @@ export component LibraryGrid inherits Rectangle {
scope-pinned: root.scope-pinned;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
exporting: root.exporting;
export-to-server: root.export-to-server;
export-selection => { root.export-selection(); }
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
change-library => { root.change-library(); }
@@ -938,6 +988,10 @@ export component LibraryGrid inherits Rectangle {
scope-pinned: root.scope-pinned;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
exporting: root.exporting;
export-to-server: root.export-to-server;
export-selection => { root.export-selection(); }
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
change-library => { root.change-library(); }