use std::collections::VecDeque;
use std::time::{Duration, Instant};
const UNDO_STACK_CAP: usize = 100;
const COALESCE_TIME_GAP: Duration = Duration::from_millis(500);
#[derive(Clone, Debug)]
pub(crate) struct EditSnapshot {
pub(crate) text: String,
pub(crate) caret: usize,
pub(crate) anchor: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum EditOp {
Insert,
Backspace,
Discrete,
}
#[derive(Clone, Debug)]
pub struct UndoStack {
entries: VecDeque<EditSnapshot>,
cursor: usize,
head_op: Option<EditOp>,
head_edit_t: Option<Instant>,
motion_since_last_edit: bool,
}
impl Default for UndoStack {
fn default() -> Self {
Self::with_baseline(String::new(), 0)
}
}
impl UndoStack {
pub(crate) fn with_baseline(text: String, caret: usize) -> Self {
let mut entries = VecDeque::with_capacity(UNDO_STACK_CAP);
entries.push_back(EditSnapshot {
text,
caret,
anchor: None,
});
Self {
entries,
cursor: 0,
head_op: None,
head_edit_t: None,
motion_since_last_edit: false,
}
}
pub(crate) fn record_edit(&mut self, op: EditOp, post: EditSnapshot) {
if self.cursor + 1 < self.entries.len() {
self.entries.drain(self.cursor + 1..);
}
if self.should_push(op) {
if self.entries.len() == UNDO_STACK_CAP {
self.entries.pop_front();
self.cursor = self.cursor.saturating_sub(1);
}
self.entries.push_back(post);
self.cursor = self.entries.len() - 1;
} else if let Some(slot) = self.entries.get_mut(self.cursor) {
*slot = post;
}
self.head_op = Some(op);
self.head_edit_t = Some(Instant::now());
self.motion_since_last_edit = false;
}
pub(crate) fn mark_motion(&mut self) {
self.motion_since_last_edit = true;
}
pub(crate) fn undo(&mut self) -> Option<EditSnapshot> {
if self.cursor == 0 {
return None;
}
self.cursor -= 1;
self.head_op = None;
self.head_edit_t = None;
self.motion_since_last_edit = false;
Some(self.entries[self.cursor].clone())
}
pub(crate) fn redo(&mut self) -> Option<EditSnapshot> {
if self.cursor + 1 >= self.entries.len() {
return None;
}
self.cursor += 1;
self.head_op = None;
self.head_edit_t = None;
self.motion_since_last_edit = false;
Some(self.entries[self.cursor].clone())
}
fn should_push(&self, op: EditOp) -> bool {
let Some(prev_op) = self.head_op else {
return true;
};
if op == EditOp::Discrete || prev_op == EditOp::Discrete {
return true;
}
if prev_op != op {
return true;
}
if let Some(t) = self.head_edit_t
&& t.elapsed() > COALESCE_TIME_GAP
{
return true;
}
if self.motion_since_last_edit {
return true;
}
false
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.entries.len()
}
#[cfg(test)]
pub(crate) fn cursor(&self) -> usize {
self.cursor
}
}
#[cfg(test)]
mod undo_tests {
use super::*;
fn snap(text: &str, caret: usize) -> EditSnapshot {
EditSnapshot {
text: text.to_string(),
caret,
anchor: None,
}
}
fn fresh() -> UndoStack {
UndoStack::with_baseline(String::new(), 0)
}
#[test]
fn baseline_is_present_and_undo_returns_none() {
let mut s = fresh();
assert_eq!(s.len(), 1);
assert_eq!(s.cursor(), 0);
assert!(s.undo().is_none());
}
#[test]
fn typing_burst_coalesces_into_single_step() {
let mut s = fresh();
s.record_edit(EditOp::Insert, snap("a", 1));
s.record_edit(EditOp::Insert, snap("ab", 2));
s.record_edit(EditOp::Insert, snap("abc", 3));
assert_eq!(s.len(), 2);
let restored = s.undo().expect("undo should restore baseline");
assert_eq!(restored.text, "");
}
#[test]
fn op_type_change_opens_new_step() {
let mut s = fresh();
s.record_edit(EditOp::Insert, snap("a", 1));
s.record_edit(EditOp::Backspace, snap("", 0));
assert_eq!(s.len(), 3);
}
#[test]
fn motion_marks_split_typing_into_two_steps() {
let mut s = fresh();
s.record_edit(EditOp::Insert, snap("a", 1));
s.mark_motion();
s.record_edit(EditOp::Insert, snap("ab", 2));
assert_eq!(s.len(), 3);
}
#[test]
fn discrete_op_never_coalesces() {
let mut s = fresh();
s.record_edit(EditOp::Discrete, snap("p", 1));
s.record_edit(EditOp::Discrete, snap("pp", 2));
assert_eq!(s.len(), 3);
}
#[test]
fn typing_after_discrete_op_does_not_coalesce_backward() {
let mut s = fresh();
s.record_edit(EditOp::Discrete, snap("pasted", 6));
s.record_edit(EditOp::Insert, snap("pastedX", 7));
assert_eq!(s.len(), 3);
}
#[test]
fn undo_then_edit_truncates_redo_tail() {
let mut s = fresh();
s.record_edit(EditOp::Insert, snap("a", 1));
s.record_edit(EditOp::Backspace, snap("", 0));
s.undo();
s.record_edit(EditOp::Insert, snap("aX", 2));
assert_eq!(s.len(), 3);
assert_eq!(s.cursor(), 2);
assert!(s.redo().is_none(), "redo tail must be empty after new edit");
}
#[test]
fn redo_walks_forward_after_undo() {
let mut s = fresh();
s.record_edit(EditOp::Insert, snap("a", 1));
s.record_edit(EditOp::Backspace, snap("", 0));
let _ = s.undo();
let redone = s.redo().expect("redo available");
assert_eq!(redone.text, "");
}
#[test]
fn capacity_overflow_drops_oldest_and_clamps_cursor() {
let mut s = fresh();
for i in 1..=(UNDO_STACK_CAP + 5) {
s.record_edit(EditOp::Insert, snap(&"x".repeat(i), i));
s.mark_motion();
}
assert_eq!(s.len(), UNDO_STACK_CAP);
assert!(s.cursor() < s.len());
}
#[test]
fn undo_after_typing_burst_restores_pre_burst_state() {
let mut s = fresh();
s.record_edit(EditOp::Insert, snap("h", 1));
s.record_edit(EditOp::Insert, snap("he", 2));
s.record_edit(EditOp::Insert, snap("hel", 3));
let restored = s.undo().expect("undo");
assert_eq!(restored.text, "", "Apple Notes: one undo erases the burst");
}
}