Give a mis-drag a way back

Develop edits now save themselves to a sidecar the moment you leave the
image, so until this there was no way to undo one — the mistake was
persisted and the only recourse was to remember the old number.

The history is a stack of snapshots, because the edit graph is already
plain data: `Preset::capture` reduces it to what differs from default and
`Preset::apply` puts it back, so undo is those two calls and nothing else.
A command object per action, with an inverse beside it, would have been a
second thing every operation had to register — and operations are declared
in YAML precisely so that a new one needs no code written for it. A
snapshot cannot fall behind them.

The interesting part is coalescing. A slider drag emits an event per frame
and must be one step, not forty. Nothing in the interface reports a gesture
boundary — the same wall the render coalescing hit, and it is answered the
same way rather than by threading a "finger is down" out of every slider,
curve point and crop handle. What stands in for the boundary is the control
plus recency: changes to the same control within 700 ms amend one step.
Which control is "the same" is asked of the graph, not listed: an operation
whose declared presentation claims a parameter is one where a single
gesture moves several — a curve point carries an x and a y — so those
coalesce as one widget. Nothing in the history names the tone curve.

The compromise, and it is a real one: a control let go of and picked up
again within the window is one step rather than two. Buying the other
answer costs a gesture-boundary signal on every control, which is more
surface than the difference is worth.

The stack is bounded at 64 states for NFR-RES-1 — a develop session stays
open for hours. Sixty-four rather than a byte cap: what is being bounded is
steps a photographer would want back, and a byte cap would give the
elaborate edit the shallowest history, which is exactly backwards.

The session owns its history and every mutator records into it, so the
callbacks in `lib.rs` cannot change the edit and forget to — with a dozen
generic callbacks that would have been one press of undo away from wrong
every time a control was added. Opening a photograph makes its stored edit
the floor rather than a step: it is not work done in this sitting, and an
undo reaching behind it would discard a previous session's edit and then
save that on the way out.

Not yet done, from FR-DEV-5: history is per-session and in memory, and
there are no named snapshots. What mattered was that a saved mis-drag had
no way back at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:03:34 +02:00
co-authored by Claude Opus 5
parent 2330ed25e9
commit 7f524d2fd0
5 changed files with 779 additions and 2 deletions
+51
View File
@@ -33,11 +33,16 @@ component StatusBar inherits Rectangle {
/// dialogue: an export that succeeded needs no acknowledging, and one
/// that failed needs its reason where the retry is.
in property <string> export-status;
/// Whether the edit can be stepped either way (FR-DEV-5).
in property <bool> can-undo: false;
in property <bool> can-redo: false;
callback back-to-library();
callback open-settings();
callback toggle-panel();
callback export-image();
callback undo();
callback redo();
// 44px and `surface`, the same bar the library and settings draw.
//
@@ -86,6 +91,25 @@ component StatusBar inherits Rectangle {
Caption { text: root.layout-class; }
// Undo and redo, in the strip rather than in the develop column: the
// panel can be put away, and the one control that takes back a
// mis-drag must not go away with it. Disabled rather than hidden, so
// the pair keeps its place and the keyboard shortcut has something
// visible to correspond to.
Button {
text: "Undo";
enabled: root.can-undo;
y: (parent.height - self.height) / 2;
clicked => { root.undo(); }
}
Button {
text: "Redo";
enabled: root.can-redo;
y: (parent.height - self.height) / 2;
clicked => { root.redo(); }
}
// Show or hide the develop column. On a tablet the panel is 280px of a
// screen that is mostly photograph, and the whole point of opening an
// image is to look at it — so being able to put the instruments away
@@ -253,6 +277,16 @@ export component AppWindow inherits Window {
callback straighten-changed(float);
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback framing-reset();
// --- undo and redo (FR-DEV-5) ---
//
// Rust owns whether there is anywhere to step, because "anywhere" is a
// position in a stack of snapshots this side never sees. A whole drag is
// one step; the coalescing that makes it so lives in `dr-pipeline`.
in property <bool> can-undo: false;
in property <bool> can-redo: false;
callback undo();
callback redo();
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
@@ -1015,10 +1049,14 @@ in property <bool> panel-visible: true;
export-label: root.export-label;
export-busy: root.export-busy;
export-status: root.export-status;
can-undo: root.can-undo;
can-redo: root.can-redo;
back-to-library() => { root.back-to-library(); }
open-settings() => { root.settings-open(); }
toggle-panel() => { root.toggle-panel(); }
export-image() => { root.export-image(); }
undo() => { root.undo(); }
redo() => { root.redo(); }
}
HorizontalLayout {
@@ -1371,6 +1409,19 @@ in property <bool> panel-visible: true;
init => { self.focus(); }
key-pressed(event) => {
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
// of the letter, because the logical key that
// reaches us carries the shift: holding it for
// redo turns "z" into "Z".
if (event.modifiers.control
&& (event.text == "z" || event.text == "Z")) {
if (event.modifiers.shift) {
root.redo();
} else {
root.undo();
}
return accept;
}
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;