Skip to main content

mach/
undo.rs

1//! Form-level undo / redo.
2//!
3//! - Snapshot before each mutating edit.
4//! - Coalesce consecutive typing / single-char deletes within a short window.
5//! - Atomic edits (paste, word-delete, newline, structure) always split.
6//! - New edits clear redo; caret and selection are part of the snapshot.
7
8use std::collections::VecDeque;
9use std::time::{Duration, Instant};
10
11/// Max undo steps kept per open dialog.
12const MAX_DEPTH: usize = 64;
13/// Window for coalescing successive typing keys into one undo step.
14const COALESCE_WINDOW: Duration = Duration::from_millis(1_200);
15
16/// How an upcoming edit should group with the previous one.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum EditKind {
19    /// Single-character insert, backspace, or forward-delete.
20    Typing,
21    /// Paste, word ops, newline, list/image changes, importance, due, etc.
22    Atomic,
23}
24
25/// Undo/redo stacks for one form dialog.
26#[derive(Debug, Clone)]
27pub struct History<T> {
28    undo: VecDeque<T>,
29    redo: VecDeque<T>,
30    last_edit: Option<(EditKind, Instant)>,
31}
32
33impl<T> Default for History<T> {
34    fn default() -> Self {
35        Self {
36            undo: VecDeque::new(),
37            redo: VecDeque::new(),
38            last_edit: None,
39        }
40    }
41}
42
43impl<T> History<T> {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// True when the next typing edit should extend the current undo step.
49    pub fn will_coalesce(&self, kind: EditKind) -> bool {
50        kind == EditKind::Typing
51            && self.last_edit.is_some_and(|(last_kind, at)| {
52                last_kind == EditKind::Typing && at.elapsed() < COALESCE_WINDOW
53            })
54            && !self.undo.is_empty()
55    }
56
57    /// Refresh the coalesce timer without pushing a snapshot.
58    pub fn touch_coalesce(&mut self) {
59        if let Some((_, at)) = &mut self.last_edit {
60            *at = Instant::now();
61        }
62    }
63
64    /// Push a pre-edit snapshot. Do not call when [`Self::will_coalesce`] is true.
65    pub fn push(&mut self, current: T, kind: EditKind) {
66        self.undo.push_back(current);
67        while self.undo.len() > MAX_DEPTH {
68            self.undo.pop_front();
69        }
70        self.redo.clear();
71        self.last_edit = Some((kind, Instant::now()));
72    }
73
74    /// Call before mutating. Builds `current` only when a new step is needed.
75    pub fn before_edit_with(&mut self, kind: EditKind, current: impl FnOnce() -> T) {
76        if self.will_coalesce(kind) {
77            self.touch_coalesce();
78            return;
79        }
80        self.push(current(), kind);
81    }
82
83    /// End the current typing run (e.g. after navigation or focus change).
84    pub fn break_coalesce(&mut self) {
85        self.last_edit = None;
86    }
87
88    /// Pop undo; push `current` onto redo. Returns the state to restore.
89    pub fn undo(&mut self, current: T) -> Option<T> {
90        let prev = self.undo.pop_back()?;
91        self.redo.push_back(current);
92        self.break_coalesce();
93        Some(prev)
94    }
95
96    /// Pop redo; push `current` onto undo. Returns the state to restore.
97    pub fn redo(&mut self, current: T) -> Option<T> {
98        let next = self.redo.pop_back()?;
99        self.undo.push_back(current);
100        self.break_coalesce();
101        Some(next)
102    }
103
104    pub fn can_redo(&self) -> bool {
105        !self.redo.is_empty()
106    }
107
108    /// Update snapshots after an external vocabulary change invalidates or
109    /// reorders references stored inside them.
110    pub fn for_each_mut(&mut self, mut update: impl FnMut(&mut T)) {
111        self.undo.iter_mut().for_each(&mut update);
112        self.redo.iter_mut().for_each(update);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn typing_coalesces_into_one_step() {
122        let mut h: History<String> = History::new();
123        h.before_edit_with(EditKind::Typing, || "a".into());
124        h.before_edit_with(EditKind::Typing, || "ab".into());
125        h.before_edit_with(EditKind::Typing, || "abc".into());
126        assert_eq!(h.undo.len(), 1);
127        let restored = h.undo("abc".into()).unwrap();
128        assert_eq!(restored, "a");
129    }
130
131    #[test]
132    fn atomic_always_splits() {
133        let mut h: History<String> = History::new();
134        h.before_edit_with(EditKind::Typing, || "a".into());
135        h.before_edit_with(EditKind::Atomic, || "ab".into());
136        h.before_edit_with(EditKind::Typing, || "abc".into());
137        assert_eq!(h.undo.len(), 3);
138    }
139
140    #[test]
141    fn redo_clears_on_new_edit() {
142        let mut h: History<String> = History::new();
143        h.before_edit_with(EditKind::Atomic, || "0".into());
144        let _ = h.undo("1".into());
145        assert!(h.can_redo());
146        h.before_edit_with(EditKind::Atomic, || "2".into());
147        assert!(!h.can_redo());
148    }
149
150    #[test]
151    fn coalesce_skips_snapshot_fn() {
152        let mut h: History<String> = History::new();
153        h.before_edit_with(EditKind::Typing, || "a".into());
154        let mut built = 0;
155        h.before_edit_with(EditKind::Typing, || {
156            built += 1;
157            "ab".into()
158        });
159        assert_eq!(built, 0);
160        assert_eq!(h.undo.len(), 1);
161    }
162}