Skip to main content

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}