Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a mistake just noticed. It is the wrong instrument for one noticed six adjustments later: eight presses, each changing the picture, with no way to see how far back the mistake is without passing through it. A step is a whole state, so arriving from six away costs what arriving from one does — which is what makes a row worth making clickable rather than decorative. `Edit::Discrete` had to go for the list to be worth drawing. Seventeen call sites recorded the same anonymous step, which is fine for deciding whether two changes are one gesture and useless for a panel: seventeen rows reading "Discrete" is not a history. Every variant now carries enough to name itself, and the compiler enumerated the sites that had to start saying so. A step that moved a parameter is still named out of the descriptor, so an operation added as a YAML declaration appears in the history correctly named with nothing written for it (FR-DEV-3c). Choosing a film stock was not undoable at all. The pick went straight to `choose_film`, which nothing on the history's path ever sees. `pick_film` records it, and is separate because the same call is also how a *restored* edit gets its tables back — recording that would push a step for the undo the photographer had just asked for. The list is rebuilt off a revision rather than off every redraw. A drag ends in a redraw per frame while folding into one step, so the unconditional version would tear down and recreate every row sixty times a second to arrive back at the list already on screen. The counter is process-wide: a per-instance one starts every photograph at the same number, so a frontend holding "the revision I last drew" would keep the previous image's steps on screen — invisible while every image opens with one identical row, and a wrong-photograph bug the moment persisted history means it does not. The step names that no descriptor can supply are constants with a roll, and a test walks the roll rather than a second copy of it. `resolve` splits so that "is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into "Mask Toggled", which names a field rather than an act and, being perfectly readable, is a mistake nobody would look at twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1285 lines
52 KiB
Rust
1285 lines
52 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 [`EditGraph::state`] reduces it to
|
|
//! exactly what an edit is. So a history is a list of those, and undo is
|
|
//! [`EditGraph::set_state`] — the same two calls the sidecar is 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.
|
|
//!
|
|
//! # The snapshot has to be of the whole edit
|
|
//!
|
|
//! That last claim held for the operations and stopped holding for everything
|
|
//! else. This stack used to snapshot a [`Preset`](crate::Preset), which is the
|
|
//! parameter map — and a mask layer is not a parameter, a film stock is not a
|
|
//! parameter, and a repair is not a parameter, all three deliberately so.
|
|
//!
|
|
//! It went wrong three times, in two different ways, which is the argument for
|
|
//! not leaving it to anyone's memory:
|
|
//!
|
|
//! - **The masks and the stock went missing quietly.** Drawing a mask changed
|
|
//! nothing a `Preset` could see, so [`History::record`] returned `false`, no
|
|
//! step was opened, and the interface went on calling it in good faith. The
|
|
//! layer a photographer had just painted had no way back, and nothing
|
|
//! panicked, no test failed, and the only signal available was a `bool`
|
|
//! nobody was reading.
|
|
//!
|
|
//! - **The repairs would have gone missing loudly.** Undo is the single most
|
|
//! expected thing to do with a spot — place it, dislike it, take it back —
|
|
//! and a snapshot that could not carry the spot set would have stepped some
|
|
//! unrelated slider instead and left the repair on the photograph. That is
|
|
//! worse than no undo at all, because it looks like undo is *broken* rather
|
|
//! than absent. It did not happen: [`EditState`] refused to compile when the
|
|
//! spot set arrived in the graph, which is what that type is for.
|
|
//!
|
|
//! So the snapshot is an [`EditState`], and what makes that stay true is a
|
|
//! compiler error rather than a habit — see [`crate::state`].
|
|
//!
|
|
//! # 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, and FR-DEV-7 for comparing against a chosen history state.
|
|
//! This is per-session and in memory, with no way to name a step or to jump
|
|
//! to one: 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::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use crate::descriptor::{LocalizedKey, OpId, ParamId};
|
|
use crate::graph::EditGraph;
|
|
use crate::graph::OpCapability;
|
|
use crate::state::{EditState, FilmRebake};
|
|
|
|
/// 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
|
|
/// What the state a photograph opened in is called.
|
|
///
|
|
/// A step like any other in the list, because it is one: it is where undo
|
|
/// stops, and a list whose first row is blank would leave the floor looking
|
|
/// like a missing entry rather than the beginning.
|
|
pub const OPENED: LocalizedKey = LocalizedKey("history.opened");
|
|
|
|
/// The fallback for a step naming an operation or parameter this build no
|
|
/// longer has.
|
|
///
|
|
/// Reachable only across a version skew, and it resolves to a readable word
|
|
/// rather than to nothing: a row the user cannot identify is still better than
|
|
/// a row that is not there, since the step exists and undo will pass through
|
|
/// it either way.
|
|
pub const UNNAMED: LocalizedKey = LocalizedKey("history.edit");
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// Which control a change came from.
|
|
///
|
|
/// Two jobs, and they used to be one. Deciding whether consecutive changes are
|
|
/// one continuing gesture needs only an *identity* — that was the whole of
|
|
/// this type, and `Discrete` was a perfectly good name for "some control, no
|
|
/// gesture". Listing the steps needs each one to say what it **was**, and
|
|
/// seventeen rows reading "Discrete" is not a history. So every variant now
|
|
/// carries enough to name itself, and the compiler enumerated the call sites
|
|
/// that had to start saying so.
|
|
///
|
|
/// It is still not "what changed" — the snapshot carries that.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Edit {
|
|
/// One parameter's own control, dragged. Named by its descriptor.
|
|
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. Named by its
|
|
/// descriptor.
|
|
Op(OpId),
|
|
/// A dragged control the graph does not own: a mask layer's feather, its
|
|
/// opacity, a gradient handle. Coalesces like a slider, because it is one
|
|
/// — but there is no descriptor to ask for a name, so it carries its own.
|
|
Control(LocalizedKey),
|
|
/// A change with no gesture behind it: a reset, a paste, a flip, a quarter
|
|
/// turn, a layer added. Never coalesces, not even with an identical one,
|
|
/// because two clicks are two decisions however quickly they follow each
|
|
/// other — which is also why the key is not enough to tell two of them
|
|
/// apart and does not have to be.
|
|
Action(LocalizedKey),
|
|
}
|
|
|
|
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(¶m)));
|
|
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::Action(_))
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// What to call this step in a list of them.
|
|
///
|
|
/// A [`LocalizedKey`], never a string: resolving one needs a localiser and
|
|
/// `core/` must not depend on one (NFR-A11Y-1). The frontend has the
|
|
/// catalogue, and an uncatalogued key derives a readable fallback — so an
|
|
/// operation added as a YAML declaration appears in the history under a
|
|
/// sensible name with no code written for it, on the same terms it appears
|
|
/// in the panel (FR-DEV-3c).
|
|
///
|
|
/// Takes the capabilities rather than the graph so that listing a whole
|
|
/// stack walks the chain once instead of once per step.
|
|
pub fn label(self, caps: &[OpCapability]) -> LocalizedKey {
|
|
match self {
|
|
Self::Param(op, param) => caps
|
|
.iter()
|
|
.find(|c| c.id == op)
|
|
.and_then(|c| c.params.iter().find(|p| p.id == param))
|
|
.map(|p| p.label)
|
|
.unwrap_or(UNNAMED),
|
|
Self::Op(op) => caps
|
|
.iter()
|
|
.find(|c| c.id == op)
|
|
.map(|c| c.label)
|
|
.unwrap_or(UNNAMED),
|
|
Self::Control(key) | Self::Action(key) => key,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-DEV-7
|
|
/// One row of the history, for a frontend that lists them.
|
|
///
|
|
/// Carries its own [`Self::index`] rather than leaving the caller to infer one
|
|
/// from a position in the returned list. The list a photographer reads runs
|
|
/// newest-first — the same order the trash is reviewed in, and for the same
|
|
/// reason: it is consulted to undo a recent mistake rather than browsed
|
|
/// chronologically — so the row's position and its position in the stack are
|
|
/// deliberately not the same number, and a frontend doing that arithmetic
|
|
/// itself is a frontend that will one day jump to the wrong state.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Entry {
|
|
/// Where this state sits in the stack. What [`History::go_to`] takes.
|
|
pub index: usize,
|
|
/// What to call it. A localisation key; the frontend resolves it.
|
|
pub label: LocalizedKey,
|
|
/// Whether this is the state the graph is showing now.
|
|
pub current: bool,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// What one press of undo or redo did.
|
|
///
|
|
/// Not a `bool`, because stepping is not the only outcome a caller has to act
|
|
/// on: a step that crosses a change of film leaves the graph without its
|
|
/// tables, and only the caller can bake them back (see [`FilmRebake`]). A
|
|
/// `bool` would let that be dropped by writing nothing at all, which is the
|
|
/// shape of mistake this module has already made once.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
#[must_use = "a step that is not acted on leaves the panel showing the old values"]
|
|
pub enum Step {
|
|
/// Nowhere to go — the stack is at its floor, or at its head.
|
|
Nowhere,
|
|
/// The graph now holds the neighbouring state, and this is what it is
|
|
/// still owed.
|
|
Took(FilmRebake),
|
|
}
|
|
|
|
impl Step {
|
|
/// Whether the graph moved.
|
|
///
|
|
/// The panel has to be rebuilt when it did: undo replaces the values the
|
|
/// controls are showing and nothing here pushes them.
|
|
pub fn moved(&self) -> bool {
|
|
matches!(self, Self::Took(_))
|
|
}
|
|
|
|
/// The film this step needs baked back, if any.
|
|
pub fn rebake(&self) -> Option<&crate::state::FilmRef> {
|
|
match self {
|
|
Self::Nowhere => None,
|
|
Self::Took(film) => film.wanted(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hands out revisions, shared by every [`History`] in the process.
|
|
///
|
|
/// See [`History::revision`]. `Relaxed` because the only thing required of
|
|
/// these values is that they differ: nothing is published through the counter,
|
|
/// and a frontend comparing two of them is doing so on its own thread.
|
|
static REVISIONS: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn next_revision() -> u64 {
|
|
REVISIONS.fetch_add(1, Ordering::Relaxed)
|
|
}
|
|
|
|
/// A state, and what put the graph into it.
|
|
///
|
|
/// The edit is kept for the *list*, not for the stepping: undo restores by
|
|
/// replacing the whole state, so it never needs to know what the change was.
|
|
/// It needs to know only in order to say so.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct Snapshot {
|
|
state: EditState,
|
|
/// `None` for the floor, which nothing in this session produced.
|
|
edit: Option<Edit>,
|
|
}
|
|
|
|
/// 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<Snapshot>,
|
|
cursor: usize,
|
|
/// A value that changes whenever [`Self::entries`] would read differently,
|
|
/// and that **no other history will ever report**.
|
|
///
|
|
/// For a frontend that draws the list. Rebuilding it costs a walk of the
|
|
/// chain, sixty-odd label lookups and — the part that actually hurts — a
|
|
/// model reset that makes the toolkit tear down and rebuild every row. A
|
|
/// drag emits a change per frame and coalesces into the step already on
|
|
/// top, so during the one gesture where that cost would be paid sixty
|
|
/// times a second, the list is not changing at all.
|
|
///
|
|
/// A counter rather than the `(depth, cursor)` pair it is tempting to
|
|
/// compare instead: stepping back one and then editing truncates the tail
|
|
/// and pushes a replacement, which can land on the same depth and the same
|
|
/// cursor with a different step on top. The pair would call that unchanged
|
|
/// and the panel would name the branch the photographer just abandoned.
|
|
///
|
|
/// **Drawn from a counter shared by every history in the process**, which
|
|
/// is the part that is easy to leave out and expensive to add back. A
|
|
/// per-instance counter starts each photograph at the same number, so a
|
|
/// frontend holding "the revision I last drew" sees a *stale* value match
|
|
/// a *fresh* history and keeps the previous photograph's list on screen.
|
|
/// Today that is invisible, because a freshly opened image always has the
|
|
/// same one row — and it stops being invisible the moment FR-DEV-5's
|
|
/// persisted history means an image opens with steps already in it, at
|
|
/// which point the panel shows another photograph's work. A shared counter
|
|
/// is two lines and the failure cannot occur; the alternative is every
|
|
/// frontend remembering to invalidate on open.
|
|
revision: u64,
|
|
/// 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)>,
|
|
}
|
|
|
|
impl History {
|
|
/// Start from the state `graph` is in.
|
|
pub fn new(graph: &EditGraph) -> Self {
|
|
Self {
|
|
states: vec![Snapshot {
|
|
state: graph.state(),
|
|
edit: None,
|
|
}],
|
|
cursor: 0,
|
|
revision: next_revision(),
|
|
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 = graph.state();
|
|
if self.states.get(self.cursor).map(|s| &s.state) == Some(&state) {
|
|
return false;
|
|
}
|
|
|
|
if self.coalesces(edit, at) {
|
|
if let Some(top) = self.states.get_mut(self.cursor) {
|
|
// The label stays whatever opened the step. It is the same
|
|
// control by definition — that is what coalescing decided —
|
|
// and rewriting it every frame of a drag would be work to
|
|
// arrive back at the word already there.
|
|
top.state = state;
|
|
self.last = Some((edit, at));
|
|
// No bump. The row is the same row with the same name; only
|
|
// the state behind it moved, and nothing drawing the list can
|
|
// tell. This is the case the counter exists for.
|
|
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(Snapshot {
|
|
state,
|
|
edit: Some(edit),
|
|
});
|
|
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));
|
|
self.revision = next_revision();
|
|
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.
|
|
///
|
|
/// The whole edit goes back, not merely its colour: framing is as undoable
|
|
/// as exposure, a mask as undoable as either, and a crop drag the user
|
|
/// wants back is one of the likelier reasons to reach for this. The view
|
|
/// survives, because [`EditGraph::set_state`] preserves it — an undo that
|
|
/// also jumped the viewport would read as navigation.
|
|
pub fn undo(&mut self, graph: &mut EditGraph) -> Step {
|
|
let Some(target) = self.cursor.checked_sub(1) else {
|
|
return Step::Nowhere;
|
|
};
|
|
self.restore(graph, target)
|
|
}
|
|
|
|
/// Step `graph` forward one.
|
|
pub fn redo(&mut self, graph: &mut EditGraph) -> Step {
|
|
self.restore(graph, self.cursor + 1)
|
|
}
|
|
|
|
fn restore(&mut self, graph: &mut EditGraph, target: usize) -> Step {
|
|
let Some(snapshot) = self.states.get(target).cloned() else {
|
|
return Step::Nowhere;
|
|
};
|
|
let rebake = graph.set_state(&snapshot.state);
|
|
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;
|
|
self.revision = next_revision();
|
|
Step::Took(rebake)
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// Where in the stack the graph currently stands.
|
|
///
|
|
/// Everything below it is undo; everything above it is redo. Exposed so a
|
|
/// frontend can mark the row rather than infer it from a run of
|
|
/// [`Self::can_undo`] calls.
|
|
pub fn cursor(&self) -> usize {
|
|
self.cursor
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// A number that changes exactly when [`Self::entries`] would.
|
|
///
|
|
/// What a frontend compares against the last list it drew, so that a drag
|
|
/// — which amends the step on top sixty times a second without changing
|
|
/// a single row — does not rebuild the panel once a frame.
|
|
pub fn revision(&self) -> u64 {
|
|
self.revision
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-DEV-7
|
|
/// Every step, oldest first, each saying what it was.
|
|
///
|
|
/// **Oldest first, though a photographer reads the list the other way
|
|
/// round.** The order a list is *displayed* in is a frontend decision — it
|
|
/// depends on where the panel is and which end has the room — whereas the
|
|
/// order the stack is in is a fact. Reversing here would bake one
|
|
/// interface's choice into the core and leave every [`Entry::index`]
|
|
/// counting backwards for everyone else.
|
|
///
|
|
/// Walks the chain once for the whole list rather than once per step:
|
|
/// resolving a label asks the descriptors, and sixty-four steps against a
|
|
/// twenty-operation chain is otherwise a thousand lookups for one panel
|
|
/// refresh.
|
|
pub fn entries(&self, graph: &EditGraph) -> Vec<Entry> {
|
|
let caps = graph.capabilities();
|
|
self.states
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, snapshot)| Entry {
|
|
index,
|
|
label: snapshot.edit.map_or(OPENED, |edit| edit.label(&caps)),
|
|
current: index == self.cursor,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-DEV-7
|
|
/// Jump straight to one step, however far away it is.
|
|
///
|
|
/// The same restore undo and redo make — a step is a whole state, so
|
|
/// arriving at one from six steps away costs exactly what arriving from
|
|
/// one does, and there is no sequence of intermediate states to replay.
|
|
/// That is the property that makes a clickable list worth having rather
|
|
/// than a decoration over the buttons.
|
|
///
|
|
/// Jumping to where the graph already is reports [`Step::Nowhere`], so a
|
|
/// frontend can click the current row without paying for a redraw.
|
|
pub fn go_to(&mut self, graph: &mut EditGraph, index: usize) -> Step {
|
|
if index == self.cursor {
|
|
return Step::Nowhere;
|
|
}
|
|
self.restore(graph, index)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::framing;
|
|
use crate::graph::Film;
|
|
use crate::mask::{MaskLayer, MaskSource};
|
|
use crate::ops::{curve, exposure, saturation};
|
|
use crate::state::FilmRef;
|
|
use crate::CropRect;
|
|
|
|
/// A radial layer, the cheapest thing a photographer can put on a picture
|
|
/// that is not a number.
|
|
fn layer(id: &str) -> MaskLayer {
|
|
MaskLayer::new(
|
|
id,
|
|
MaskSource::Radial {
|
|
centre: (0.5, 0.5),
|
|
radii: (0.25, 0.25),
|
|
angle: 0.0,
|
|
feather: 0.2,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// A stock with tables well-formed enough for the film node to keep them.
|
|
/// The numbers are not a real emulsion and do not need to be — what is
|
|
/// under test is whether the *choice* survives a step.
|
|
fn film(stock: &str) -> Film {
|
|
Film {
|
|
stock: stock.to_string(),
|
|
print: None,
|
|
tables: crate::ops::FilmTables {
|
|
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
|
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
|
curve_log_min: -3.0,
|
|
curve_log_max: 1.0,
|
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
|
density_max: 2.0,
|
|
lut_size: 2,
|
|
grain_particles: [0.0; 3],
|
|
grain_density_max: [2.0; 3],
|
|
grain_uniformity: 1.0,
|
|
},
|
|
}
|
|
}
|
|
|
|
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 two_photographs_never_report_the_same_revision() {
|
|
// A frontend holds the revision it last drew and rebuilds when it
|
|
// moves. Per-instance counters start every photograph at the same
|
|
// number, so a stale value matches a fresh history and the panel keeps
|
|
// the previous image's steps on screen — invisible while every image
|
|
// opens with one identical row, and a wrong-photograph bug the moment
|
|
// FR-DEV-5's persisted history means it does not.
|
|
let g = EditGraph::default_chain();
|
|
let first = History::new(&g);
|
|
let second = History::new(&g);
|
|
assert_ne!(first.revision(), second.revision());
|
|
|
|
// Including across the reset that opening an image makes.
|
|
let mut reused = History::new(&g);
|
|
let before = reused.revision();
|
|
reused.reset(&g);
|
|
assert_ne!(reused.revision(), before);
|
|
assert_ne!(reused.revision(), first.revision());
|
|
assert_ne!(reused.revision(), second.revision());
|
|
}
|
|
|
|
#[test]
|
|
fn a_drag_does_not_make_the_list_look_different() {
|
|
// The whole reason the counter exists. A drag emits a change per frame
|
|
// and folds every one into the step already on top: the rows do not
|
|
// move, and a panel that rebuilt itself anyway would tear down and
|
|
// recreate sixty rows sixty times a second for no visible change.
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
let base = Instant::now();
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.1);
|
|
h.record_at(
|
|
&g,
|
|
Edit::for_param(&g, exposure::ID, exposure::EXPOSURE),
|
|
base,
|
|
);
|
|
let opened = h.revision();
|
|
|
|
drag(&mut h, &mut g, base, 0.1, 1.5, 40);
|
|
assert_eq!(
|
|
h.revision(),
|
|
opened,
|
|
"coalescing into one row still moved the counter"
|
|
);
|
|
assert_eq!(h.entries(&g).len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn the_counter_moves_for_everything_that_shows() {
|
|
// The complement, and each of these is a case that would otherwise
|
|
// leave the panel drawing a list the photograph is no longer in.
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
|
|
let mut seen = vec![h.revision()];
|
|
let note = |h: &History, seen: &mut Vec<u64>, what: &str| {
|
|
assert!(
|
|
!seen.contains(&h.revision()),
|
|
"{what} did not move the counter"
|
|
);
|
|
seen.push(h.revision());
|
|
};
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.1);
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
|
note(&h, &mut seen, "recording a step");
|
|
|
|
assert!(h.undo(&mut g).moved());
|
|
note(&h, &mut seen, "an undo");
|
|
|
|
assert!(h.redo(&mut g).moved());
|
|
note(&h, &mut seen, "a redo");
|
|
|
|
assert!(h.go_to(&mut g, 0).moved());
|
|
note(&h, &mut seen, "a jump");
|
|
|
|
h.reset(&EditGraph::default_chain());
|
|
note(&h, &mut seen, "opening another photograph");
|
|
}
|
|
|
|
#[test]
|
|
fn a_step_that_moves_nothing_leaves_the_counter_alone() {
|
|
// `record` returning false means no row appeared, so nothing that
|
|
// draws rows has anything to do.
|
|
let g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
let before = h.revision();
|
|
assert!(!h.record(&g, Edit::Action(LocalizedKey("history.test"))));
|
|
assert_eq!(h.revision(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn every_step_says_what_it_was() {
|
|
// The reason `Discrete` had to go. A list is only worth drawing if the
|
|
// rows are distinguishable, and a parameter names itself out of the
|
|
// descriptor rather than out of a table here — so an operation added
|
|
// as a YAML declaration appears in the history with no code written
|
|
// for it, exactly as it appears in the panel (FR-DEV-3c).
|
|
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::for_param(&g, exposure::ID, exposure::EXPOSURE));
|
|
g.set_param(curve::ID, curve::P1_X, 0.4);
|
|
h.record(&g, Edit::for_param(&g, curve::ID, curve::P1_X));
|
|
g.masks_mut().push(layer("l1"));
|
|
h.record(&g, Edit::Action(LocalizedKey("history.mask_added")));
|
|
|
|
let labels: Vec<&str> = h.entries(&g).iter().map(|e| e.label.0).collect();
|
|
assert_eq!(
|
|
labels,
|
|
vec![
|
|
"history.opened",
|
|
"param.exposure",
|
|
// The curve point is one widget over two parameters, so the
|
|
// step is the operation and it is named as one.
|
|
"op.tone_curve",
|
|
"history.mask_added",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_list_marks_where_the_photograph_stands() {
|
|
// What a panel draws the highlight from. Everything below the mark is
|
|
// undo and everything above it is redo, so a mark in the wrong place
|
|
// is a list that lies about which way the photograph will move.
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
for v in [0.1, 0.2, 0.3] {
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, v);
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
|
}
|
|
|
|
assert_eq!(current(&h, &g), 3);
|
|
assert!(h.undo(&mut g).moved());
|
|
assert_eq!(current(&h, &g), 2);
|
|
assert_eq!(h.cursor(), 2);
|
|
|
|
// Exactly one row is ever marked.
|
|
assert_eq!(h.entries(&g).iter().filter(|e| e.current).count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_row_can_be_jumped_to_from_any_distance() {
|
|
// The property that makes the list clickable rather than decorative:
|
|
// a step is a whole state, so arriving from six steps away costs what
|
|
// arriving from one does and there is nothing to replay in between.
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
for v in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] {
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, v);
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
|
}
|
|
assert_eq!(h.depth(), 7, "the floor and one step per value");
|
|
|
|
assert!(h.go_to(&mut g, 1).moved());
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.1));
|
|
|
|
// And forward again, over the same distance.
|
|
assert!(h.go_to(&mut g, 6).moved());
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.6));
|
|
}
|
|
|
|
#[test]
|
|
fn jumping_to_the_row_already_showing_does_nothing() {
|
|
// A panel highlights the current row, and a row is a thing people
|
|
// click. Reported as `Nowhere` so the frontend can skip the redraw
|
|
// rather than re-rendering the frame it is already looking at.
|
|
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::Action(LocalizedKey("history.test")));
|
|
|
|
assert!(!h.go_to(&mut g, h.cursor()).moved());
|
|
assert!(
|
|
!h.go_to(&mut g, 99).moved(),
|
|
"a row off the end is not a step"
|
|
);
|
|
assert_eq!(h.cursor(), 1, "and neither moved the cursor");
|
|
}
|
|
|
|
#[test]
|
|
fn editing_from_a_row_in_the_middle_drops_the_rows_above_it() {
|
|
// Jumping back and then working is the branch the user chose. The
|
|
// steps that were above are gone from the list as well as from redo,
|
|
// because a row that cannot be reached is a row that must not be
|
|
// offered.
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
for v in [0.1, 0.2, 0.3] {
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, v);
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
|
}
|
|
|
|
assert!(h.go_to(&mut g, 1).moved());
|
|
g.set_param(saturation::ID, saturation::SATURATION, 20.0);
|
|
h.record(&g, Edit::Action(LocalizedKey("history.other")));
|
|
|
|
let entries = h.entries(&g);
|
|
assert_eq!(entries.len(), 3, "the abandoned branch is still listed");
|
|
assert!(entries.last().is_some_and(|e| e.current));
|
|
assert!(!h.can_redo());
|
|
}
|
|
|
|
#[test]
|
|
fn a_drag_stays_one_row_named_once() {
|
|
// Coalescing has to hold for the list as well as for the stepping: a
|
|
// drag that amended its step must not also rewrite its own label
|
|
// forty times, and must not appear forty times.
|
|
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);
|
|
|
|
let entries = h.entries(&g);
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(entries[1].label.0, "param.exposure");
|
|
}
|
|
|
|
/// The index the list says the photograph is standing on.
|
|
fn current(h: &History, g: &EditGraph) -> usize {
|
|
h.entries(g)
|
|
.into_iter()
|
|
.find(|e| e.current)
|
|
.expect("some row is always current")
|
|
.index
|
|
}
|
|
|
|
#[test]
|
|
fn a_mask_the_user_just_drew_can_be_taken_back() {
|
|
// The bug this module was rewritten for. A snapshot used to be a
|
|
// `Preset` — the parameter map — and a mask layer is deliberately not
|
|
// a parameter, so drawing one changed nothing the history could see:
|
|
// `record` returned `false`, no step opened, and the layer had no way
|
|
// back. Nothing failed; the undo simply was not there.
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
|
|
g.masks_mut().push(layer("l1"));
|
|
assert!(
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test"))),
|
|
"drawing a mask has to open a step"
|
|
);
|
|
|
|
assert!(h.undo(&mut g).moved());
|
|
assert!(
|
|
g.masks().is_empty(),
|
|
"the layer survived an undo: {} left",
|
|
g.masks().len()
|
|
);
|
|
assert!(h.redo(&mut g).moved());
|
|
assert_eq!(g.masks().len(), 1, "and redo has to put it back");
|
|
}
|
|
|
|
#[test]
|
|
fn editing_a_layer_is_a_step_of_its_own() {
|
|
// Not merely adding and removing: the settings *inside* a layer are
|
|
// the part a photographer works at, and they are as far from being a
|
|
// graph parameter as the layer itself.
|
|
let mut g = EditGraph::default_chain();
|
|
g.masks_mut().push(layer("l1"));
|
|
let mut h = History::new(&g);
|
|
|
|
if let Some(l) = g.masks_mut().get_mut("l1") {
|
|
l.opacity = 0.4;
|
|
}
|
|
assert!(h.record(&g, Edit::Op(OpId("mask-opacity"))));
|
|
|
|
assert!(h.undo(&mut g).moved());
|
|
let back = g.masks().get("l1").expect("the layer itself must remain");
|
|
assert!(
|
|
(back.opacity - 1.0).abs() < 1e-6,
|
|
"the opacity did not come back: {}",
|
|
back.opacity
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn undoing_across_a_change_of_film_asks_for_the_stock_back() {
|
|
// A stock is not a scalar either, so it went missing the same way. It
|
|
// needs the extra half-step because this crate cannot bake tables —
|
|
// the step reports what it owes rather than leaving the caller to
|
|
// remember, which `Version::update` is the standing evidence for.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_film(Some(film("kodak_portra_400")));
|
|
let mut h = History::new(&g);
|
|
|
|
g.set_film(Some(film("ilford_hp5")));
|
|
assert!(
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test"))),
|
|
"a change of stock is a step"
|
|
);
|
|
|
|
let step = h.undo(&mut g);
|
|
assert!(step.moved());
|
|
assert_eq!(
|
|
step.rebake(),
|
|
Some(&FilmRef {
|
|
stock: "kodak_portra_400".into(),
|
|
print: None,
|
|
}),
|
|
"undo must name the stock it needs baked back"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clearing_the_film_is_undoable_and_the_floor_has_none_to_restore() {
|
|
let mut g = EditGraph::default_chain();
|
|
let mut h = History::new(&g);
|
|
|
|
g.set_film(Some(film("kodak_portra_400")));
|
|
assert!(h.record(&g, Edit::Action(LocalizedKey("history.test"))));
|
|
|
|
let step = h.undo(&mut g);
|
|
assert!(step.moved());
|
|
assert_eq!(
|
|
step.rebake(),
|
|
None,
|
|
"there was no film to go back to, so nothing is owed"
|
|
);
|
|
assert!(g.film().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_step_that_leaves_the_masks_alone_does_not_copy_them() {
|
|
// The reason `EditState` shares its stack rather than owning it.
|
|
// `record` runs on every parameter change, which during a drag is once
|
|
// a frame; a painted brush is thousands of stroke points, and
|
|
// deep-copying it sixty times a second to note an exposure move would
|
|
// be a cost paid for nothing at all.
|
|
let mut g = EditGraph::default_chain();
|
|
g.masks_mut().push(layer("l1"));
|
|
|
|
let before = g.state();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let after = g.state();
|
|
|
|
assert!(
|
|
std::sync::Arc::ptr_eq(&before.masks, &after.masks),
|
|
"an exposure move deep-copied the mask stack"
|
|
);
|
|
|
|
// And the sharing ends the moment a layer is actually touched, or the
|
|
// two snapshots would be the same object and undo would restore
|
|
// nothing.
|
|
g.masks_mut().push(layer("l2"));
|
|
assert!(!std::sync::Arc::ptr_eq(&before.masks, &g.state().masks));
|
|
assert_eq!(before.masks.len(), 1, "the older snapshot was mutated");
|
|
}
|
|
|
|
#[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).moved());
|
|
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).moved());
|
|
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).moved());
|
|
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).moved());
|
|
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::Action(LocalizedKey("history.test")), base);
|
|
g.set_param(saturation::ID, saturation::SATURATION, 20.0);
|
|
h.record_at(&g, Edit::Action(LocalizedKey("history.test")), 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::Action(LocalizedKey("history.test"))));
|
|
// 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::Action(LocalizedKey("history.test"))));
|
|
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::Action(LocalizedKey("history.test")));
|
|
let after = g.state();
|
|
|
|
assert!(h.undo(&mut g).moved());
|
|
assert!(h.redo(&mut g).moved());
|
|
assert_eq!(g.state(), 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::Action(LocalizedKey("history.test")));
|
|
assert!(h.undo(&mut g).moved());
|
|
|
|
g.set_param(saturation::ID, saturation::SATURATION, 25.0);
|
|
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
|
|
|
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).moved());
|
|
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).moved());
|
|
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::Action(LocalizedKey("history.test")),
|
|
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).moved() {
|
|
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::Action(LocalizedKey("history.test")));
|
|
|
|
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::Action(LocalizedKey("history.test")));
|
|
|
|
g.framing_mut().set_view(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.25,
|
|
height: 0.25,
|
|
});
|
|
assert!(h.undo(&mut g).moved());
|
|
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)
|
|
);
|
|
}
|
|
}
|