Files
DarkRoom/ui/dr-ui/ui/history.slint
T
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.

Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.

A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.

`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:03 +02:00

193 lines
7.8 KiB
Plaintext

// 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 { PanelHeading, Caption, Label, Value, Button } 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,
}
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;
}
}
}
export component HistoryPanel inherits Rectangle {
in property <[HistoryRow]> rows;
in property <bool> enabled: true;
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);
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 root.enabled && root.rows.length > 1: Value {
text: root.rows.length - 1 + (root.rows.length == 2 ? " step" : " steps");
}
}
if !root.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 root.enabled: HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Undo";
enabled: root.can-undo;
horizontal-stretch: 1;
clicked => { root.undo(); }
}
Button {
text: "Redo";
enabled: root.can-redo;
horizontal-stretch: 1;
clicked => { root.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 root.enabled && root.can-undo: Caption {
text: "Undo: " + root.undo-label;
overflow: elide;
}
if root.enabled && root.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 root.rows: StepRow {
data: row;
enabled: root.enabled;
picked => { root.picked(row.index); }
}
}
}