Skip to main content

saorsa_core/
undo.rs

1//! Undo/redo stack for text editing operations.
2//!
3//! Provides an [`UndoStack`] that tracks [`EditOperation`] values and
4//! supports bounded undo/redo history. Each operation is invertible so
5//! that undo can be implemented by applying the inverse.
6
7use crate::cursor::CursorPosition;
8
9/// A single text editing operation that can be undone and redone.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum EditOperation {
12    /// Text was inserted at a position.
13    Insert {
14        /// Position where text was inserted.
15        pos: CursorPosition,
16        /// The text that was inserted.
17        text: String,
18    },
19    /// Text was deleted from a position.
20    Delete {
21        /// Position where text was deleted.
22        pos: CursorPosition,
23        /// The text that was deleted.
24        text: String,
25    },
26    /// Text was replaced at a position.
27    Replace {
28        /// Position where replacement started.
29        pos: CursorPosition,
30        /// The old text that was replaced.
31        old_text: String,
32        /// The new text that replaced it.
33        new_text: String,
34    },
35}
36
37impl EditOperation {
38    /// Return the inverse of this operation.
39    ///
40    /// Applying the inverse undoes the original operation:
41    /// - `Insert` → `Delete` (and vice versa)
42    /// - `Replace` swaps `old_text` and `new_text`
43    pub fn inverse(&self) -> Self {
44        match self {
45            Self::Insert { pos, text } => Self::Delete {
46                pos: *pos,
47                text: text.clone(),
48            },
49            Self::Delete { pos, text } => Self::Insert {
50                pos: *pos,
51                text: text.clone(),
52            },
53            Self::Replace {
54                pos,
55                old_text,
56                new_text,
57            } => Self::Replace {
58                pos: *pos,
59                old_text: new_text.clone(),
60                new_text: old_text.clone(),
61            },
62        }
63    }
64}
65
66/// A bounded undo/redo stack for text editing.
67///
68/// Stores up to `max_history` operations. When the limit is reached,
69/// the oldest operation is dropped. Pushing a new operation clears
70/// the redo stack.
71#[derive(Clone, Debug)]
72pub struct UndoStack {
73    undo_stack: Vec<EditOperation>,
74    redo_stack: Vec<EditOperation>,
75    max_history: usize,
76}
77
78impl UndoStack {
79    /// Create a new undo stack with the given maximum history size.
80    pub fn new(max_history: usize) -> Self {
81        Self {
82            undo_stack: Vec::new(),
83            redo_stack: Vec::new(),
84            max_history,
85        }
86    }
87
88    /// Push a new operation onto the undo stack.
89    ///
90    /// This clears the redo stack. If the undo stack exceeds
91    /// `max_history`, the oldest operation is dropped.
92    pub fn push(&mut self, op: EditOperation) {
93        self.redo_stack.clear();
94        self.undo_stack.push(op);
95        if self.undo_stack.len() > self.max_history {
96            self.undo_stack.remove(0);
97        }
98    }
99
100    /// Pop the most recent operation and return its inverse for undoing.
101    ///
102    /// The operation is moved to the redo stack.
103    pub fn undo(&mut self) -> Option<EditOperation> {
104        let op = self.undo_stack.pop()?;
105        let inverse = op.inverse();
106        self.redo_stack.push(op);
107        Some(inverse)
108    }
109
110    /// Pop the most recent redo operation and return it for reapplying.
111    ///
112    /// The operation is moved back to the undo stack.
113    pub fn redo(&mut self) -> Option<EditOperation> {
114        let op = self.redo_stack.pop()?;
115        let result = op.clone();
116        self.undo_stack.push(op);
117        Some(result)
118    }
119
120    /// Returns `true` if there are operations to undo.
121    pub fn can_undo(&self) -> bool {
122        !self.undo_stack.is_empty()
123    }
124
125    /// Returns `true` if there are operations to redo.
126    pub fn can_redo(&self) -> bool {
127        !self.redo_stack.is_empty()
128    }
129
130    /// Clear both undo and redo stacks.
131    pub fn clear(&mut self) {
132        self.undo_stack.clear();
133        self.redo_stack.clear();
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn insert_op(line: usize, col: usize, text: &str) -> EditOperation {
142        EditOperation::Insert {
143            pos: CursorPosition::new(line, col),
144            text: text.to_string(),
145        }
146    }
147
148    fn delete_op(line: usize, col: usize, text: &str) -> EditOperation {
149        EditOperation::Delete {
150            pos: CursorPosition::new(line, col),
151            text: text.to_string(),
152        }
153    }
154
155    #[test]
156    fn push_and_undo() {
157        let mut stack = UndoStack::new(100);
158        stack.push(insert_op(0, 0, "hello"));
159        assert!(stack.can_undo());
160        let inv = stack.undo();
161        match inv {
162            Some(EditOperation::Delete { ref text, .. }) if text == "hello" => {}
163            other => unreachable!("expected Delete('hello'), got {other:?}"),
164        }
165    }
166
167    #[test]
168    fn undo_then_redo() {
169        let mut stack = UndoStack::new(100);
170        stack.push(insert_op(0, 0, "a"));
171        let _inv = stack.undo();
172        assert!(stack.can_redo());
173        let redo = stack.redo();
174        match redo {
175            Some(EditOperation::Insert { ref text, .. }) if text == "a" => {}
176            other => unreachable!("expected Insert('a'), got {other:?}"),
177        }
178    }
179
180    #[test]
181    fn push_clears_redo() {
182        let mut stack = UndoStack::new(100);
183        stack.push(insert_op(0, 0, "a"));
184        let _inv = stack.undo();
185        assert!(stack.can_redo());
186        stack.push(insert_op(0, 0, "b"));
187        assert!(!stack.can_redo());
188    }
189
190    #[test]
191    fn undo_multiple() {
192        let mut stack = UndoStack::new(100);
193        stack.push(insert_op(0, 0, "a"));
194        stack.push(insert_op(0, 1, "b"));
195        stack.push(insert_op(0, 2, "c"));
196
197        // Undo c, b, a
198        match stack.undo() {
199            Some(EditOperation::Delete { ref text, .. }) if text == "c" => {}
200            other => unreachable!("expected Delete('c'), got {other:?}"),
201        }
202        match stack.undo() {
203            Some(EditOperation::Delete { ref text, .. }) if text == "b" => {}
204            other => unreachable!("expected Delete('b'), got {other:?}"),
205        }
206        match stack.undo() {
207            Some(EditOperation::Delete { ref text, .. }) if text == "a" => {}
208            other => unreachable!("expected Delete('a'), got {other:?}"),
209        }
210        assert!(!stack.can_undo());
211    }
212
213    #[test]
214    fn max_history_limit() {
215        let mut stack = UndoStack::new(3);
216        stack.push(insert_op(0, 0, "a"));
217        stack.push(insert_op(0, 1, "b"));
218        stack.push(insert_op(0, 2, "c"));
219        stack.push(insert_op(0, 3, "d")); // "a" should be dropped
220
221        // Only 3 items remain: b, c, d
222        let _d = stack.undo();
223        let _c = stack.undo();
224        match stack.undo() {
225            Some(EditOperation::Delete { ref text, .. }) if text == "b" => {}
226            other => unreachable!("expected Delete('b'), got {other:?}"),
227        }
228        assert!(!stack.can_undo()); // "a" was dropped
229    }
230
231    #[test]
232    fn inverse_insert() {
233        let op = insert_op(1, 5, "hello");
234        match op.inverse() {
235            EditOperation::Delete { pos, ref text } => {
236                assert!(pos == CursorPosition::new(1, 5));
237                assert!(text == "hello");
238            }
239            other => unreachable!("expected Delete, got {other:?}"),
240        }
241    }
242
243    #[test]
244    fn inverse_delete() {
245        let op = delete_op(2, 3, "world");
246        match op.inverse() {
247            EditOperation::Insert { pos, ref text } => {
248                assert!(pos == CursorPosition::new(2, 3));
249                assert!(text == "world");
250            }
251            other => unreachable!("expected Insert, got {other:?}"),
252        }
253    }
254
255    #[test]
256    fn inverse_replace() {
257        let op = EditOperation::Replace {
258            pos: CursorPosition::new(0, 0),
259            old_text: "foo".to_string(),
260            new_text: "bar".to_string(),
261        };
262        match op.inverse() {
263            EditOperation::Replace {
264                ref old_text,
265                ref new_text,
266                ..
267            } => {
268                assert!(old_text == "bar");
269                assert!(new_text == "foo");
270            }
271            other => unreachable!("expected Replace, got {other:?}"),
272        }
273    }
274
275    #[test]
276    fn clear_resets_both_stacks() {
277        let mut stack = UndoStack::new(100);
278        stack.push(insert_op(0, 0, "a"));
279        let _inv = stack.undo();
280        assert!(stack.can_redo());
281        stack.clear();
282        assert!(!stack.can_undo());
283        assert!(!stack.can_redo());
284    }
285
286    #[test]
287    fn empty_stack_undo_redo_returns_none() {
288        let mut stack = UndoStack::new(100);
289        assert!(stack.undo().is_none());
290        assert!(stack.redo().is_none());
291    }
292}