use std::collections::VecDeque;
use super::place::{Place, Range};
use super::select::Selection;
#[derive(Debug, Clone, PartialEq)]
pub enum UndoItem {
InsertWord {
old: Place,
new: Place,
ch: char,
before: Selection,
},
InsertReturn {
old: Place,
new: Place,
before: Selection,
},
Backspace {
old: Place,
new: Place,
ch: char,
section_break: bool,
before: Selection,
},
Delete {
old: Place,
new: Place,
ch: char,
section_break: bool,
before: Selection,
},
Clear {
range: Range,
text: String,
before: Selection,
},
InsertText {
old: Place,
new: Place,
text: String,
before: Selection,
},
GroupBoundary,
}
impl UndoItem {
#[must_use]
pub fn is_boundary(&self) -> bool {
matches!(self, UndoItem::GroupBoundary)
}
#[must_use]
pub fn before(&self) -> Option<Selection> {
match self {
UndoItem::InsertWord { before, .. }
| UndoItem::InsertReturn { before, .. }
| UndoItem::Backspace { before, .. }
| UndoItem::Delete { before, .. }
| UndoItem::Clear { before, .. }
| UndoItem::InsertText { before, .. } => Some(*before),
UndoItem::GroupBoundary => None,
}
}
}
#[derive(Debug, Clone)]
pub struct UndoStack {
items: VecDeque<UndoItem>,
pos: usize,
max: usize,
enabled: bool,
}
impl UndoStack {
pub const DEFAULT_MAX: u32 = 10_000;
pub const MIN_MAX: u32 = 4;
#[must_use]
pub fn with_max(max: u32) -> UndoStack {
UndoStack {
items: VecDeque::new(),
pos: 0,
max: max.max(UndoStack::MIN_MAX) as usize,
enabled: true,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
#[must_use]
pub fn max(&self) -> usize {
self.max
}
#[must_use]
pub fn len(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use]
pub fn can_undo(&self) -> bool {
self.pos > 0
}
#[must_use]
pub fn can_redo(&self) -> bool {
self.pos < self.items.len()
}
pub fn clear(&mut self) {
self.items.clear();
self.pos = 0;
}
pub fn push(&mut self, item: UndoItem) {
if !self.enabled {
return;
}
self.items.truncate(self.pos);
self.evict_to_fit();
self.items.push_back(item);
self.pos = self.items.len();
}
fn evict_to_fit(&mut self) {
while self.items.len() >= self.max {
let opened_group = matches!(self.items.front(), Some(UndoItem::GroupBoundary));
self.items.pop_front();
if opened_group {
while let Some(item) = self.items.pop_front() {
if item.is_boundary() {
break;
}
}
}
if self.items.is_empty() {
break;
}
}
self.pos = self.items.len();
}
pub fn undo(&mut self) -> Vec<UndoItem> {
let mut taken = Vec::new();
let mut first = true;
while self.pos > 0 {
self.pos -= 1;
let Some(item) = self.items.get(self.pos) else {
break;
};
let is_boundary = item.is_boundary();
taken.push(item.clone());
if first {
first = false;
if !is_boundary {
break;
}
} else if is_boundary {
break;
}
}
taken
}
pub fn redo(&mut self) -> Vec<UndoItem> {
let mut taken = Vec::new();
let mut first = true;
while self.pos < self.items.len() {
let Some(item) = self.items.get(self.pos) else {
break;
};
let is_boundary = item.is_boundary();
taken.push(item.clone());
self.pos += 1;
if first {
first = false;
if !is_boundary {
break;
}
} else if is_boundary {
break;
}
}
taken
}
pub fn items(&self) -> impl Iterator<Item = &UndoItem> {
self.items.iter()
}
#[must_use]
pub fn position(&self) -> usize {
self.pos
}
}
impl PartialEq for UndoStack {
fn eq(&self, other: &UndoStack) -> bool {
self.pos == other.pos && self.items == other.items
}
}
impl Eq for UndoStack {}
impl Default for UndoStack {
fn default() -> UndoStack {
UndoStack::with_max(UndoStack::DEFAULT_MAX)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn word(ch: char) -> UndoItem {
UndoItem::InsertWord {
old: Place::start(),
new: Place::start(),
ch,
before: Selection::empty(),
}
}
fn chars_of(items: &[UndoItem]) -> Vec<char> {
items
.iter()
.filter_map(|i| match i {
UndoItem::InsertWord { ch, .. } => Some(*ch),
_ => None,
})
.collect()
}
#[test]
fn a_fresh_stack_can_neither_undo_nor_redo() {
let stack = UndoStack::default();
assert!(!stack.can_undo());
assert!(!stack.can_redo());
}
#[test]
fn one_typed_character_is_one_item() {
let mut stack = UndoStack::default();
for ch in "ABCDE".chars() {
stack.push(word(ch));
}
assert_eq!(stack.len(), 5);
assert_eq!(chars_of(&stack.undo()), vec!['E']);
assert_eq!(chars_of(&stack.undo()), vec!['D']);
assert!(stack.can_undo());
assert!(stack.can_redo());
assert_eq!(chars_of(&stack.redo()), vec!['D']);
assert_eq!(chars_of(&stack.redo()), vec!['E']);
assert!(!stack.can_redo());
assert!(stack.can_undo());
}
#[test]
fn undoing_to_the_bottom_reports_the_bottom() {
let mut stack = UndoStack::default();
for ch in "ABC".chars() {
stack.push(word(ch));
}
for _ in 0..3 {
assert!(stack.can_undo());
stack.undo();
}
assert!(!stack.can_undo());
assert!(stack.undo().is_empty());
}
#[test]
fn a_group_undoes_and_redoes_as_one_step() {
let mut stack = UndoStack::default();
stack.push(word('A'));
stack.push(UndoItem::GroupBoundary);
stack.push(word('X'));
stack.push(word('Y'));
stack.push(word('Z'));
stack.push(UndoItem::GroupBoundary);
let undone = stack.undo();
assert_eq!(chars_of(&undone), vec!['Z', 'Y', 'X']);
assert!(stack.can_undo());
assert_eq!(chars_of(&stack.undo()), vec!['A']);
assert!(!stack.can_undo());
assert_eq!(chars_of(&stack.redo()), vec!['A']);
assert_eq!(chars_of(&stack.redo()), vec!['X', 'Y', 'Z']);
assert!(!stack.can_redo());
}
#[test]
fn a_fresh_edit_drops_the_redo_branch() {
let mut stack = UndoStack::default();
stack.push(word('A'));
stack.push(word('B'));
stack.undo();
assert!(stack.can_redo());
stack.push(word('C'));
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert_eq!(chars_of(&stack.undo()), vec!['C']);
}
#[test]
fn capacity_is_clamped_up_to_the_worst_case_group() {
assert_eq!(UndoStack::with_max(0).max(), 4);
assert_eq!(UndoStack::with_max(1).max(), 4);
assert_eq!(UndoStack::with_max(4).max(), 4);
assert_eq!(UndoStack::with_max(9).max(), 9);
}
#[test]
fn eviction_never_leaves_half_a_group() {
let mut stack = UndoStack::with_max(4);
stack.push(UndoItem::GroupBoundary);
stack.push(word('X'));
stack.push(word('Y'));
stack.push(UndoItem::GroupBoundary);
assert_eq!(stack.len(), 4);
stack.push(word('Z'));
let boundaries = stack.items().filter(|i| i.is_boundary()).count();
assert_eq!(boundaries % 2, 0, "an unmatched boundary survived");
assert_eq!(
chars_of(&stack.items().cloned().collect::<Vec<_>>()),
vec!['Z']
);
}
#[test]
fn eviction_holds_the_invariants_over_many_shapes() {
for max in [4u32, 5, 7] {
for seed in 0..64u32 {
let mut stack = UndoStack::with_max(max);
let mut bits = seed;
for n in 0..20u32 {
if bits & 1 == 1 {
stack.push(UndoItem::GroupBoundary);
stack.push(word('a'));
stack.push(UndoItem::GroupBoundary);
} else {
stack.push(word(char::from_u32('a' as u32 + n % 26).unwrap_or('a')));
}
bits >>= 1;
if bits == 0 {
bits = seed | 1;
}
assert!(stack.len() <= max as usize, "capacity exceeded");
let boundaries = stack.items().filter(|i| i.is_boundary()).count();
assert_eq!(
boundaries % 2,
0,
"unmatched boundary at max={max} seed={seed}"
);
}
}
}
}
#[test]
fn a_disabled_stack_records_nothing() {
let mut stack = UndoStack::default();
stack.set_enabled(false);
stack.push(word('A'));
stack.push(UndoItem::GroupBoundary);
assert!(stack.is_empty());
assert!(!stack.can_undo());
}
#[test]
fn clear_forgets_both_branches() {
let mut stack = UndoStack::default();
stack.push(word('A'));
stack.push(word('B'));
stack.undo();
stack.clear();
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.is_empty());
}
}