Files
DarkRoom/ui/dr-ui/ui/history.slint
T
dtourolle d3b6127db6 Let a photographer name the state they liked, and go back to it or look at it
FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a
comparison against a chosen one, and neither existed. The history stack
is per sitting and forgotten with it, on purpose — the gap that mattered
was an automatically saved mis-drag with no way back, and that was closed
first. What was left was the other half: a state the photographer wants
to keep *because* it is worth keeping, which is a different thing from a
step and is not served by making the steps last longer.

A snapshot is an edit state, and an edit state is exactly what a sidecar
version stores, so it is stored as one: a `[version]` block carrying
`snapshot-of = <uuid>`. The parameters, the masks and their parts, the
repairs and the film all arrive through the blocks that already carry
them, a merge keys on the uuid as it does for any version, and a build
that predates the key reads the block as a named version and keeps it —
the right failure. Only the pointer is new. The one reader that has to
know is `default_version`, which must never answer with a snapshot: a
file whose edit is missing is not a file whose edit is one of its saved
moments. The snapshots of an edit are listed by that pointer, oldest
first, the same on every device.

Writing them back removes what this sitting deleted and puts in what it
holds, and leaves standing whatever it never saw — a snapshot the other
device took since the photograph was opened here is not this device's to
remove by not knowing about it. That is the rule the version merge
already keeps, applied one level down, and it is why the save carries
the deleted ids rather than replacing the list wholesale as the masks
are. Each is re-pointed at the uuid the save settled on, because the
default may have been fused onto its canonical identity since the
snapshot was taken.

Restoring is one history step, so undo takes it back whole, as a paste
is. Taking and deleting are not steps: they change nothing about the
photograph, and an undo that removed a snapshot would be undoing a
decision to remember. Holding the eye beside one renders the snapshot
and hands the edit straight back — the same suspension "Before" uses,
against a point the photographer chose rather than the file. Two
sessions on the same photograph get ids that cannot collide, stamped
with the second and a random word, because the merge folds equal ids
into one.
2026-09-12 01:08:10 +02:00

