1#![allow(missing_docs)]
14
15use bevy_ecs::prelude::*;
16use lumen_core::text_model::{TextBuffer, TextCursor, TextPos};
17use lumen_core::time::Instant;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum UndoKind {
22 Insert,
24 Delete,
26 Replace,
28}
29
30#[derive(Clone, Debug)]
32pub struct UndoEntry {
33 pub kind: UndoKind,
35 pub position: usize,
37 pub removed: String,
39 pub inserted: String,
41 pub cursor_before: TextPos,
43}
44
45#[derive(Component, Debug, Clone)]
52pub struct UndoStack {
53 entries: Vec<UndoEntry>,
54 head: usize,
55 last_edit: Option<Instant>,
56 pub group_window: std::time::Duration,
59 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 pub fn len(&self) -> usize {
78 self.entries.len()
79 }
80
81 pub fn is_empty(&self) -> bool {
83 self.entries.is_empty()
84 }
85
86 pub fn can_undo(&self) -> bool {
88 self.head > 0
89 }
90
91 pub fn can_redo(&self) -> bool {
93 self.head < self.entries.len()
94 }
95
96 pub fn clear(&mut self) {
99 self.entries.clear();
100 self.head = 0;
101 self.last_edit = None;
102 }
103
104 pub fn record_insert(&mut self, position: usize, inserted: &str, cursor_before: TextPos) {
106 let now = Instant::now();
107 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 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 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 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 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 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 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
222fn 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 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 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 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 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 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 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 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 s.record_insert(10, "abc", TextPos::ZERO);
416 let mut buf = TextBuffer::multi_line("x"); 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 s.record_insert(0, "Y", TextPos::ZERO);
434 assert!(!s.can_redo());
435 }
436}