Skip to main content

edit/
history.rs

1//! Undo / redo for the edit model.
2
3use crate::ops::{EditError, EditOp};
4use crate::project::EditProject;
5
6/// Default maximum number of undo steps retained.
7pub const DEFAULT_HISTORY_LIMIT: usize = 200;
8
9/// A bounded undo / redo stack wrapping a live [`EditProject`].
10///
11/// Edits go through [`History::apply`], which snapshots the project
12/// before each change. Because `apply` runs the operation on a clone and
13/// only commits when the project actually changed, `apply` then `undo` is
14/// guaranteed to restore the exact prior state — no fragile per-operation
15/// inverse derivation, and no-op operations never pollute the undo stack.
16#[derive(Clone, Debug)]
17pub struct History {
18    current: EditProject,
19    undo: Vec<EditProject>,
20    redo: Vec<EditProject>,
21    limit: usize,
22}
23
24impl History {
25    /// Wrap a project with an undo stack of the default depth.
26    #[must_use]
27    pub fn new(project: EditProject) -> Self {
28        Self::with_limit(project, DEFAULT_HISTORY_LIMIT)
29    }
30
31    /// Wrap a project with an undo stack of at most `limit` steps
32    /// (clamped to at least 1).
33    #[must_use]
34    pub fn with_limit(project: EditProject, limit: usize) -> Self {
35        Self {
36            current: project,
37            undo: Vec::new(),
38            redo: Vec::new(),
39            limit: limit.max(1),
40        }
41    }
42
43    /// The live project.
44    #[must_use]
45    pub fn project(&self) -> &EditProject {
46        &self.current
47    }
48
49    /// Whether there is anything to undo.
50    #[must_use]
51    pub fn can_undo(&self) -> bool {
52        !self.undo.is_empty()
53    }
54
55    /// Whether there is anything to redo.
56    #[must_use]
57    pub fn can_redo(&self) -> bool {
58        !self.redo.is_empty()
59    }
60
61    /// Apply an edit, recording it for undo if it changed anything.
62    ///
63    /// # Errors
64    ///
65    /// Propagates [`EditError`] from [`EditProject::apply`]. On error the
66    /// project is left unchanged (the operation runs on a clone that is
67    /// discarded).
68    pub fn apply(&mut self, op: &EditOp) -> Result<(), EditError> {
69        let mut next = self.current.clone();
70        next.apply(op)?;
71        if next != self.current {
72            self.undo.push(std::mem::replace(&mut self.current, next));
73            if self.undo.len() > self.limit {
74                self.undo.remove(0);
75            }
76            self.redo.clear();
77        }
78        Ok(())
79    }
80
81    /// Undo the last recorded change. Returns whether anything was undone
82    /// (the boolean is an optional signal — `undo` is usually called for
83    /// effect).
84    #[allow(
85        clippy::must_use_candidate,
86        reason = "undo is invoked for its side effect; the bool is an optional 'did something' signal callers may ignore"
87    )]
88    pub fn undo(&mut self) -> bool {
89        if let Some(prev) = self.undo.pop() {
90            self.redo.push(std::mem::replace(&mut self.current, prev));
91            true
92        } else {
93            false
94        }
95    }
96
97    /// Redo the last undone change. Returns whether anything was redone.
98    #[allow(
99        clippy::must_use_candidate,
100        reason = "redo is invoked for its side effect; the bool is an optional 'did something' signal callers may ignore"
101    )]
102    pub fn redo(&mut self) -> bool {
103        if let Some(next) = self.redo.pop() {
104            self.undo.push(std::mem::replace(&mut self.current, next));
105            true
106        } else {
107            false
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::clip::ClipRef;
116    use crate::ops::TrimEdge;
117    use crate::segment::TimelineSegment;
118    use crate::zoom::{ZoomId, ZoomSegment};
119    use proptest::prelude::*;
120    use std::path::PathBuf;
121
122    fn project() -> EditProject {
123        EditProject::from_recording(ClipRef::new(
124            PathBuf::from("/tmp/rec.mp4"),
125            1920,
126            1080,
127            30,
128            900,
129        ))
130    }
131
132    #[test]
133    fn apply_records_and_undo_redo_round_trip() {
134        let mut h = History::new(project());
135        assert!(!h.can_undo());
136        h.apply(&EditOp::Split { at: 300 }).unwrap();
137        assert!(h.can_undo());
138        assert_eq!(h.project().segments.len(), 2);
139
140        assert!(h.undo());
141        assert_eq!(h.project().segments.len(), 1);
142        assert!(h.can_redo());
143
144        assert!(h.redo());
145        assert_eq!(h.project().segments.len(), 2);
146        assert_eq!(h.project().segments[1], TimelineSegment::new(300, 900));
147    }
148
149    #[test]
150    fn noop_apply_does_not_record() {
151        let mut h = History::new(project());
152        h.apply(&EditOp::Split { at: 0 }).unwrap(); // boundary → no change
153        assert!(!h.can_undo());
154    }
155
156    #[test]
157    fn new_apply_clears_redo() {
158        let mut h = History::new(project());
159        h.apply(&EditOp::Split { at: 300 }).unwrap();
160        h.undo();
161        assert!(h.can_redo());
162        h.apply(&EditOp::SetSpeed {
163            index: 0,
164            timescale: 2.0,
165        })
166        .unwrap();
167        assert!(!h.can_redo());
168    }
169
170    #[test]
171    fn history_limit_bounds_undo_depth() {
172        let mut h = History::with_limit(project(), 3);
173        for ts in [1.5, 2.0, 2.5, 3.0, 0.5] {
174            h.apply(&EditOp::SetSpeed {
175                index: 0,
176                timescale: ts,
177            })
178            .unwrap();
179        }
180        let mut undos = 0;
181        while h.undo() {
182            undos += 1;
183        }
184        assert_eq!(undos, 3, "only the last `limit` changes are undoable");
185    }
186
187    fn any_op() -> impl Strategy<Value = EditOp> {
188        prop_oneof![
189            (0u64..1000).prop_map(|at| EditOp::Split { at }),
190            (
191                0usize..4,
192                prop_oneof![Just(TrimEdge::Start), Just(TrimEdge::End)],
193                0u64..1000
194            )
195                .prop_map(|(index, edge, to)| EditOp::Trim { index, edge, to }),
196            (0u64..1000, 0u64..1000).prop_map(|(a, b)| EditOp::RippleDelete {
197                start: a.min(b),
198                end: a.max(b)
199            }),
200            (0usize..4, 0.2f64..5.0)
201                .prop_map(|(index, timescale)| EditOp::SetSpeed { index, timescale }),
202            (0u64..880, 1u64..40, 1.0f64..3.0).prop_map(|(s, d, amount)| EditOp::AddZoom {
203                zoom: ZoomSegment::manual(ZoomId(0), s, s + d, amount)
204            }),
205            (0u32..8).prop_map(|id| EditOp::RemoveZoom { id: ZoomId(id) }),
206            (0u32..8, 0u64..880, 1u64..40).prop_map(|(id, s, d)| EditOp::MoveZoom {
207                id: ZoomId(id),
208                start: s,
209                end: s + d
210            }),
211        ]
212    }
213
214    proptest! {
215        #[test]
216        fn single_op_apply_then_undo_is_identity(op in any_op()) {
217            let mut h = History::new(project());
218            let before = h.project().clone();
219            let _ = h.apply(&op);
220            h.undo();
221            prop_assert_eq!(h.project(), &before);
222        }
223
224        #[test]
225        fn op_sequence_preserves_invariants(ops in prop::collection::vec(any_op(), 0..25)) {
226            let mut h = History::new(project());
227            for op in &ops {
228                let _ = h.apply(op);
229                prop_assert!(
230                    h.project().check_invariants().is_ok(),
231                    "invariant violated after {:?}: {:?}",
232                    op,
233                    h.project().check_invariants()
234                );
235            }
236        }
237
238        #[test]
239        fn undo_all_returns_to_start(ops in prop::collection::vec(any_op(), 0..25)) {
240            let start = project();
241            let mut h = History::new(start.clone());
242            for op in &ops {
243                let _ = h.apply(op);
244            }
245            while h.undo() {}
246            prop_assert_eq!(h.project(), &start);
247        }
248    }
249}