//! 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, } /// 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, 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 { 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, 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) ); } }