//! The editor's central undo/redo command stack. //! //! Every editor mutation that should be undoable — a transform edit, a rename, a //! spawn/despawn, and later sculpt/paint/scatter brush strokes — is expressed as //! a [`Command`] and pushed onto a [`CommandStack`]. Routing *all* edits through //! one stack is what makes undo/redo consistent across the whole editor, and it //! is why the Stage-7 gizmos and every later tool get undo "for free". //! //! The stack is generic over the context `C` a command mutates (in the editor //! that is the scene + editor state), which keeps it decoupled and unit-testable //! against a trivial context. use std::any::Any; /// A reversible editor action over a context `C`. /// /// A command must be able to [`apply`](Self::apply) its effect and exactly /// [`undo`](Self::undo) it. Commands are stored boxed on the [`CommandStack`]. pub trait Command: 'static { /// Performs the action, mutating `ctx`. fn apply(&mut self, ctx: &mut C); /// Reverses the action, restoring `ctx` to its pre-[`apply`](Self::apply) state. fn undo(&mut self, ctx: &mut C); /// A short human-readable label (shown in the Edit menu / history). fn label(&self) -> String; /// Upcast for [`merge`](Self::merge) to downcast a following command. /// Implement as `self`. fn as_any_mut(&mut self) -> &mut dyn Any; /// Tries to fold the immediately-following command `next` into this one so /// they share a single undo entry (e.g. every frame of a gizmo drag becomes /// one undoable move). Return `true` if absorbed; the default never merges. /// /// When merging, update `self` so that undoing it reverses *both* effects. fn merge(&mut self, next: &mut dyn Command) -> bool { let _ = next; false } } /// A composite command: several commands grouped into one undo entry. /// /// Applied front-to-back and undone back-to-front, so a multi-step operation /// (e.g. "duplicate and offset") is a single, atomic undo. pub struct Group { label: String, commands: Vec>>, } impl Group { /// A new, empty group with the given label. pub fn new(label: impl Into) -> Self { Self { label: label.into(), commands: Vec::new(), } } /// Adds a command to the group (not yet applied). pub fn push(&mut self, command: impl Command + 'static) { self.commands.push(Box::new(command)); } /// Whether the group has no commands. pub fn is_empty(&self) -> bool { self.commands.is_empty() } } impl Command for Group { fn apply(&mut self, ctx: &mut C) { for command in &mut self.commands { command.apply(ctx); } } fn undo(&mut self, ctx: &mut C) { for command in self.commands.iter_mut().rev() { command.undo(ctx); } } fn label(&self) -> String { self.label.clone() } fn as_any_mut(&mut self) -> &mut dyn Any { self } } /// A bounded undo/redo stack of [`Command`]s over a context `C`. /// /// Pushing a command applies it and clears the redo history. Capacity caps how /// many undo entries are retained (oldest dropped first) so the history cannot /// grow without bound. pub struct CommandStack { undo: Vec>>, redo: Vec>>, capacity: usize, } impl CommandStack { /// The default maximum number of retained undo entries. pub const DEFAULT_CAPACITY: usize = 256; /// A stack with the [default capacity](Self::DEFAULT_CAPACITY). pub fn new() -> Self { Self::with_capacity(Self::DEFAULT_CAPACITY) } /// A stack retaining at most `capacity` undo entries (minimum 1). pub fn with_capacity(capacity: usize) -> Self { Self { undo: Vec::new(), redo: Vec::new(), capacity: capacity.max(1), } } /// Applies `command` and records it, clearing the redo history. /// /// If the previous top entry [`merge`](Command::merge)s this command, the two /// share one undo entry instead of pushing a new one. pub fn push(&mut self, command: impl Command + 'static, ctx: &mut C) { self.push_boxed(Box::new(command), ctx); } /// Applies and records an already-boxed command (e.g. a [`Group`]). pub fn push_boxed(&mut self, mut command: Box>, ctx: &mut C) { command.apply(ctx); self.redo.clear(); if let Some(top) = self.undo.last_mut() { if top.merge(command.as_mut()) { return; } } self.undo.push(command); while self.undo.len() > self.capacity { self.undo.remove(0); } } /// Undoes the most recent command, moving it to the redo history. Returns its /// label, or `None` if there was nothing to undo. pub fn undo(&mut self, ctx: &mut C) -> Option { let mut command = self.undo.pop()?; command.undo(ctx); let label = command.label(); self.redo.push(command); Some(label) } /// Redoes the most recently undone command. Returns its label, or `None`. pub fn redo(&mut self, ctx: &mut C) -> Option { let mut command = self.redo.pop()?; command.apply(ctx); let label = command.label(); self.undo.push(command); Some(label) } /// Whether there is anything to undo. pub fn can_undo(&self) -> bool { !self.undo.is_empty() } /// Whether there is anything to redo. pub fn can_redo(&self) -> bool { !self.redo.is_empty() } /// The label of the next undo, if any (for the Edit menu). pub fn undo_label(&self) -> Option { self.undo.last().map(|c| c.label()) } /// The label of the next redo, if any. pub fn redo_label(&self) -> Option { self.redo.last().map(|c| c.label()) } /// The number of retained undo entries. pub fn undo_depth(&self) -> usize { self.undo.len() } /// Clears all history (e.g. on project close). pub fn clear(&mut self) { self.undo.clear(); self.redo.clear(); } } impl Default for CommandStack { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; /// A trivial context: a single integer the test commands mutate. type Ctx = i32; /// Adds `amount` to the context; undo subtracts it. Consecutive `Add`s merge /// into one undo entry (modeling a continuous drag). struct Add { amount: i32, mergeable: bool, } impl Add { fn new(amount: i32) -> Self { Self { amount, mergeable: true, } } fn standalone(amount: i32) -> Self { Self { amount, mergeable: false, } } } impl Command for Add { fn apply(&mut self, ctx: &mut Ctx) { *ctx += self.amount; } fn undo(&mut self, ctx: &mut Ctx) { *ctx -= self.amount; } fn label(&self) -> String { format!("Add {}", self.amount) } fn as_any_mut(&mut self) -> &mut dyn Any { self } fn merge(&mut self, next: &mut dyn Command) -> bool { if !self.mergeable { return false; } if let Some(other) = next.as_any_mut().downcast_mut::() { if other.mergeable { // Fold next's effect into this entry: undoing reverses both. self.amount += other.amount; return true; } } false } } #[test] fn apply_undo_redo_round_trip() { let mut ctx: Ctx = 0; let mut stack = CommandStack::new(); stack.push(Add::standalone(5), &mut ctx); stack.push(Add::standalone(3), &mut ctx); assert_eq!(ctx, 8); assert_eq!(stack.undo_depth(), 2); assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 3")); assert_eq!(ctx, 5); assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 5")); assert_eq!(ctx, 0); assert!(!stack.can_undo()); assert_eq!(stack.redo(&mut ctx).as_deref(), Some("Add 5")); assert_eq!(ctx, 5); assert!(stack.can_redo()); } #[test] fn pushing_clears_redo() { let mut ctx: Ctx = 0; let mut stack = CommandStack::new(); stack.push(Add::standalone(1), &mut ctx); stack.undo(&mut ctx); assert!(stack.can_redo()); stack.push(Add::standalone(10), &mut ctx); // new edit invalidates redo assert!(!stack.can_redo()); assert_eq!(ctx, 10); } #[test] fn consecutive_mergeable_commands_share_one_entry() { let mut ctx: Ctx = 0; let mut stack = CommandStack::new(); // Simulate a drag: many small mergeable adds. for _ in 0..5 { stack.push(Add::new(2), &mut ctx); } assert_eq!(ctx, 10); assert_eq!(stack.undo_depth(), 1, "drag should be one undo entry"); // A single undo reverses the whole drag. stack.undo(&mut ctx); assert_eq!(ctx, 0); } #[test] fn group_is_atomic() { let mut ctx: Ctx = 0; let mut stack = CommandStack::new(); let mut group = Group::new("Duplicate+Offset"); group.push(Add::standalone(4)); group.push(Add::standalone(6)); stack.push_boxed(Box::new(group), &mut ctx); assert_eq!(ctx, 10); assert_eq!(stack.undo_depth(), 1); assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Duplicate+Offset")); assert_eq!(ctx, 0, "group undoes as one atomic step"); } #[test] fn capacity_drops_oldest_entries() { let mut ctx: Ctx = 0; let mut stack = CommandStack::with_capacity(3); for i in 1..=5 { stack.push(Add::standalone(i), &mut ctx); } // Only the last 3 entries are retained for undo. assert_eq!(stack.undo_depth(), 3); // Undoing all retained entries removes 3+4+5 = 12 from the final 15. while stack.undo(&mut ctx).is_some() {} assert_eq!(ctx, 1 + 2); // the dropped 1 and 2 can't be undone } }