pdfrum_form/edit/undo.rs
1//! The undo stack.
2//!
3//! # Why this is not the obvious design
4//!
5//! The tempting model — an item remembers the text before and after, and undo
6//! and redo restore their respective snapshots — is wrong here in four
7//! separate ways that ported assertions catch:
8//!
9//! - **Undo restores the selection that was live before the edit; redo does
10//! not.** After an undo the caret is back inside whatever was selected when
11//! the user typed over it; after the matching redo the selection is empty.
12//! A symmetric design cannot express that.
13//! - **An item replays the inverse operation against the live layout**, so a
14//! position is recomputed rather than remembered. A stored offset drifts as
15//! soon as an intervening operation rewraps a line.
16//! - **Granularity is per keystroke going in, but per call going out**: one
17//! typed character is one item, while a paste, a cut or a delete of a
18//! selection is exactly one item however many characters it moved.
19//! - **Grouping is a pair of sentinels, not a begin/end API.** A variable
20//! length run is bracketed by two [`UndoItem::GroupBoundary`] markers, and
21//! undo walks past members until it consumes the matching one.
22//!
23//! Head eviction is group-atomic: dropping the oldest entry to make room
24//! drops a whole group when the oldest entry opens one, so the stack can
25//! never hold a boundary whose partner has been evicted. That is why the
26//! capacity has a floor of four — the worst-case group is a sentinel, a
27//! clear, an insert and a sentinel.
28
29use std::collections::VecDeque;
30
31use super::place::{Place, Range};
32use super::select::Selection;
33
34/// One reversible edit, stored as enough to replay its inverse.
35///
36/// Every variant but the boundary carries `before`: the selection that was
37/// live when the edit was made, which undo restores and redo does not.
38#[derive(Debug, Clone, PartialEq)]
39pub enum UndoItem {
40 /// One character was inserted.
41 InsertWord {
42 /// The caret before the insert.
43 old: Place,
44 /// The caret after it.
45 new: Place,
46 /// The character.
47 ch: char,
48 /// The selection that was live before the edit.
49 before: Selection,
50 },
51 /// A paragraph break was inserted.
52 InsertReturn {
53 /// The caret before the insert.
54 old: Place,
55 /// The caret after it.
56 new: Place,
57 /// The selection that was live before the edit.
58 before: Selection,
59 },
60 /// The character before the caret was deleted.
61 Backspace {
62 /// The caret before the delete.
63 old: Place,
64 /// The caret after it.
65 new: Place,
66 /// The character that was removed.
67 ch: char,
68 /// Whether what was removed was a paragraph break rather than a
69 /// character. Snapshotted at record time rather than re-derived at
70 /// replay time, which is the one place two upstream mechanisms are
71 /// collapsed into the one that cannot disagree with itself.
72 section_break: bool,
73 /// The selection that was live before the edit.
74 before: Selection,
75 },
76 /// The character after the caret was deleted.
77 Delete {
78 /// The caret before the delete.
79 old: Place,
80 /// The caret after it.
81 new: Place,
82 /// The character that was removed.
83 ch: char,
84 /// Whether what was removed was a paragraph break.
85 section_break: bool,
86 /// The selection that was live before the edit.
87 before: Selection,
88 },
89 /// A range was removed.
90 Clear {
91 /// What was removed.
92 range: Range,
93 /// The text that was in it.
94 text: String,
95 /// The selection that was live before the edit.
96 before: Selection,
97 },
98 /// A run of text was inserted.
99 InsertText {
100 /// The caret before the insert.
101 old: Place,
102 /// The caret after it.
103 new: Place,
104 /// The text.
105 text: String,
106 /// The selection that was live before the edit.
107 before: Selection,
108 },
109 /// A group boundary. Undo and redo continue past members until the
110 /// matching boundary is consumed.
111 GroupBoundary,
112}
113
114impl UndoItem {
115 /// Whether this item is a group boundary rather than an edit.
116 #[must_use]
117 pub fn is_boundary(&self) -> bool {
118 matches!(self, UndoItem::GroupBoundary)
119 }
120
121 /// The selection that was live before this edit, if it is an edit.
122 #[must_use]
123 pub fn before(&self) -> Option<Selection> {
124 match self {
125 UndoItem::InsertWord { before, .. }
126 | UndoItem::InsertReturn { before, .. }
127 | UndoItem::Backspace { before, .. }
128 | UndoItem::Delete { before, .. }
129 | UndoItem::Clear { before, .. }
130 | UndoItem::InsertText { before, .. } => Some(*before),
131 UndoItem::GroupBoundary => None,
132 }
133 }
134}
135
136/// A bounded, linear undo stack.
137///
138/// `items[..pos]` are undoable and `items[pos..]` are redoable, so a fresh
139/// edit truncating the redo branch is one `truncate`.
140#[derive(Debug, Clone)]
141pub struct UndoStack {
142 items: VecDeque<UndoItem>,
143 pos: usize,
144 max: usize,
145 enabled: bool,
146}
147
148impl UndoStack {
149 /// The default capacity.
150 pub const DEFAULT_MAX: u32 = 10_000;
151
152 /// The smallest capacity that can hold the worst-case group: a boundary,
153 /// a clear, an insert and a boundary.
154 pub const MIN_MAX: u32 = 4;
155
156 /// An empty, enabled stack holding at most `max` items.
157 ///
158 /// `max` is clamped **up** to [`UndoStack::MIN_MAX`], because a capacity
159 /// below the worst-case group size could only be satisfied by evicting
160 /// half a group, and a half-evicted group is not a state this type
161 /// admits.
162 #[must_use]
163 pub fn with_max(max: u32) -> UndoStack {
164 UndoStack {
165 items: VecDeque::new(),
166 pos: 0,
167 max: max.max(UndoStack::MIN_MAX) as usize,
168 enabled: true,
169 }
170 }
171
172 /// Turns recording on or off. Does not discard what is already recorded.
173 ///
174 /// A disabled stack receives no items, boundaries included — the check
175 /// happens once, at the single entry point, rather than at each of the
176 /// several call sites that would otherwise each have to remember it.
177 pub fn set_enabled(&mut self, enabled: bool) {
178 self.enabled = enabled;
179 }
180
181 /// The capacity.
182 #[must_use]
183 pub fn max(&self) -> usize {
184 self.max
185 }
186
187 /// How many items are stored, undoable and redoable together.
188 #[must_use]
189 pub fn len(&self) -> usize {
190 self.items.len()
191 }
192
193 /// Whether nothing is stored.
194 #[must_use]
195 pub fn is_empty(&self) -> bool {
196 self.items.is_empty()
197 }
198
199 /// Whether there is anything to undo.
200 #[must_use]
201 pub fn can_undo(&self) -> bool {
202 self.pos > 0
203 }
204
205 /// Whether there is anything to redo.
206 #[must_use]
207 pub fn can_redo(&self) -> bool {
208 self.pos < self.items.len()
209 }
210
211 /// Forgets everything. What a focus change to another field does.
212 pub fn clear(&mut self) {
213 self.items.clear();
214 self.pos = 0;
215 }
216
217 /// Records one item, truncating the redo branch and evicting from the
218 /// head if the stack is full.
219 ///
220 /// A disabled stack ignores the call.
221 pub fn push(&mut self, item: UndoItem) {
222 if !self.enabled {
223 return;
224 }
225 self.items.truncate(self.pos);
226 self.evict_to_fit();
227 self.items.push_back(item);
228 self.pos = self.items.len();
229 }
230
231 /// Drops whole groups from the head until one more item fits.
232 ///
233 /// Dropping a lone item is one `pop_front`; dropping a group's opening
234 /// boundary takes everything up to and including its partner, so the
235 /// stack never holds an unmatched boundary.
236 fn evict_to_fit(&mut self) {
237 while self.items.len() >= self.max {
238 let opened_group = matches!(self.items.front(), Some(UndoItem::GroupBoundary));
239 self.items.pop_front();
240 if opened_group {
241 while let Some(item) = self.items.pop_front() {
242 if item.is_boundary() {
243 break;
244 }
245 }
246 }
247 if self.items.is_empty() {
248 break;
249 }
250 }
251 self.pos = self.items.len();
252 }
253
254 /// The items one `undo` would replay, **newest first**, and moves the
255 /// cursor past them.
256 ///
257 /// The order is the inverse-replay order and is deliberately the opposite
258 /// of [`UndoStack::redo`]'s: undoing a group has to unwind its members
259 /// from the last one backwards, while redoing one re-applies them from
260 /// the first forwards.
261 ///
262 /// Exactly one item when the top of the stack is an ordinary edit. When
263 /// it is a group's closing boundary, the whole group: the walk continues
264 /// until it has consumed the opening boundary.
265 ///
266 /// Returns an empty slice when there is nothing to undo, which is the
267 /// same answer as "the cursor is at the bottom".
268 pub fn undo(&mut self) -> Vec<UndoItem> {
269 let mut taken = Vec::new();
270 let mut first = true;
271 while self.pos > 0 {
272 self.pos -= 1;
273 let Some(item) = self.items.get(self.pos) else {
274 break;
275 };
276 let is_boundary = item.is_boundary();
277 taken.push(item.clone());
278 if first {
279 first = false;
280 if !is_boundary {
281 break;
282 }
283 } else if is_boundary {
284 break;
285 }
286 }
287 taken
288 }
289
290 /// The items one `redo` would replay, **oldest first**, and moves the
291 /// cursor past them. The mirror of [`UndoStack::undo`], including in the
292 /// order it hands them back.
293 pub fn redo(&mut self) -> Vec<UndoItem> {
294 let mut taken = Vec::new();
295 let mut first = true;
296 while self.pos < self.items.len() {
297 let Some(item) = self.items.get(self.pos) else {
298 break;
299 };
300 let is_boundary = item.is_boundary();
301 taken.push(item.clone());
302 self.pos += 1;
303 if first {
304 first = false;
305 if !is_boundary {
306 break;
307 }
308 } else if is_boundary {
309 break;
310 }
311 }
312 taken
313 }
314
315 /// The stored items, oldest first. For tests and diagnostics.
316 pub fn items(&self) -> impl Iterator<Item = &UndoItem> {
317 self.items.iter()
318 }
319
320 /// How many items are undoable — the cursor's position.
321 #[must_use]
322 pub fn position(&self) -> usize {
323 self.pos
324 }
325}
326
327/// Two stacks are equal when they hold the same items with the cursor in the
328/// same place. The capacity and the enable switch are configuration rather
329/// than content, so they do not participate.
330impl PartialEq for UndoStack {
331 fn eq(&self, other: &UndoStack) -> bool {
332 self.pos == other.pos && self.items == other.items
333 }
334}
335
336impl Eq for UndoStack {}
337
338impl Default for UndoStack {
339 fn default() -> UndoStack {
340 UndoStack::with_max(UndoStack::DEFAULT_MAX)
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn word(ch: char) -> UndoItem {
349 UndoItem::InsertWord {
350 old: Place::start(),
351 new: Place::start(),
352 ch,
353 before: Selection::empty(),
354 }
355 }
356
357 fn chars_of(items: &[UndoItem]) -> Vec<char> {
358 items
359 .iter()
360 .filter_map(|i| match i {
361 UndoItem::InsertWord { ch, .. } => Some(*ch),
362 _ => None,
363 })
364 .collect()
365 }
366
367 #[test]
368 fn a_fresh_stack_can_neither_undo_nor_redo() {
369 let stack = UndoStack::default();
370 assert!(!stack.can_undo());
371 assert!(!stack.can_redo());
372 }
373
374 /// Typing n characters pushes exactly n items, and each undo takes one.
375 #[test]
376 fn one_typed_character_is_one_item() {
377 let mut stack = UndoStack::default();
378 for ch in "ABCDE".chars() {
379 stack.push(word(ch));
380 }
381 assert_eq!(stack.len(), 5);
382 assert_eq!(chars_of(&stack.undo()), vec!['E']);
383 assert_eq!(chars_of(&stack.undo()), vec!['D']);
384 assert!(stack.can_undo());
385 assert!(stack.can_redo());
386 assert_eq!(chars_of(&stack.redo()), vec!['D']);
387 assert_eq!(chars_of(&stack.redo()), vec!['E']);
388 assert!(!stack.can_redo());
389 assert!(stack.can_undo());
390 }
391
392 /// The stack bottom is observable: after undoing every item, `can_undo`
393 /// is false rather than merely unhelpful.
394 #[test]
395 fn undoing_to_the_bottom_reports_the_bottom() {
396 let mut stack = UndoStack::default();
397 for ch in "ABC".chars() {
398 stack.push(word(ch));
399 }
400 for _ in 0..3 {
401 assert!(stack.can_undo());
402 stack.undo();
403 }
404 assert!(!stack.can_undo());
405 assert!(stack.undo().is_empty());
406 }
407
408 /// A bracketed run is undone as a unit however many members it has.
409 #[test]
410 fn a_group_undoes_and_redoes_as_one_step() {
411 let mut stack = UndoStack::default();
412 stack.push(word('A'));
413 stack.push(UndoItem::GroupBoundary);
414 stack.push(word('X'));
415 stack.push(word('Y'));
416 stack.push(word('Z'));
417 stack.push(UndoItem::GroupBoundary);
418
419 let undone = stack.undo();
420 assert_eq!(chars_of(&undone), vec!['Z', 'Y', 'X']);
421 assert!(stack.can_undo());
422 assert_eq!(chars_of(&stack.undo()), vec!['A']);
423 assert!(!stack.can_undo());
424
425 assert_eq!(chars_of(&stack.redo()), vec!['A']);
426 assert_eq!(chars_of(&stack.redo()), vec!['X', 'Y', 'Z']);
427 assert!(!stack.can_redo());
428 }
429
430 /// A new edit after an undo truncates the redo branch.
431 #[test]
432 fn a_fresh_edit_drops_the_redo_branch() {
433 let mut stack = UndoStack::default();
434 stack.push(word('A'));
435 stack.push(word('B'));
436 stack.undo();
437 assert!(stack.can_redo());
438
439 stack.push(word('C'));
440 assert!(stack.can_undo());
441 assert!(!stack.can_redo());
442 assert_eq!(chars_of(&stack.undo()), vec!['C']);
443 }
444
445 /// The capacity floor exists so the worst-case group always fits.
446 #[test]
447 fn capacity_is_clamped_up_to_the_worst_case_group() {
448 assert_eq!(UndoStack::with_max(0).max(), 4);
449 assert_eq!(UndoStack::with_max(1).max(), 4);
450 assert_eq!(UndoStack::with_max(4).max(), 4);
451 assert_eq!(UndoStack::with_max(9).max(), 9);
452 }
453
454 /// Eviction takes whole groups, so no unmatched boundary can survive.
455 #[test]
456 fn eviction_never_leaves_half_a_group() {
457 let mut stack = UndoStack::with_max(4);
458 stack.push(UndoItem::GroupBoundary);
459 stack.push(word('X'));
460 stack.push(word('Y'));
461 stack.push(UndoItem::GroupBoundary);
462 assert_eq!(stack.len(), 4);
463
464 // One more item does not fit; the whole group leaves together.
465 stack.push(word('Z'));
466 let boundaries = stack.items().filter(|i| i.is_boundary()).count();
467 assert_eq!(boundaries % 2, 0, "an unmatched boundary survived");
468 assert_eq!(
469 chars_of(&stack.items().cloned().collect::<Vec<_>>()),
470 vec!['Z']
471 );
472 }
473
474 /// However the stack is filled, it never holds an unmatched boundary and
475 /// never exceeds its capacity.
476 #[test]
477 fn eviction_holds_the_invariants_over_many_shapes() {
478 for max in [4u32, 5, 7] {
479 for seed in 0..64u32 {
480 let mut stack = UndoStack::with_max(max);
481 let mut bits = seed;
482 for n in 0..20u32 {
483 if bits & 1 == 1 {
484 stack.push(UndoItem::GroupBoundary);
485 stack.push(word('a'));
486 stack.push(UndoItem::GroupBoundary);
487 } else {
488 stack.push(word(char::from_u32('a' as u32 + n % 26).unwrap_or('a')));
489 }
490 bits >>= 1;
491 if bits == 0 {
492 bits = seed | 1;
493 }
494
495 assert!(stack.len() <= max as usize, "capacity exceeded");
496 let boundaries = stack.items().filter(|i| i.is_boundary()).count();
497 assert_eq!(
498 boundaries % 2,
499 0,
500 "unmatched boundary at max={max} seed={seed}"
501 );
502 }
503 }
504 }
505 }
506
507 /// A disabled stack takes nothing, boundaries included.
508 #[test]
509 fn a_disabled_stack_records_nothing() {
510 let mut stack = UndoStack::default();
511 stack.set_enabled(false);
512 stack.push(word('A'));
513 stack.push(UndoItem::GroupBoundary);
514 assert!(stack.is_empty());
515 assert!(!stack.can_undo());
516 }
517
518 /// A focus change to another field empties the stack.
519 #[test]
520 fn clear_forgets_both_branches() {
521 let mut stack = UndoStack::default();
522 stack.push(word('A'));
523 stack.push(word('B'));
524 stack.undo();
525 stack.clear();
526 assert!(!stack.can_undo());
527 assert!(!stack.can_redo());
528 assert!(stack.is_empty());
529 }
530}