Files
DarkRoom/core/dr-pipeline/src/history.rs
T
dtourolleandClaude Opus 5 2958444835 Let undo reach the repairs before the tool can make one
A history state was a Preset — a map of scalars — which was right while
every edit in the graph was a parameter. A spot is not one, and undo is the
first thing anyone does with a repair: place it, dislike it, take it back.
Left as it was, that press would have stepped some unrelated slider and
left the spot on the photograph, which reads as undo being broken rather
than as undo being absent.

So a state is now the pair, params and spot set. The same door is the one
the mask stack will come through: mask edits are outside undo today for
exactly this reason, and FR-DEV-5 is not finished until they are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:27:34 +02:00

650 lines
25 KiB
Rust

//! TRACES: FR-DEV-5
//! Stepping an edit backwards, and forwards again.
//!
//! # A stack of snapshots, not a stack of commands
//!
//! The edit graph is plain data, and [`Preset::capture`] already reduces it to
//! the values that differ from default. So a history is a list of those, and
//! undo is [`Preset::apply`] at full scope — the same two calls the clipboard
//! and the sidecar are built from.
//!
//! The alternative, a command per action with an inverse beside it, would be a
//! second thing every operation had to register. Operations are *declared*
//! (FR-DEV-3c): a new node is a YAML file and appears in the panel with no
//! code written for it, and it must appear in the history on the same terms.
//! A snapshot knows nothing about which operations exist, so it cannot fall
//! behind them.
//!
//! # What makes forty events one step
//!
//! A drag emits a change per frame. Recorded naively that is forty undo steps,
//! thirty-nine of which are positions the photographer's finger passed
//! through rather than decisions they made — and undo would rewind a gesture
//! at forty presses to the millimetre.
//!
//! Nothing reports a gesture boundary. No slider, curve point or crop handle
//! says "the finger is down", and threading that out of every control would be
//! a lot of surface for a bookkeeping concern — this is the same conclusion
//! `dr-ui`'s render coalescing reached, and it stands in the same place. What
//! stands in for the boundary here is **the control plus recency**: two
//! changes to the same control within [`COALESCE_WINDOW`] amend one step, and
//! anything else opens a new one. A control the user let go of and came back
//! to a second later is one step rather than two, and that is the honest cost
//! of not having the boundary; a pause long enough to be a decision is not.
//!
//! Which control is "the same control" is [`Edit`], and it is asked of the
//! graph rather than hardcoded: an operation whose
//! [`Presentation`](crate::Presentation) claims a parameter is an operation
//! where one gesture moves several at once — a curve
//! point carries an x and a y — so those coalesce as a single widget. Nothing
//! here names the tone curve.
//!
//! # What this is not, yet
//!
//! FR-DEV-5 also asks for history persisted with the catalog and named
//! snapshots. This is per-session and in memory: closing the image forgets it.
//! The gap that mattered was that an automatically saved mis-drag had no way
//! back at all, and that is what this closes.
use std::time::{Duration, Instant};
use crate::descriptor::{OpId, ParamId};
use crate::graph::EditGraph;
use crate::preset::{Preset, Scope};
/// TRACES: FR-DEV-5 | NFR-RES-1
/// How many states are held, the current one included.
///
/// Bounded because memory is a stated budget (NFR-RES-1) and a develop session
/// can be open for hours. A snapshot costs only what the edit moved off
/// default — a heavily worked frame with the colour mixer engaged is on the
/// order of a hundred entries, so the whole stack is a few hundred kilobytes
/// at worst and a few kilobytes in practice.
///
/// Sixty-four rather than a number derived from a byte budget: the thing being
/// bounded is *steps a photographer would want back*, and past a few dozen the
/// answer is a preset or a reset rather than more presses of undo. A byte cap
/// would make the depth depend on how complicated the edit is, which is
/// exactly backwards — the elaborate edit is the one worth stepping through.
pub const DEPTH: usize = 64;
/// TRACES: FR-DEV-5
/// How long the same control may keep amending its own step.
///
/// Above the pause a finger makes mid-drag — a slider is often held still
/// while the sharp frame catches up, and `dr-ui` waits 120 ms before drawing
/// it — and below the pause that reads as having finished and thought again.
pub const COALESCE_WINDOW: Duration = Duration::from_millis(700);
/// TRACES: FR-DEV-5
/// Which control a change came from, for deciding whether two changes are one
/// gesture.
///
/// Not "what changed" — the snapshot already carries that. This exists purely
/// so that consecutive changes can be told apart from one continuing change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Edit {
/// One parameter's own control, dragged.
Param(OpId, ParamId),
/// A whole operation, where one gesture moves several of its parameters at
/// once — a curve point, or a crop rectangle's four edges.
Op(OpId),
/// A change with no gesture behind it: a reset, a paste, a flip, a quarter
/// turn. Never coalesces, not even with an identical one, because two
/// clicks are two decisions however quickly they follow each other.
Discrete,
}
impl Edit {
/// The key a change to one parameter coalesces under.
///
/// A parameter a [`Presentation`](crate::Presentation) claims is not
/// dragged on its own — the widget owning it moves its siblings in the
/// same gesture — so the key is the operation. Asked of the graph rather
/// than listed here, so an operation that declares a compound widget gets
/// the right undo granularity by declaring it (FR-DEV-3c).
pub fn for_param(graph: &EditGraph, op: OpId, param: ParamId) -> Self {
let grouped = graph
.capabilities()
.into_iter()
.any(|cap| cap.id == op && cap.presentation.is_some_and(|p| p.params.contains(&param)));
if grouped {
Self::Op(op)
} else {
Self::Param(op, param)
}
}
/// Whether a second change to this same control continues the first.
fn is_gesture(self) -> bool {
!matches!(self, Self::Discrete)
}
}
/// TRACES: FR-DEV-5
/// One image's undo stack.
///
/// Holds *states*, not differences: `states[cursor]` is what the graph shows
/// now, everything before it is where undo goes, and everything after it is
/// where redo goes. `states` is never empty — the state the history was opened
/// on is the floor, and undo stops there rather than at nothing.
pub struct History {
states: Vec<State>,
cursor: usize,
/// What produced `states[cursor]`, and when — the pair that decides
/// whether the next change amends it. `None` means the current step is
/// closed: nothing may be folded into it.
last: Option<(Edit, Instant)>,
}
/// TRACES: FR-DEV-5 | FR-DEV-8
/// One remembered state of an edit.
///
/// # Why this is not simply a `Preset`
///
/// It was, and that was right while every edit in the graph was a parameter.
/// A [`Preset`] is a map of scalars, which is exactly what the module
/// documentation above says a snapshot should be — nothing to register, nothing
/// to fall behind the operation set.
///
/// A repair is not a scalar (see [`crate::spot`]), and undo is the single most
/// expected thing to do with one: place a spot, dislike it, take it back. If
/// the snapshot could not carry the spot set, that press would step some
/// unrelated slider instead and leave the repair on the photograph — which is
/// worse than no undo at all, because it looks like undo is broken rather than
/// absent.
///
/// So the state is the pair. The same door is what the mask stack will come
/// through — masks are outside undo today for precisely this reason, and
/// FR-DEV-5 is not finished until they are not.
#[derive(Debug, Clone, PartialEq)]
struct State {
params: Preset,
spots: crate::spot::SpotSet,
}
impl State {
fn capture(graph: &EditGraph) -> Self {
Self {
params: Preset::capture(graph),
spots: graph.spots().clone(),
}
}
fn apply(&self, graph: &mut EditGraph) {
self.params.apply(graph, Scope::Everything);
*graph.spots_mut() = self.spots.clone();
}
}
impl History {
/// Start from the state `graph` is in.
pub fn new(graph: &EditGraph) -> Self {
Self {
states: vec![State::capture(graph)],
cursor: 0,
last: None,
}
}
/// Forget everything and take `graph` as the new floor.
///
/// What opening an image does. A photograph's stored edit is not something
/// the user did in this session, so undo must not reach behind it and
/// silently discard work from a previous one.
pub fn reset(&mut self, graph: &EditGraph) {
*self = Self::new(graph);
}
/// Note that `edit` has just changed `graph`.
///
/// Returns whether a step was opened or amended — `false` when the graph
/// holds what it already held, which happens whenever a control re-emits
/// its current value or a clamp swallows a movement.
pub fn record(&mut self, graph: &EditGraph, edit: Edit) -> bool {
self.record_at(graph, edit, Instant::now())
}
/// [`Self::record`] with the clock supplied, so coalescing can be tested
/// without sleeping.
pub fn record_at(&mut self, graph: &EditGraph, edit: Edit, at: Instant) -> bool {
let state = State::capture(graph);
if self.states.get(self.cursor) == Some(&state) {
return false;
}
if self.coalesces(edit, at) {
if let Some(top) = self.states.get_mut(self.cursor) {
*top = state;
self.last = Some((edit, at));
return true;
}
}
// A new step abandons the redo tail: the future that was undone away
// is no longer reachable from here, and keeping it would let redo jump
// to a state this one was never derived from.
self.states.truncate(self.cursor + 1);
self.states.push(state);
self.cursor = self.states.len().saturating_sub(1);
// Oldest first, so what is lost is the part furthest from where the
// user is working.
let excess = self.states.len().saturating_sub(DEPTH);
if excess > 0 {
self.states.drain(..excess);
self.cursor = self.cursor.saturating_sub(excess);
}
self.last = Some((edit, at));
true
}
/// Whether `edit` at `at` continues the step already on top.
fn coalesces(&self, edit: Edit, at: Instant) -> bool {
// Never over the floor. The state the image opened in is what the
// first undo has to return to, and folding the first change into it
// would make that state unreachable.
if self.cursor == 0 {
return false;
}
let Some((last, when)) = self.last else {
return false;
};
edit.is_gesture() && last == edit && at.saturating_duration_since(when) <= COALESCE_WINDOW
}
pub fn can_undo(&self) -> bool {
self.cursor > 0
}
pub fn can_redo(&self) -> bool {
self.cursor + 1 < self.states.len()
}
/// Step `graph` back one, returning whether there was anywhere to go.
///
/// Applied at [`Scope::Everything`]: framing is as undoable as colour, and
/// a crop drag the user wants back is one of the likelier reasons to
/// reach for this. The view survives, because [`Preset::apply`] preserves
/// it — an undo that also jumped the viewport would read as navigation.
pub fn undo(&mut self, graph: &mut EditGraph) -> bool {
let Some(target) = self.cursor.checked_sub(1) else {
return false;
};
self.restore(graph, target)
}
/// Step `graph` forward one, returning whether there was anywhere to go.
pub fn redo(&mut self, graph: &mut EditGraph) -> bool {
self.restore(graph, self.cursor + 1)
}
fn restore(&mut self, graph: &mut EditGraph, target: usize) -> bool {
let Some(state) = self.states.get(target).cloned() else {
return false;
};
state.apply(graph);
self.cursor = target;
// Closes the current step. Without this, a slider moved immediately
// after an undo would fold into the step it was just undone out of —
// and the state the user had just recovered would be overwritten by
// the very edit they made from it.
self.last = None;
true
}
/// How many states are held, the current one included.
///
/// Never zero. Reported rather than inferred so a caller can say how far
/// back it can go without walking the stack.
pub fn depth(&self) -> usize {
self.states.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::framing;
use crate::ops::{curve, exposure, saturation};
use crate::CropRect;
fn at(base: Instant, ms: u64) -> Instant {
base + Duration::from_millis(ms)
}
/// A slider dragged from `from` to `to` in `steps`, one event per frame at
/// 60 Hz — what the coalescing has to survive.
fn drag(h: &mut History, g: &mut EditGraph, base: Instant, from: f32, to: f32, steps: u32) {
for i in 1..=steps {
let v = from + (to - from) * (i as f32 / steps as f32);
g.set_param(exposure::ID, exposure::EXPOSURE, v);
h.record_at(
g,
Edit::for_param(g, exposure::ID, exposure::EXPOSURE),
at(base, u64::from(i) * 16),
);
}
}
#[test]
fn a_whole_drag_of_one_slider_is_a_single_undo_step() {
// The reason this type is not a plain stack. A drag emits an event per
// frame; recorded one apiece, undo would rewind the gesture in
// millimetres and forty presses would not reach the start of it.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
drag(&mut h, &mut g, base, 0.0, 1.5, 40);
assert_eq!(h.depth(), 2, "the drag should have added exactly one state");
assert!(h.undo(&mut g));
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert!(!h.can_undo(), "undo went behind the state it opened in");
}
#[test]
fn a_pause_ends_the_gesture_so_the_next_move_is_its_own_step() {
// The other half of coalescing. Without a time bound, every change a
// photographer ever made to the exposure slider — across the whole
// session — would collapse into one step, and undo would throw away an
// hour of decisions in a single press.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record_at(&g, Edit::Param(exposure::ID, exposure::EXPOSURE), base);
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
h.record_at(
&g,
Edit::Param(exposure::ID, exposure::EXPOSURE),
at(base, COALESCE_WINDOW.as_millis() as u64 + 1),
);
assert_eq!(h.depth(), 3);
assert!(h.undo(&mut g));
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
}
#[test]
fn two_controls_moved_in_quick_succession_are_two_steps() {
// Coalescing keys on the control, not on time alone. Were it time
// alone, reaching straight from exposure to saturation would fuse two
// unrelated decisions and undo would take back the one the user did
// not ask about.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record_at(&g, Edit::Param(exposure::ID, exposure::EXPOSURE), base);
g.set_param(saturation::ID, saturation::SATURATION, 30.0);
h.record_at(
&g,
Edit::Param(saturation::ID, saturation::SATURATION),
at(base, 16),
);
assert!(h.undo(&mut g));
assert_eq!(g.param(saturation::ID, saturation::SATURATION), Some(0.0));
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.0),
"undoing the saturation must not take the exposure with it"
);
}
#[test]
fn a_curve_point_drag_is_one_step_although_it_moves_two_parameters() {
// A curve point is dragged in x and y together, so the events
// alternate between two parameters. Keyed per parameter, *every* event
// would look like a new control and the coalescing would never fire —
// which is the case that made the key ask the graph about the
// operation's presentation rather than assume one parameter per
// widget.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
for i in 1..=20u32 {
let t = i as f32 / 40.0;
for (param, value) in [(curve::P1_X, 0.25 + t * 0.1), (curve::P1_Y, 0.25 + t * 0.3)] {
g.set_param(curve::ID, param, value);
h.record_at(
&g,
Edit::for_param(&g, curve::ID, param),
at(base, u64::from(i) * 16),
);
}
}
assert_eq!(h.depth(), 2, "the curve drag fragmented into steps");
}
#[test]
fn a_crop_drag_is_one_step_and_undo_gives_the_composition_back() {
// Framing is undoable on the same terms as colour. A crop is the edit
// a mis-drag destroys most visibly, and it is stored as four
// parameters moved by one gesture — so it is keyed on the operation.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
for i in 1..=15u32 {
let w = 1.0 - i as f32 * 0.04;
g.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: w,
height: w,
});
h.record_at(&g, Edit::Op(framing::ID), at(base, u64::from(i) * 16));
}
assert_eq!(h.depth(), 2);
assert!(h.undo(&mut g));
assert!(
g.crop().is_full(),
"the frame did not come back: {:?}",
g.crop()
);
}
#[test]
fn a_reset_never_folds_into_the_change_before_it() {
// `Discrete` must not coalesce even with itself. Two resets in quick
// succession — the geometry section then the panel — are two actions,
// and folding them would make one press of undo restore neither
// completely.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record_at(&g, Edit::Discrete, base);
g.set_param(saturation::ID, saturation::SATURATION, 20.0);
h.record_at(&g, Edit::Discrete, at(base, 5));
assert_eq!(h.depth(), 3);
}
#[test]
fn a_change_that_moves_nothing_opens_no_step() {
// A slider re-emitting its current value, or a drag pushed past a
// clamp so the graph stops moving. Recorded anyway, undo would need
// several presses to visibly do anything, and holding a slider against
// its end stop would quietly consume the whole history.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
assert!(h.record(&g, Edit::Discrete));
// Clamped at the descriptor's ceiling, so this asks for no movement.
g.set_param(exposure::ID, exposure::EXPOSURE, 120.0);
assert!(!h.record(&g, Edit::Discrete));
assert_eq!(h.depth(), 2);
}
#[test]
fn redo_puts_back_exactly_what_undo_took() {
// Checked over the whole chain rather than the parameter that moved:
// undo restores by *replacing* the graph's state, so an operation that
// survived the round trip only because it happened to be neutral would
// pass a narrower assertion.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
g.set_param(saturation::ID, saturation::SATURATION, -40.0);
g.set_param(framing::ID, framing::ANGLE, 3.0);
h.record(&g, Edit::Discrete);
let after = Preset::capture(&g);
assert!(h.undo(&mut g));
assert!(h.redo(&mut g));
assert_eq!(Preset::capture(&g), after);
assert!(!h.can_redo());
}
#[test]
fn editing_after_an_undo_discards_the_future() {
// The branch the user chose not to take must not be reachable. Left
// in place, redo would jump to a state derived from an edit that no
// longer exists — values from a version of the photograph that was
// never on screen.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record(&g, Edit::Discrete);
assert!(h.undo(&mut g));
g.set_param(saturation::ID, saturation::SATURATION, 25.0);
h.record(&g, Edit::Discrete);
assert!(!h.can_redo());
assert_eq!(h.depth(), 2);
}
#[test]
fn an_edit_straight_after_an_undo_does_not_overwrite_what_was_recovered() {
// Undo closes the open step. Without that, moving the same slider
// again within the coalescing window would amend the step the undo had
// just stepped out of, and the recovered state would be lost with no
// action having discarded it.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record_at(&g, Edit::Param(exposure::ID, exposure::EXPOSURE), base);
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
h.record_at(
&g,
Edit::Param(exposure::ID, exposure::EXPOSURE),
at(base, 2000),
);
assert!(h.undo(&mut g));
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
g.set_param(exposure::ID, exposure::EXPOSURE, 1.1);
h.record_at(
&g,
Edit::Param(exposure::ID, exposure::EXPOSURE),
at(base, 2010),
);
assert!(h.undo(&mut g));
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.0),
"the state the undo recovered was folded away"
);
}
#[test]
fn the_stack_is_bounded_and_forgets_the_oldest_first() {
// NFR-RES-1. A develop session stays open for hours, and an unbounded
// stack would grow with every gesture for all of them. What it drops
// is the far end, because that is the part furthest from where the
// user is working.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
let base = Instant::now();
for i in 1..=(DEPTH as u32 * 2) {
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.01);
h.record_at(&g, Edit::Discrete, at(base, u64::from(i) * 1000));
}
assert_eq!(h.depth(), DEPTH);
// Every step still present is reachable, and the walk stops at the
// floor rather than running off the end.
let mut steps = 0;
while h.undo(&mut g) {
steps += 1;
}
assert_eq!(steps, DEPTH - 1);
}
#[test]
fn reopening_an_image_does_not_leave_the_previous_ones_history_behind() {
// A session is reused across photographs. Were the stack carried over,
// one press of undo on a freshly opened frame would apply the previous
// frame's edit to it — the paste nobody asked for.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record(&g, Edit::Discrete);
let mut next = EditGraph::default_chain();
next.set_param(saturation::ID, saturation::SATURATION, 10.0);
h.reset(&next);
assert!(!h.can_undo());
assert!(!h.can_redo());
assert_eq!(h.depth(), 1);
}
#[test]
fn undo_leaves_the_view_where_the_user_was_looking() {
// Zoom is navigation, not an edit — the same rule `Preset::apply`
// keeps for a paste. An undo that refitted the frame would read as
// having moved the photograph rather than having taken back a change.
let mut g = EditGraph::default_chain();
let mut h = History::new(&g);
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
h.record(&g, Edit::Discrete);
g.framing_mut().set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.25,
height: 0.25,
});
assert!(h.undo(&mut g));
assert!(g.framing().is_zoomed(), "the undo threw away the viewport");
}
#[test]
fn the_key_for_an_ungrouped_parameter_is_the_parameter_itself() {
// The complement of the curve case: most operations are plain sliders,
// and keying those on the operation would fuse two of an operation's
// sliders — temperature and tint — into one undo step.
let g = EditGraph::default_chain();
assert_eq!(
Edit::for_param(&g, exposure::ID, exposure::EXPOSURE),
Edit::Param(exposure::ID, exposure::EXPOSURE)
);
assert_eq!(
Edit::for_param(&g, curve::ID, curve::P1_X),
Edit::Op(curve::ID)
);
}
}