//! Undo for INTERFACE mistakes, never sim mistakes (docs/03 §3.10). //! //! Kindness removes *friction*, never *consequence* (docs/01 §7): a delver //! who mis-clicks a loot filter or mistypes a chalk mark gets it back; a //! delver who drinks a mystery phial does not. This stack is generic over //! interface state (`T`) and knows nothing about the sim. Snapshots are moved //! into and out of the stack, so even non-`Clone` interface state is supported. //! //! Hook example (parent-side, NOT code — the CLI owns chalk): before applying //! a chalk-mark edit on the tactical planner (`M`, docs/05 §5.1 camera 3), //! the CLI pushes `undo.push("chalk: bridge?", marks.clone())`; on `u` it //! pops with [`UndoStack::undo`] and restores the marks. Same hook fits loot //! filters and menu selections. The sim tick, inventory, and world state must //! NEVER flow through this stack. /// Bounded LIFO stack of labeled interface snapshots. #[derive(Clone, Debug)] pub struct UndoStack { stack: Vec<(String, T)>, cap: usize, } impl UndoStack { /// New stack holding at most `cap` snapshots. `cap == 0` stores nothing /// (pushes are no-ops, [`UndoStack::undo`] always returns `None`). pub fn new(cap: usize) -> Self { Self { stack: Vec::new(), cap, } } /// Default capacity for casual interface state (16 snapshots). pub const DEFAULT_CAP: usize = 16; /// Push a labeled snapshot; drops the oldest beyond capacity. pub fn push(&mut self, label: &str, state: T) { if self.cap == 0 { return; } if self.stack.len() >= self.cap { self.stack.remove(0); } self.stack.push((label.to_owned(), state)); } /// Pop the most recent snapshot (LIFO), with its label. pub fn undo(&mut self) -> Option<(String, T)> { self.stack.pop() } /// Number of snapshots currently held. pub fn len(&self) -> usize { self.stack.len() } /// True when there is nothing to undo. pub fn is_empty(&self) -> bool { self.stack.is_empty() } /// Maximum snapshots held. pub fn cap(&self) -> usize { self.cap } /// Discard all snapshots (e.g. on screen change). pub fn clear(&mut self) { self.stack.clear(); } } impl Default for UndoStack { fn default() -> Self { Self::new(Self::DEFAULT_CAP) } } #[cfg(test)] mod tests { use super::*; #[test] fn lifo_order() { let mut u: UndoStack> = UndoStack::new(8); u.push("first", vec!["a".to_owned()]); u.push("second", vec!["a".to_owned(), "b".to_owned()]); let (label, state) = u.undo().expect("second undo target"); assert_eq!(label, "second"); assert_eq!(state, vec!["a".to_owned(), "b".to_owned()]); let (label, state) = u.undo().expect("first undo target"); assert_eq!(label, "first"); assert_eq!(state, vec!["a".to_owned()]); } #[test] fn cap_eviction_drops_oldest() { let mut u: UndoStack = UndoStack::new(2); u.push("one", 1); u.push("two", 2); u.push("three", 3); assert_eq!(u.len(), 2); assert_eq!(u.undo(), Some(("three".to_owned(), 3))); assert_eq!(u.undo(), Some(("two".to_owned(), 2))); assert_eq!(u.undo(), None, "evicted 'one' is gone"); } #[test] fn empty_undo_is_none() { let mut u: UndoStack = UndoStack::new(4); assert!(u.is_empty()); assert_eq!(u.undo(), None); } #[test] fn labels_preserved() { let mut u: UndoStack = UndoStack::new(4); u.push("chalk: bridge?", 'b'); u.push("filter: wire only", 'w'); assert_eq!(u.undo(), Some(("filter: wire only".to_owned(), 'w'))); } #[test] fn len_and_is_empty_track() { let mut u: UndoStack = UndoStack::new(4); assert!(u.is_empty()); u.push("a", 1); assert_eq!(u.len(), 1); assert!(!u.is_empty()); u.clear(); assert!(u.is_empty()); } #[test] fn zero_cap_stores_nothing() { let mut u: UndoStack = UndoStack::new(0); u.push("x", 1); assert!(u.is_empty()); assert_eq!(u.undo(), None); } #[test] fn undo_returns_owned_snapshot() { let mut u: UndoStack> = UndoStack::new(4); let marks = vec![1, 2, 3]; u.push("chalk", marks.clone()); let (_, back) = u.undo().expect("snapshot"); assert_eq!(back, marks); } #[test] fn supports_non_clone_interface_state() { #[derive(Debug, PartialEq, Eq)] struct CursorSelection(usize); let mut u = UndoStack::new(1); u.push("cursor", CursorSelection(7)); assert_eq!(u.undo(), Some(("cursor".to_owned(), CursorSelection(7)))); } #[test] fn default_capacity_is_bounded() { let mut u = UndoStack::default(); for state in 0..(UndoStack::::DEFAULT_CAP + 3) { u.push("selection", state); } assert_eq!(u.cap(), UndoStack::::DEFAULT_CAP); assert_eq!(u.len(), UndoStack::::DEFAULT_CAP); } #[test] fn clear_preserves_capacity_for_reuse() { let mut u = UndoStack::new(2); u.push("one", 1); u.clear(); assert_eq!(u.cap(), 2); u.push("two", 2); assert_eq!(u.undo(), Some(("two".to_owned(), 2))); } }