List the steps, and let a photographer step straight to one
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
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>
This commit is contained in:
+482
-31
@@ -75,10 +75,12 @@
|
||||
//! 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::{OpId, ParamId};
|
||||
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
|
||||
@@ -106,22 +108,52 @@ pub const DEPTH: usize = 64;
|
||||
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.
|
||||
/// What the state a photograph opened in is called.
|
||||
///
|
||||
/// Not "what changed" — the snapshot already carries that. This exists purely
|
||||
/// so that consecutive changes can be told apart from one continuing change.
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// 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. Never coalesces, not even with an identical one, because two
|
||||
/// clicks are two decisions however quickly they follow each other.
|
||||
Discrete,
|
||||
/// 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 {
|
||||
@@ -146,8 +178,57 @@ impl Edit {
|
||||
|
||||
/// Whether a second change to this same control continues the first.
|
||||
fn is_gesture(self) -> bool {
|
||||
!matches!(self, Self::Discrete)
|
||||
!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
|
||||
@@ -186,6 +267,29 @@ impl Step {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -194,8 +298,36 @@ impl Step {
|
||||
/// 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<EditState>,
|
||||
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.
|
||||
@@ -206,8 +338,12 @@ impl History {
|
||||
/// Start from the state `graph` is in.
|
||||
pub fn new(graph: &EditGraph) -> Self {
|
||||
Self {
|
||||
states: vec![graph.state()],
|
||||
states: vec![Snapshot {
|
||||
state: graph.state(),
|
||||
edit: None,
|
||||
}],
|
||||
cursor: 0,
|
||||
revision: next_revision(),
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
@@ -234,14 +370,21 @@ impl History {
|
||||
/// 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) == Some(&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) {
|
||||
*top = state;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -250,7 +393,10 @@ impl History {
|
||||
// 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.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
|
||||
@@ -262,6 +408,7 @@ impl History {
|
||||
}
|
||||
|
||||
self.last = Some((edit, at));
|
||||
self.revision = next_revision();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -307,16 +454,17 @@ impl History {
|
||||
}
|
||||
|
||||
fn restore(&mut self, graph: &mut EditGraph, target: usize) -> Step {
|
||||
let Some(state) = self.states.get(target).cloned() else {
|
||||
let Some(snapshot) = self.states.get(target).cloned() else {
|
||||
return Step::Nowhere;
|
||||
};
|
||||
let rebake = graph.set_state(&state);
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -327,6 +475,71 @@ impl History {
|
||||
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)]
|
||||
@@ -393,6 +606,237 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
@@ -405,7 +849,7 @@ mod tests {
|
||||
|
||||
g.masks_mut().push(layer("l1"));
|
||||
assert!(
|
||||
h.record(&g, Edit::Discrete),
|
||||
h.record(&g, Edit::Action(LocalizedKey("history.test"))),
|
||||
"drawing a mask has to open a step"
|
||||
);
|
||||
|
||||
@@ -453,7 +897,10 @@ mod tests {
|
||||
let mut h = History::new(&g);
|
||||
|
||||
g.set_film(Some(film("ilford_hp5")));
|
||||
assert!(h.record(&g, Edit::Discrete), "a change of stock is a step");
|
||||
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());
|
||||
@@ -473,7 +920,7 @@ mod tests {
|
||||
let mut h = History::new(&g);
|
||||
|
||||
g.set_film(Some(film("kodak_portra_400")));
|
||||
assert!(h.record(&g, Edit::Discrete));
|
||||
assert!(h.record(&g, Edit::Action(LocalizedKey("history.test"))));
|
||||
|
||||
let step = h.undo(&mut g);
|
||||
assert!(step.moved());
|
||||
@@ -649,9 +1096,9 @@ mod tests {
|
||||
let base = Instant::now();
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
h.record_at(&g, Edit::Discrete, base);
|
||||
h.record_at(&g, Edit::Action(LocalizedKey("history.test")), base);
|
||||
g.set_param(saturation::ID, saturation::SATURATION, 20.0);
|
||||
h.record_at(&g, Edit::Discrete, at(base, 5));
|
||||
h.record_at(&g, Edit::Action(LocalizedKey("history.test")), at(base, 5));
|
||||
|
||||
assert_eq!(h.depth(), 3);
|
||||
}
|
||||
@@ -666,10 +1113,10 @@ mod tests {
|
||||
let mut h = History::new(&g);
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
|
||||
assert!(h.record(&g, Edit::Discrete));
|
||||
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::Discrete));
|
||||
assert!(!h.record(&g, Edit::Action(LocalizedKey("history.test"))));
|
||||
assert_eq!(h.depth(), 2);
|
||||
}
|
||||
|
||||
@@ -685,7 +1132,7 @@ mod tests {
|
||||
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);
|
||||
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
||||
let after = g.state();
|
||||
|
||||
assert!(h.undo(&mut g).moved());
|
||||
@@ -704,11 +1151,11 @@ mod tests {
|
||||
let mut h = History::new(&g);
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
h.record(&g, Edit::Discrete);
|
||||
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::Discrete);
|
||||
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
||||
|
||||
assert!(!h.can_redo());
|
||||
assert_eq!(h.depth(), 2);
|
||||
@@ -763,7 +1210,11 @@ mod tests {
|
||||
|
||||
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));
|
||||
h.record_at(
|
||||
&g,
|
||||
Edit::Action(LocalizedKey("history.test")),
|
||||
at(base, u64::from(i) * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(h.depth(), DEPTH);
|
||||
@@ -784,7 +1235,7 @@ mod tests {
|
||||
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);
|
||||
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
||||
|
||||
let mut next = EditGraph::default_chain();
|
||||
next.set_param(saturation::ID, saturation::SATURATION, 10.0);
|
||||
@@ -803,7 +1254,7 @@ mod tests {
|
||||
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);
|
||||
h.record(&g, Edit::Action(LocalizedKey("history.test")));
|
||||
|
||||
g.framing_mut().set_view(CropRect {
|
||||
x: 0.25,
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use detail::{
|
||||
};
|
||||
pub use framing::{CropRect, Framing};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use history::{Edit, History, Step};
|
||||
pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
||||
pub use lens::{compose_warps, ComposedWarp, Warp};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||
|
||||
@@ -256,7 +256,10 @@ fn undo_takes_a_repair_back() {
|
||||
let mut history = History::new(&graph);
|
||||
|
||||
graph.spots_mut().place(spot_at((0.5, 0.5), (0.08, 0.0)));
|
||||
assert!(history.record(&graph, Edit::Discrete));
|
||||
assert!(history.record(
|
||||
&graph,
|
||||
Edit::Action(dr_pipeline::LocalizedKey("history.spot"))
|
||||
));
|
||||
assert_eq!(graph.spots().len(), 1);
|
||||
|
||||
assert!(history.undo(&mut graph).moved());
|
||||
@@ -285,7 +288,10 @@ fn undo_steps_back_through_a_moved_source() {
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.set_offset((0.2, 0.1));
|
||||
history.record(&graph, Edit::Discrete);
|
||||
history.record(
|
||||
&graph,
|
||||
Edit::Action(dr_pipeline::LocalizedKey("history.spot")),
|
||||
);
|
||||
|
||||
assert!(history.undo(&mut graph).moved());
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user