1use crate::ops::{EditError, EditOp};
4use crate::project::EditProject;
5
6pub const DEFAULT_HISTORY_LIMIT: usize = 200;
8
9#[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 #[must_use]
27 pub fn new(project: EditProject) -> Self {
28 Self::with_limit(project, DEFAULT_HISTORY_LIMIT)
29 }
30
31 #[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 #[must_use]
45 pub fn project(&self) -> &EditProject {
46 &self.current
47 }
48
49 #[must_use]
51 pub fn can_undo(&self) -> bool {
52 !self.undo.is_empty()
53 }
54
55 #[must_use]
57 pub fn can_redo(&self) -> bool {
58 !self.redo.is_empty()
59 }
60
61 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 #[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 #[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(); 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}