Files
Oxide/editor/src/command.rs
T
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

336 lines
10 KiB
Rust

//! 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<C>: '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<C>) -> 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<C> {
label: String,
commands: Vec<Box<dyn Command<C>>>,
}
impl<C: 'static> Group<C> {
/// A new, empty group with the given label.
pub fn new(label: impl Into<String>) -> 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<C> + 'static) {
self.commands.push(Box::new(command));
}
/// Whether the group has no commands.
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
impl<C: 'static> Command<C> for Group<C> {
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<C> {
undo: Vec<Box<dyn Command<C>>>,
redo: Vec<Box<dyn Command<C>>>,
capacity: usize,
}
impl<C: 'static> CommandStack<C> {
/// 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<C> + '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<dyn Command<C>>, 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<String> {
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<String> {
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<String> {
self.undo.last().map(|c| c.label())
}
/// The label of the next redo, if any.
pub fn redo_label(&self) -> Option<String> {
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<C: 'static> Default for CommandStack<C> {
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<Ctx> 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<Ctx>) -> bool {
if !self.mergeable {
return false;
}
if let Some(other) = next.as_any_mut().downcast_mut::<Add>() {
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
}
}