lattice_core/undo.rs
1//! Undo stack.
2//!
3//! Phase 0 ships a linear undo stack: each entry is the inverse of one applied
4//! edit (or one batch of edits applied as a single command). The branching
5//! undo *tree* per §5.1 is a later refinement; the linear stack is forward
6//! compatible because branching is built by retaining alternative redo paths
7//! when a new edit is applied while redo entries exist.
8
9use lattice_protocol::edit::Edit;
10
11/// One entry on the undo stack: an ordered list of edits whose application
12/// inverts the user-visible operation. Storing a list (not a single Edit) lets
13/// a batch of edits applied atomically be undone atomically.
14#[derive(Debug, Clone)]
15pub struct UndoEntry {
16 /// The edits that invert the operation, in the order they must be
17 /// applied — the reverse of the order the original edits were applied.
18 /// Each edit's range is in the coordinates left by the edit before it.
19 pub inverse_edits: Vec<Edit>,
20 /// Description for status messages / dot-repeat. Empty for unnamed batches.
21 pub label: String,
22}
23
24/// A linear undo / redo stack of [`UndoEntry`]s.
25///
26/// This is a passive container: it never touches a buffer. The owner (in
27/// practice [`Document`](crate::Document)) applies the popped entry's
28/// edits and records the resulting inverse on the other side — see
29/// [`Self::pop_for_undo`] / [`Self::record_redo`]. Pushing a new entry
30/// discards redo history (no undo tree yet).
31///
32/// # Examples
33///
34/// ```
35/// use lattice_core::{UndoEntry, UndoStack};
36///
37/// let entry = |label: &str| UndoEntry { inverse_edits: vec![], label: label.into() };
38/// let mut stack = UndoStack::new();
39/// stack.push(entry("a"));
40/// stack.push(entry("b"));
41///
42/// // Undo "b": pop it, apply its edits (elided), record the redo side.
43/// let undone = stack.pop_for_undo().map(|e| e.label);
44/// assert_eq!(undone.as_deref(), Some("b"));
45/// stack.record_redo(entry("b"));
46/// assert_eq!((stack.undo_depth(), stack.redo_depth()), (1, 1));
47///
48/// // A fresh edit drops the redo history.
49/// stack.push(entry("c"));
50/// assert_eq!((stack.undo_depth(), stack.redo_depth()), (2, 0));
51/// ```
52#[derive(Debug, Default, Clone)]
53pub struct UndoStack {
54 undo: Vec<UndoEntry>,
55 redo: Vec<UndoEntry>,
56}
57
58impl UndoStack {
59 /// An empty stack.
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 /// Record a new undo entry. Any pending redo history is dropped.
65 pub fn push(&mut self, entry: UndoEntry) {
66 self.undo.push(entry);
67 self.redo.clear();
68 }
69
70 /// Fold `inverses` into the most recent undo entry instead of
71 /// pushing a new one -- the primitive behind undo-group coalescing
72 /// (a vim insert session collapses to a single undo unit). The
73 /// caller passes the just-applied operation's inverse edits in the
74 /// same stored order [`push`](Self::push) would use (reverse-application order);
75 /// they are prepended so the combined entry still replays
76 /// newest -> oldest during undo (`inv(eN) .. inv(e1)`).
77 ///
78 /// Redo is intentionally not cleared: an amend never diverges the
79 /// history (the initiating `push` that opened the group already
80 /// cleared redo, and no redo can accrue mid-group). If there is no
81 /// top entry to amend -- which the group bookkeeping is meant to
82 /// prevent -- it falls back to a plain push so the edit stays
83 /// undoable rather than being silently lost.
84 pub fn amend_top(&mut self, mut inverses: Vec<Edit>) {
85 match self.undo.last_mut() {
86 Some(top) => {
87 inverses.append(&mut top.inverse_edits);
88 top.inverse_edits = inverses;
89 }
90 None => self.undo.push(UndoEntry {
91 inverse_edits: inverses,
92 label: String::new(),
93 }),
94 }
95 }
96
97 /// Pop the most recent undo entry, or `None` if there is none.
98 ///
99 /// This does **not** touch the redo stack: the caller applies
100 /// `entry.inverse_edits` to the buffer and passes the resulting
101 /// "inverse-of-the-inverse" back via [`Self::record_redo`].
102 pub fn pop_for_undo(&mut self) -> Option<UndoEntry> {
103 self.undo.pop()
104 }
105
106 /// Reciprocal of `pop_for_undo`. Stores the edit set that would replay the
107 /// undone operation onto the redo stack.
108 pub fn record_redo(&mut self, redo_entry: UndoEntry) {
109 self.redo.push(redo_entry);
110 }
111
112 /// Pop the most recent redo entry, or `None` if there is none. Like
113 /// [`Self::pop_for_undo`], the caller applies it and records the result
114 /// via [`Self::record_undo`].
115 pub fn pop_for_redo(&mut self) -> Option<UndoEntry> {
116 self.redo.pop()
117 }
118
119 /// Reciprocal of [`Self::pop_for_redo`]: push the edit set that undoes a
120 /// just-redone operation. Unlike [`Self::push`] it leaves the redo stack
121 /// intact, so further redos remain available.
122 pub fn record_undo(&mut self, undo_entry: UndoEntry) {
123 self.undo.push(undo_entry);
124 }
125
126 /// Number of entries available to undo. [`Document`](crate::Document)
127 /// compares it against the depth recorded at save time to decide
128 /// dirtiness.
129 pub fn undo_depth(&self) -> usize {
130 self.undo.len()
131 }
132
133 /// Number of entries available to redo.
134 pub fn redo_depth(&self) -> usize {
135 self.redo.len()
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 #![allow(clippy::unwrap_used, clippy::panic)]
142 use super::*;
143 use lattice_protocol::edit::Edit;
144 use lattice_protocol::position::Position;
145
146 fn entry(label: &str) -> UndoEntry {
147 UndoEntry {
148 inverse_edits: vec![Edit::insert(Position::ZERO, "x")],
149 label: label.into(),
150 }
151 }
152
153 #[test]
154 fn new_stack_is_empty() {
155 let s = UndoStack::new();
156 assert_eq!(s.undo_depth(), 0);
157 assert_eq!(s.redo_depth(), 0);
158 }
159
160 #[test]
161 fn push_increments_undo_depth() {
162 let mut s = UndoStack::new();
163 s.push(entry("a"));
164 s.push(entry("b"));
165 assert_eq!(s.undo_depth(), 2);
166 assert_eq!(s.redo_depth(), 0);
167 }
168
169 #[test]
170 fn pop_for_undo_returns_in_lifo_order() {
171 let mut s = UndoStack::new();
172 s.push(entry("a"));
173 s.push(entry("b"));
174 let top = s.pop_for_undo().unwrap();
175 assert_eq!(top.label, "b");
176 let next = s.pop_for_undo().unwrap();
177 assert_eq!(next.label, "a");
178 assert!(s.pop_for_undo().is_none());
179 }
180
181 #[test]
182 fn record_redo_pushes_to_redo_stack() {
183 let mut s = UndoStack::new();
184 s.push(entry("a"));
185 let popped = s.pop_for_undo().unwrap();
186 s.record_redo(popped);
187 assert_eq!(s.undo_depth(), 0);
188 assert_eq!(s.redo_depth(), 1);
189 }
190
191 #[test]
192 fn pop_for_redo_returns_in_lifo_order() {
193 let mut s = UndoStack::new();
194 s.record_redo(entry("first"));
195 s.record_redo(entry("second"));
196 assert_eq!(s.pop_for_redo().unwrap().label, "second");
197 assert_eq!(s.pop_for_redo().unwrap().label, "first");
198 assert!(s.pop_for_redo().is_none());
199 }
200
201 #[test]
202 fn push_clears_pending_redo() {
203 // Standard undo invariant: making a new edit while there is a redo
204 // history must drop that history (the user has diverged onto a new
205 // branch). The branching tree variant in §5.1 will preserve it; the
206 // linear stack does not.
207 let mut s = UndoStack::new();
208 s.push(entry("a"));
209 let popped = s.pop_for_undo().unwrap();
210 s.record_redo(popped);
211 assert_eq!(s.redo_depth(), 1);
212
213 s.push(entry("b"));
214 assert_eq!(s.redo_depth(), 0);
215 }
216
217 #[test]
218 fn amend_top_prepends_into_the_latest_entry() {
219 // Two edits folded into one entry: the second edit's inverse is
220 // prepended so undo replays newest -> oldest. Depth stays 1.
221 let mut s = UndoStack::new();
222 s.push(UndoEntry {
223 inverse_edits: vec![Edit::insert(Position::ZERO, "first")],
224 label: String::new(),
225 });
226 s.amend_top(vec![Edit::insert(Position::new(0, 5), "second")]);
227 assert_eq!(s.undo_depth(), 1);
228 let top = s.pop_for_undo().unwrap();
229 assert_eq!(top.inverse_edits.len(), 2);
230 // Prepended: the later edit's inverse comes first.
231 assert_eq!(
232 top.inverse_edits[0],
233 Edit::insert(Position::new(0, 5), "second")
234 );
235 assert_eq!(top.inverse_edits[1], Edit::insert(Position::ZERO, "first"));
236 }
237
238 #[test]
239 fn amend_top_on_empty_stack_falls_back_to_push() {
240 let mut s = UndoStack::new();
241 s.amend_top(vec![Edit::insert(Position::ZERO, "x")]);
242 assert_eq!(s.undo_depth(), 1);
243 }
244
245 #[test]
246 fn amend_top_does_not_clear_redo() {
247 // Unlike `push`, amending an open group must not drop redo.
248 let mut s = UndoStack::new();
249 s.record_redo(entry("r"));
250 s.push(entry("open")); // clears redo per the push invariant...
251 s.record_redo(entry("r2")); // ...re-seed to prove amend leaves it be
252 s.amend_top(vec![Edit::insert(Position::ZERO, "y")]);
253 assert_eq!(s.redo_depth(), 1);
254 }
255
256 #[test]
257 fn record_undo_does_not_clear_redo() {
258 // record_undo is the bookkeeping primitive used by `Document::redo`
259 // -- it should NOT clear the redo stack the way `push` does.
260 let mut s = UndoStack::new();
261 s.record_redo(entry("r"));
262 s.record_undo(entry("u"));
263 assert_eq!(s.undo_depth(), 1);
264 assert_eq!(s.redo_depth(), 1);
265 }
266
267 #[test]
268 fn full_undo_redo_dance() {
269 let mut s = UndoStack::new();
270 s.push(entry("op"));
271 let popped = s.pop_for_undo().unwrap();
272 // pretend we re-applied the inverse and computed an inverse-of-inverse:
273 s.record_redo(entry("inv-of-inv"));
274 let redone = s.pop_for_redo().unwrap();
275 s.record_undo(entry("re-recorded"));
276 assert_eq!(s.undo_depth(), 1);
277 assert_eq!(s.redo_depth(), 0);
278 let _ = (popped, redone);
279 }
280}