360 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TRACES: FR-DEV-5 | FR-DEV-7
// The steps this photograph has been through, and the way back to any of them.
//
// **Nothing here names an operation, and nothing here names a step.** The rows
// arrive already resolved — Rust turns each step's localisation key into a
// display string against the UI's catalogue — so an operation added to the
// pipeline as a YAML declaration appears in this list under a sensible name
// with no change to this file, on the same terms it appears in the panel
// (FR-DEV-3c).
//
// **Why a list and not just the two buttons.** Undo answers "take back the
// last thing", which is the question a photographer asks about the mistake
// they have just noticed. It is the wrong instrument for the one they notice
// six adjustments later: eight presses, each changing the picture, with no way
// to see how far back the mistake was without passing through it. A step is a
// whole state in `dr-pipeline`, so arriving at one from six away costs what
// arriving from one does — which is what makes a row worth making clickable
// rather than decorative.
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { PanelHeading, Caption, Label, Value, Button, Field } from "widgets.slint";
// One step, flattened for Slint's model system.
export struct HistoryRow {
// Routing back to the core: this step's position in the stack. **Not** its
// position in this list, which runs the other way — see `history-rows` in
// `develop.rs` for why the two are deliberately different numbers.
index: int,
// Resolved in Rust against the UI's catalogue; the core deals in keys.
label: string,
// The state the photograph is in right now. Exactly one row carries it.
current: bool,
// A step the photographer has stepped back *out* of, still reachable by
// redo. Listed rather than hidden — redo would otherwise arrive somewhere
// this panel never mentioned — but drawn as the branch it is.
undone: bool,
}
/// TRACES: FR-DEV-5
/// One named snapshot of the open edit.
export struct SnapshotRow {
/// Routing back to the core. Opaque to this file.
id: string,
name: string,
/// The canvas is showing this one instead of the edit, while held.
comparing: bool,
}
/// TRACES: FR-DEV-5 | FR-DEV-7
/// A snapshot: press it to go back to it, hold the eye beside it to look.
component SnapshotItem inherits Rectangle {
in property <SnapshotRow> data;
in property <bool> enabled: true;
callback restored();
/// Both edges, like "Before": down shows the snapshot, up shows the edit.
callback compared(bool);
callback removed();
height: Theme.row-height;
background: root.data.comparing
? Theme.selected
: (touch.has-hover ? Theme.hover : transparent);
// Behind the two buttons, so a press on either does its own job and a
// press anywhere else on the row is the restore.
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.restored(); }
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Label {
text: root.data.name;
emphasised: root.data.comparing || touch.has-hover;
horizontal-stretch: 1;
overflow: elide;
vertical-alignment: center;
}
// GESTURE: See the photograph as a snapshot had it
// where: Develop
// touch: Press and hold the eye beside the snapshot
// pointer: Press and hold the eye beside the snapshot
// why: The same hold as "Before", against a point the
// photographer chose rather than the file: "the version
// I liked twenty minutes ago" is how a choice between two
// treatments is actually made. It takes no history step
// and changes nothing; letting go puts the edit back.
//
// TRACES: FR-DEV-7
eye := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
pointer-event(event) => {
if event.kind == PointerEventKind.down
&& event.button == PointerEventButton.left {
root.compared(true);
}
if event.kind == PointerEventKind.up
|| event.kind == PointerEventKind.cancel {
root.compared(false);
}
}
Label {
text: "◐";
emphasised: eye.has-hover || root.data.comparing;
horizontal-alignment: center;
vertical-alignment: center;
}
}
cut := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.removed(); }
Label {
text: "×";
emphasised: cut.has-hover;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
component StepRow inherits Rectangle {
in property <HistoryRow> data;
in property <bool> enabled: true;
callback picked();
height: Theme.row-height;
background: root.data.current
? Theme.selected
: (touch.has-hover ? Theme.hover : transparent);
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.picked(); }
}
HorizontalLayout {
// Small, because the panel around this already pads. The row's
// background is the highlight for the current step, so it wants to be
// a band the width of the column rather than a chip inset from it.
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
// The mark for where the photograph stands. A filled bar against the
// leading edge rather than a tick beside the name: the eye finds one
// edge down a column of forty rows, and a glyph in the text column
// would have to be read.
Rectangle {
width: 2px;
height: parent.height;
background: root.data.current ? Theme.active : transparent;
}
Label {
text: root.data.label;
emphasised: root.data.current || touch.has-hover;
horizontal-stretch: 1;
overflow: elide;
// Dimmed rather than removed: this step is a future the
// photographer stepped out of, and it is still where redo goes.
opacity: root.data.undone ? 0.45 : 1.0;
}
}
}
/// TRACES: FR-DEV-5
/// The edit as a stack of steps, and the two ways of moving through it.
///
/// A global rather than five properties and three callbacks on the panel — see
/// `session.slint` for the argument. The status strip and the keyboard reach
/// the same two actions; before this all three spellings went through the
/// window root and had to be kept in step by hand.
///
/// 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`.
export global Steps {
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> rows;
in property <bool> can-undo: false;
in property <bool> can-redo: false;
/// What undo would take back, already resolved. Empty when there is
/// nowhere to go.
in property <string> undo-label: "";
callback undo();
callback redo();
/// A row's own `index`, not its position in `rows`.
callback picked(int);
/// TRACES: FR-DEV-5
/// The named snapshots of the open edit, oldest first. Rust owns the
/// list; the panel only names, restores, deletes and holds them.
in property <[SnapshotRow]> snapshots;
/// The name typed for the next one. Empty is allowed: Rust numbers it.
callback snapshot-taken(string);
callback snapshot-restored(string);
callback snapshot-removed(string);
/// TRACES: FR-DEV-7
/// `true` on the way down, `false` on the way up.
callback snapshot-compared(string, bool);
}
export component HistoryPanel inherits Rectangle {
background: Theme.surface;
// **Flat, deliberately** — `MaskPanel` carries the long version of this
// and it applies here unchanged: a nested layout under-reports its height,
// so what follows it gets drawn on top of what came before, which is
// invisible in the source and obvious the moment anyone opens the panel.
// Every element is a direct child of the one layout that measures them,
// and the ones that come and go carry their own condition rather than
// being grouped inside a wrapper.
//
// No explicit height either, for the same reason `AdjustPanel` and
// `MaskPanel` declare none: the row count changes as the photographer
// works, and a height pinned to a layout's preferred size is one more
// thing that has to keep up with a repeater.
layout := VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// A heading, not a collapsible. `ComposePanel` carries the argument
// and it holds here: the list is last in the column, so what a lid
// would save is scrolling past nothing.
HorizontalLayout {
PanelHeading { text: "HISTORY"; }
Rectangle { horizontal-stretch: 1; }
if Develop.enabled && Steps.rows.length > 1: Value {
text: Steps.rows.length - 1 + (Steps.rows.length == 2 ? " step" : " steps");
}
}
if !Develop.enabled: Caption { text: "No image"; }
// The pair, here as well as in the status strip. The strip's copy is
// the one that survives the column being put away; this one sits
// against the list that says what it will do, which is the pairing
// that makes either of them legible.
if Develop.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Undo";
enabled: Steps.can-undo;
horizontal-stretch: 1;
clicked => { Steps.undo(); }
}
Button {
text: "Redo";
enabled: Steps.can-redo;
horizontal-stretch: 1;
clicked => { Steps.redo(); }
}
}
// What the left-hand button would take back, spelled out.
//
// On a caption rather than on the button, because `Button` sizes to
// its label and does not elide: "Undo Highlights & Shadows" is most of
// a 280px column on its own, and two of those would lever the column
// open — `MaskPanel` has the same note about an unwrapped sentence.
//
// Undo only. Redo's destination is the row directly above the mark in
// the list below, where it can be seen; the step undo takes back is
// the one the photographer is standing on and stopped tracking after a
// run of small adjustments, which is the moment they are least willing
// to press a button to find out.
if Develop.enabled && Steps.can-undo: Caption {
text: "Undo: " + Steps.undo-label;
overflow: elide;
}
// TRACES: FR-DEV-5
// Snapshots above the steps: a snapshot is the state worth keeping
// out of a run of steps, so it sits where the steps lead to.
//
// GESTURE: Keep the photograph as it is now, under a name
// where: Develop
// touch: Type a name in the History panel and press Snapshot
// pointer: Type a name in the History panel and press Snapshot
// why: The history is forgotten with the sitting, on purpose;
// a snapshot is the photographer saying this one should
// not be. It is written into the sidecar as a version
// of the edit, so it survives a restart and reaches the
// other device. Pressing a snapshot puts the photograph
// back to it, as one step that undo takes back whole.
if Develop.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
name := Field {
label: "Snapshot name";
placeholder: "Name this state";
horizontal-stretch: 1;
}
Button {
text: "Snapshot";
clicked => {
Steps.snapshot-taken(name.text);
name.text = "";
}
}
}
for snapshot in Steps.snapshots: SnapshotItem {
data: snapshot;
enabled: Develop.enabled;
restored => { Steps.snapshot-restored(snapshot.id); }
compared(down) => { Steps.snapshot-compared(snapshot.id, down); }
removed => { Steps.snapshot-removed(snapshot.id); }
}
if Develop.enabled && Steps.rows.length > 0: Rectangle {
height: 1px;
background: Theme.rule;
}
// Newest first. The list is consulted to take back something just
// done rather than browsed from the beginning — the order
// `dr_catalog::trash` settled on for the same question — and it keeps
// the end being worked at against the heading rather than sixty-four
// rows below it.
for row in Steps.rows: StepRow {
data: row;
enabled: Develop.enabled;
picked => { Steps.picked(row.index); }
}
}
}