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_kind: Option<EditKind>,
31    last_at: Option<Instant>,
32}
33
34impl<T> Default for History<T> {
35    fn default() -> Self {
36        Self {
37            undo: VecDeque::new(),
38            redo: VecDeque::new(),
39            last_kind: None,
40            last_at: None,
41        }
42    }
43}
44
45impl<T> History<T> {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// True when the next typing edit should extend the current undo step.
51    pub fn will_coalesce(&self, kind: EditKind) -> bool {
52        kind == EditKind::Typing
53            && self.last_kind == Some(EditKind::Typing)
54            && self.last_at.is_some_and(|t| t.elapsed() < COALESCE_WINDOW)
55            && !self.undo.is_empty()
56    }
57
58    /// Refresh the coalesce timer without pushing a snapshot.
59    pub fn touch_coalesce(&mut self) {
60        self.last_at = Some(Instant::now());
61    }
62
63    /// Push a pre-edit snapshot. Do not call when [`Self::will_coalesce`] is true.
64    pub fn push(&mut self, current: T, kind: EditKind) {
65        self.undo.push_back(current);
66        while self.undo.len() > MAX_DEPTH {
67            self.undo.pop_front();
68        }
69        self.redo.clear();
70        self.last_kind = Some(kind);
71        self.last_at = Some(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_kind = None;
86        self.last_at = None;
87    }
88
89    /// Pop undo; push `current` onto redo. Returns the state to restore.
90    pub fn undo(&mut self, current: T) -> Option<T> {
91        let prev = self.undo.pop_back()?;
92        self.redo.push_back(current);
93        self.break_coalesce();
94        Some(prev)
95    }
96
97    /// Pop redo; push `current` onto undo. Returns the state to restore.
98    pub fn redo(&mut self, current: T) -> Option<T> {
99        let next = self.redo.pop_back()?;
100        self.undo.push_back(current);
101        self.break_coalesce();
102        Some(next)
103    }
104
105    pub fn can_redo(&self) -> bool {
106        !self.redo.is_empty()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn typing_coalesces_into_one_step() {
116        let mut h: History<String> = History::new();
117        h.before_edit_with(EditKind::Typing, || "a".into());
118        h.before_edit_with(EditKind::Typing, || "ab".into());
119        h.before_edit_with(EditKind::Typing, || "abc".into());
120        assert_eq!(h.undo.len(), 1);
121        let restored = h.undo("abc".into()).unwrap();
122        assert_eq!(restored, "a");
123    }
124
125    #[test]
126    fn atomic_always_splits() {
127        let mut h: History<String> = History::new();
128        h.before_edit_with(EditKind::Typing, || "a".into());
129        h.before_edit_with(EditKind::Atomic, || "ab".into());
130        h.before_edit_with(EditKind::Typing, || "abc".into());
131        assert_eq!(h.undo.len(), 3);
132    }
133
134    #[test]
135    fn redo_clears_on_new_edit() {
136        let mut h: History<String> = History::new();
137        h.before_edit_with(EditKind::Atomic, || "0".into());
138        let _ = h.undo("1".into());
139        assert!(h.can_redo());
140        h.before_edit_with(EditKind::Atomic, || "2".into());
141        assert!(!h.can_redo());
142    }
143
144    #[test]
145    fn coalesce_skips_snapshot_fn() {
146        let mut h: History<String> = History::new();
147        h.before_edit_with(EditKind::Typing, || "a".into());
148        let mut built = 0;
149        h.before_edit_with(EditKind::Typing, || {
150            built += 1;
151            "ab".into()
152        });
153        assert_eq!(built, 0);
154        assert_eq!(h.undo.len(), 1);
155    }
156}