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