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>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user