Skip to main content

lumen_text/
undo.rs

1//! W3.3: Undo / redo stack with typing coalesce.
2//!
3//! Per-editable component. Each [`UndoEntry`] is a structural delta -
4//! `removed` and `inserted` carry the bytes a single edit displaced.
5//! Pure-insert edits have empty `removed`; pure-delete edits have empty
6//! `inserted`; replace edits have both.
7//!
8//! Coalescing: consecutive single-character insertions within
9//! [`UndoStack::group_window`] (default 500 ms) collapse into one entry
10//! so undo restores word-sized chunks, not character-sized ones (Qt's
11//! `QUndoStack::beginMacro` analogue).
12
13#![allow(missing_docs)]
14
15use bevy_ecs::prelude::*;
16use lumen_core::text_model::{TextBuffer, TextCursor, TextPos};
17use lumen_core::time::Instant;
18
19/// What kind of delta one entry encodes.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum UndoKind {
22    /// `inserted` non-empty, `removed` empty.
23    Insert,
24    /// `removed` non-empty, `inserted` empty.
25    Delete,
26    /// Both non-empty.
27    Replace,
28}
29
30/// One reversible delta on a [`TextBuffer`].
31#[derive(Clone, Debug)]
32pub struct UndoEntry {
33    /// Kind tag (derived from removed / inserted but stored for fast match).
34    pub kind: UndoKind,
35    /// Byte offset where the edit landed.
36    pub position: usize,
37    /// Bytes that the edit displaced (empty for pure insert).
38    pub removed: String,
39    /// Bytes the edit introduced (empty for pure delete).
40    pub inserted: String,
41    /// Cursor head position before the edit.
42    pub cursor_before: TextPos,
43}
44
45/// Per-editable undo stack (W3.3).
46///
47/// Stores entries in chronological order. `head` is the next-undo
48/// index - entries at index `0..head` are undoable; entries at
49/// `head..len` are redoable (post-undo). Any new edit truncates the
50/// redo side.
51#[derive(Component, Debug, Clone)]
52pub struct UndoStack {
53    entries: Vec<UndoEntry>,
54    head: usize,
55    last_edit: Option<Instant>,
56    /// Typing-coalesce window. Consecutive single-char inserts within
57    /// this duration of each other collapse into one entry.
58    pub group_window: std::time::Duration,
59    /// Maximum stack depth. Older entries dropped from the front.
60    pub max_depth: usize,
61}
62
63impl Default for UndoStack {
64    fn default() -> Self {
65        Self {
66            entries: Vec::new(),
67            head: 0,
68            last_edit: None,
69            group_window: std::time::Duration::from_millis(500),
70            max_depth: 512,
71        }
72    }
73}
74
75impl UndoStack {
76    /// Number of stored entries (undo + redo halves).
77    pub fn len(&self) -> usize {
78        self.entries.len()
79    }
80
81    /// `true` when empty.
82    pub fn is_empty(&self) -> bool {
83        self.entries.is_empty()
84    }
85
86    /// `true` when there's something to undo.
87    pub fn can_undo(&self) -> bool {
88        self.head > 0
89    }
90
91    /// `true` when there's something to redo.
92    pub fn can_redo(&self) -> bool {
93        self.head < self.entries.len()
94    }
95
96    /// Clear all entries (external buffer rewrite - recorded offsets no
97    /// longer describe the live rope). Mirrors `QLineEdit::setText`.
98    pub fn clear(&mut self) {
99        self.entries.clear();
100        self.head = 0;
101        self.last_edit = None;
102    }
103
104    /// Record a pure-insertion edit.
105    pub fn record_insert(&mut self, position: usize, inserted: &str, cursor_before: TextPos) {
106        let now = Instant::now();
107        // Try to coalesce with the previous insert at the same edge.
108        // `starts_new_group` breaks the run at word boundaries (typing
109        // "hello world" undoes as "world", then " ", then "hello") and
110        // the `group_window` pause breaks it on hesitation - standard
111        // word-ish undo granularity.
112        if self.head == self.entries.len()
113            && let Some(last) = self.entries.last_mut()
114            && last.kind == UndoKind::Insert
115            && last.position + last.inserted.len() == position
116            && is_single_grapheme(inserted)
117            && !starts_new_group(&last.inserted, inserted)
118            && let Some(prev_time) = self.last_edit
119            && now.saturating_duration_since(prev_time) < self.group_window
120        {
121            last.inserted.push_str(inserted);
122            self.last_edit = Some(now);
123            return;
124        }
125        self.push(UndoEntry {
126            kind: UndoKind::Insert,
127            position,
128            removed: String::new(),
129            inserted: inserted.to_string(),
130            cursor_before,
131        });
132        self.last_edit = Some(now);
133    }
134
135    /// Record a pure-deletion edit.
136    pub fn record_delete(&mut self, position: usize, removed: &str, cursor_before: TextPos) {
137        self.push(UndoEntry {
138            kind: UndoKind::Delete,
139            position,
140            removed: removed.to_string(),
141            inserted: String::new(),
142            cursor_before,
143        });
144        self.last_edit = Some(Instant::now());
145    }
146
147    /// Record a replace edit (removed + inserted).
148    pub fn record_replace(
149        &mut self,
150        position: usize,
151        removed: &str,
152        inserted: &str,
153        cursor_before: TextPos,
154    ) {
155        self.push(UndoEntry {
156            kind: UndoKind::Replace,
157            position,
158            removed: removed.to_string(),
159            inserted: inserted.to_string(),
160            cursor_before,
161        });
162        self.last_edit = Some(Instant::now());
163    }
164
165    fn push(&mut self, entry: UndoEntry) {
166        // New edit truncates the redo half.
167        if self.head < self.entries.len() {
168            self.entries.truncate(self.head);
169        }
170        self.entries.push(entry);
171        self.head = self.entries.len();
172        if self.entries.len() > self.max_depth {
173            let drop = self.entries.len() - self.max_depth;
174            self.entries.drain(..drop);
175            self.head = self.head.saturating_sub(drop);
176        }
177    }
178
179    /// Pop one undo entry. Returns `true` when an entry was applied.
180    pub fn undo(&mut self, buf: &mut TextBuffer, cur: &mut TextCursor) -> bool {
181        if !self.can_undo() {
182            return false;
183        }
184        let entry = self.entries[self.head - 1].clone();
185        // Defensive: an entry whose recorded span no longer fits the
186        // live rope (external rewrite that bypassed the mirror/reflect
187        // pair) would splice at stale offsets. Drop the whole stack
188        // rather than corrupt the buffer.
189        let span = match entry.kind {
190            UndoKind::Insert | UndoKind::Replace => entry.position + entry.inserted.len(),
191            UndoKind::Delete => entry.position,
192        };
193        if span > buf.len_bytes() {
194            self.clear();
195            return false;
196        }
197        self.head -= 1;
198        apply_inverse(&entry, buf, cur);
199        true
200    }
201
202    /// Re-apply the next redo entry. Returns `true` when an entry was applied.
203    pub fn redo(&mut self, buf: &mut TextBuffer, cur: &mut TextCursor) -> bool {
204        if !self.can_redo() {
205            return false;
206        }
207        let entry = self.entries[self.head].clone();
208        let span = match entry.kind {
209            UndoKind::Insert => entry.position,
210            UndoKind::Delete | UndoKind::Replace => entry.position + entry.removed.len(),
211        };
212        if span > buf.len_bytes() {
213            self.clear();
214            return false;
215        }
216        apply_forward(&entry, buf, cur);
217        self.head += 1;
218        true
219    }
220}
221
222/// `true` when appending `next` to a coalesced insert run that currently
223/// ends with `prev` should start a new undo group. Groups break at
224/// whitespace boundaries in both directions ("hello| |world" becomes
225/// three entries) so undo restores word-sized chunks.
226fn starts_new_group(prev: &str, next: &str) -> bool {
227    let prev_ws = prev.chars().next_back().map(char::is_whitespace);
228    let next_ws = next.chars().next().map(char::is_whitespace);
229    matches!((prev_ws, next_ws), (Some(a), Some(b)) if a != b)
230}
231
232fn apply_inverse(entry: &UndoEntry, buf: &mut TextBuffer, cur: &mut TextCursor) {
233    match entry.kind {
234        UndoKind::Insert => {
235            // Remove inserted bytes.
236            let end = entry.position + entry.inserted.len();
237            let s_char = buf.rope.byte_to_char(entry.position);
238            let e_char = buf.rope.byte_to_char(end);
239            buf.rope.remove(s_char..e_char);
240        }
241        UndoKind::Delete => {
242            let s_char = buf.rope.byte_to_char(entry.position);
243            buf.rope.insert(s_char, &entry.removed);
244        }
245        UndoKind::Replace => {
246            let end = entry.position + entry.inserted.len();
247            let s_char = buf.rope.byte_to_char(entry.position);
248            let e_char = buf.rope.byte_to_char(end);
249            buf.rope.remove(s_char..e_char);
250            let s_char = buf.rope.byte_to_char(entry.position);
251            buf.rope.insert(s_char, &entry.removed);
252        }
253    }
254    buf.version = buf.version.wrapping_add(1);
255    cur.head = entry.cursor_before;
256    cur.anchor = entry.cursor_before;
257}
258
259fn apply_forward(entry: &UndoEntry, buf: &mut TextBuffer, cur: &mut TextCursor) {
260    match entry.kind {
261        UndoKind::Insert => {
262            let s_char = buf.rope.byte_to_char(entry.position);
263            buf.rope.insert(s_char, &entry.inserted);
264        }
265        UndoKind::Delete => {
266            let end = entry.position + entry.removed.len();
267            let s_char = buf.rope.byte_to_char(entry.position);
268            let e_char = buf.rope.byte_to_char(end);
269            buf.rope.remove(s_char..e_char);
270        }
271        UndoKind::Replace => {
272            let end = entry.position + entry.removed.len();
273            let s_char = buf.rope.byte_to_char(entry.position);
274            let e_char = buf.rope.byte_to_char(end);
275            buf.rope.remove(s_char..e_char);
276            let s_char = buf.rope.byte_to_char(entry.position);
277            buf.rope.insert(s_char, &entry.inserted);
278        }
279    }
280    buf.version = buf.version.wrapping_add(1);
281    // Land cursor at end of inserted (or at position for pure delete).
282    let new_byte = match entry.kind {
283        UndoKind::Insert | UndoKind::Replace => entry.position + entry.inserted.len(),
284        UndoKind::Delete => entry.position,
285    };
286    let s = buf.rope.to_string();
287    let p = TextPos::from_byte(&s, new_byte);
288    cur.head = p;
289    cur.anchor = p;
290}
291
292fn is_single_grapheme(s: &str) -> bool {
293    use unicode_segmentation::UnicodeSegmentation;
294    s.graphemes(true).count() == 1 && s != "\n"
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use lumen_core::text_model::TextBuffer;
301
302    #[test]
303    fn empty_stack_cannot_undo() {
304        let s = UndoStack::default();
305        assert!(!s.can_undo());
306        assert!(!s.can_redo());
307    }
308
309    #[test]
310    fn insert_then_undo_clears_buffer() {
311        let mut s = UndoStack::default();
312        let mut buf = TextBuffer::multi_line("hi");
313        let mut cur = TextCursor::default();
314        s.record_insert(0, "hi", TextPos::ZERO);
315        assert!(s.can_undo());
316        let ok = s.undo(&mut buf, &mut cur);
317        assert!(ok);
318        assert_eq!(buf.to_string(), "");
319    }
320
321    #[test]
322    fn delete_then_undo_restores_text() {
323        let mut s = UndoStack::default();
324        let mut buf = TextBuffer::multi_line("");
325        let mut cur = TextCursor::default();
326        // Pretend we deleted "hi" from position 0 - restore via undo.
327        s.record_delete(
328            0,
329            "hi",
330            TextPos {
331                byte: 2,
332                grapheme: 2,
333            },
334        );
335        assert!(s.can_undo());
336        s.undo(&mut buf, &mut cur);
337        assert_eq!(buf.to_string(), "hi");
338    }
339
340    #[test]
341    fn redo_reapplies_after_undo() {
342        let mut s = UndoStack::default();
343        let mut buf = TextBuffer::multi_line("abc");
344        let mut cur = TextCursor::default();
345        s.record_insert(
346            3,
347            "!",
348            TextPos {
349                byte: 3,
350                grapheme: 3,
351            },
352        );
353        // simulate the buffer carrying the inserted char already
354        buf.rope = ropey::Rope::from_str("abc!");
355        s.undo(&mut buf, &mut cur);
356        assert_eq!(buf.to_string(), "abc");
357        assert!(s.can_redo());
358        s.redo(&mut buf, &mut cur);
359        assert_eq!(buf.to_string(), "abc!");
360    }
361
362    #[test]
363    fn typing_coalesce_groups_single_chars() {
364        let mut s = UndoStack::default();
365        // Adjacent single-char inserts collapse.
366        s.record_insert(0, "a", TextPos::ZERO);
367        s.record_insert(1, "b", TextPos::ZERO);
368        s.record_insert(2, "c", TextPos::ZERO);
369        assert_eq!(s.len(), 1);
370        assert_eq!(s.entries[0].inserted, "abc");
371    }
372
373    #[test]
374    fn typing_coalesce_breaks_at_word_boundaries() {
375        let mut s = UndoStack::default();
376        // "hello world" typed one char at a time -> three word-ish
377        // groups: "hello", " ", "world".
378        for (i, ch) in "hello world".chars().enumerate() {
379            s.record_insert(i, &ch.to_string(), TextPos::ZERO);
380        }
381        assert_eq!(s.len(), 3);
382        assert_eq!(s.entries[0].inserted, "hello");
383        assert_eq!(s.entries[1].inserted, " ");
384        assert_eq!(s.entries[2].inserted, "world");
385    }
386
387    #[test]
388    fn typing_coalesce_breaks_on_pause() {
389        let mut s = UndoStack {
390            group_window: std::time::Duration::ZERO,
391            ..UndoStack::default()
392        };
393        // Zero group window => every keystroke is its own entry even
394        // with no word boundary in between.
395        s.record_insert(0, "a", TextPos::ZERO);
396        s.record_insert(1, "b", TextPos::ZERO);
397        assert_eq!(s.len(), 2, "elapsed pause breaks the coalesce group");
398    }
399
400    #[test]
401    fn clear_drops_all_entries() {
402        let mut s = UndoStack::default();
403        s.record_insert(0, "abc", TextPos::ZERO);
404        assert!(s.can_undo());
405        s.clear();
406        assert!(!s.can_undo());
407        assert!(!s.can_redo());
408        assert!(s.is_empty());
409    }
410
411    #[test]
412    fn stale_entry_is_dropped_instead_of_panicking() {
413        let mut s = UndoStack::default();
414        // Recorded against a longer buffer than the live one.
415        s.record_insert(10, "abc", TextPos::ZERO);
416        let mut buf = TextBuffer::multi_line("x"); // len 1 < 13
417        let mut cur = TextCursor::default();
418        assert!(!s.undo(&mut buf, &mut cur), "stale span must not apply");
419        assert!(s.is_empty(), "stack cleared after detecting staleness");
420        assert_eq!(buf.to_string(), "x", "buffer untouched");
421    }
422
423    #[test]
424    fn new_edit_truncates_redo_half() {
425        let mut s = UndoStack::default();
426        let mut buf = TextBuffer::multi_line("");
427        let mut cur = TextCursor::default();
428        s.record_insert(0, "X", TextPos::ZERO);
429        buf.rope = ropey::Rope::from_str("X");
430        s.undo(&mut buf, &mut cur);
431        assert!(s.can_redo());
432        // New edit invalidates redo.
433        s.record_insert(0, "Y", TextPos::ZERO);
434        assert!(!s.can_redo());
435    }
436}