"""Undo/redo system using the Command pattern.
Provides an UndoStack that tracks reversible operations, with pre-built
command types for property changes, arbitrary callables, and batched
operations.
Example:
stack = UndoStack()
cmd = PropertyCommand(node, "position", (0, 0, 0), (1, 2, 3),
description="Move node")
stack.push(cmd) # executes + records
stack.undo() # reverts to (0, 0, 0)
stack.redo() # re-applies (1, 2, 3)
"""
import logging
from collections.abc import Callable, Sequence
from typing import Any, Protocol, runtime_checkable
from .signals import Signal
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Command protocol
# ---------------------------------------------------------------------------
[docs]
@runtime_checkable
class Command(Protocol):
"""Interface for undoable operations."""
[docs]
@property
def description(self) -> str: ...
[docs]
def execute(self) -> None: ...
[docs]
def undo(self) -> None: ...
# ---------------------------------------------------------------------------
# Pre-built command types
# ---------------------------------------------------------------------------
[docs]
class PropertyCommand:
"""Sets *obj.attr* to *new_value*; undoes by restoring *old_value*."""
def __init__(
self,
obj: Any,
attr: str,
old_value: Any,
new_value: Any,
description: str = "",
) -> None:
self._obj = obj
self._attr = attr
self._old = old_value
self._new = new_value
self._description = description or f"Set {attr}"
[docs]
@property
def description(self) -> str:
return self._description
[docs]
def execute(self) -> None:
setattr(self._obj, self._attr, self._new)
[docs]
def undo(self) -> None:
setattr(self._obj, self._attr, self._old)
[docs]
class CallableCommand:
"""Wraps a *do_fn* / *undo_fn* pair as a command."""
def __init__(
self,
do_fn: Callable[[], None],
undo_fn: Callable[[], None],
description: str = "",
) -> None:
self._do = do_fn
self._undo = undo_fn
self._description = description or "Action"
[docs]
@property
def description(self) -> str:
return self._description
[docs]
def execute(self) -> None:
self._do()
[docs]
def undo(self) -> None:
self._undo()
[docs]
class BatchCommand:
"""Groups several commands into a single undo step.
Commands are executed in order and undone in reverse order.
"""
def __init__(
self,
commands: Sequence[Command],
description: str = "",
) -> None:
self._commands: list[Command] = list(commands)
self._description = description or "Batch"
[docs]
@property
def description(self) -> str:
return self._description
[docs]
def execute(self) -> None:
for cmd in self._commands:
cmd.execute()
[docs]
def undo(self) -> None:
for cmd in reversed(self._commands):
cmd.undo()
# ---------------------------------------------------------------------------
# Structural (scene-tree) commands
# ---------------------------------------------------------------------------
#
# Generic add / remove / reparent leaves usable by any tool that drives a
# ``simvx.core.Node`` tree (editor, IDE plugins, asset import wizards,
# scripted demos that mutate trees). The editor wires these from its scene
# tree panel; everything else can reuse them without re-inventing the
# command bodies.
#
# ``Node`` is imported lazily inside each command to keep the
# ``simvx.core.undo`` module free of the heavy node hierarchy import at
# top level (a small but real wins on import-graph order).
def _restore_child_index(parent: Any, child: Any, index: int) -> None:
"""Insert *child* under *parent* at *index* without touching siblings.
``Node.add_child`` always appends; this helper rewinds the new entry
to the original slot via the underlying ``_NodeChildren`` list so undo
really restores order. Wrapped here because both ``AddChildCommand``
and ``RemoveChildCommand`` need it.
"""
if index < 0:
return
lst = parent.children._list
if child in lst:
lst.remove(child)
lst.insert(index, child)
parent.children._dirty = True
[docs]
class AddChildCommand:
"""Add *child* under *parent*. Undo removes the child.
If *index* is provided (>= 0), the child is moved to that slot after
the append so order is preserved on redo.
"""
def __init__(self, parent: Any, child: Any, index: int = -1, description: str = "") -> None:
self._parent = parent
self._child = child
self._index = index
self._description = description or f"Add {getattr(child, 'name', child)}"
[docs]
@property
def description(self) -> str:
return self._description
[docs]
def execute(self) -> None:
self._parent.add_child(self._child)
if self._index >= 0:
_restore_child_index(self._parent, self._child, self._index)
[docs]
def undo(self) -> None:
self._parent.remove_child(self._child)
[docs]
class RemoveChildCommand:
"""Remove *child* from *parent*. Undo re-adds at the original index.
The original index is captured at construction so the undo always
restores the exact slot: even if siblings have shifted by the time
the undo runs.
"""
def __init__(self, parent: Any, child: Any, description: str = "") -> None:
self._parent = parent
self._child = child
self._index = list(parent.children).index(child)
self._description = description or f"Remove {getattr(child, 'name', child)}"
[docs]
@property
def description(self) -> str:
return self._description
[docs]
def execute(self) -> None:
self._parent.remove_child(self._child)
[docs]
def undo(self) -> None:
self._parent.add_child(self._child)
_restore_child_index(self._parent, self._child, self._index)
[docs]
class ReparentCommand:
"""Move *node* from *old_parent* to *new_parent*. Undo restores both
the original parent and the original sibling order via *old_index*.
"""
def __init__(
self,
node: Any,
new_parent: Any,
old_parent: Any,
old_index: int,
description: str = "",
) -> None:
self._node = node
self._new_parent = new_parent
self._old_parent = old_parent
self._old_index = old_index
self._description = description or f"Reparent {getattr(node, 'name', node)}"
[docs]
@property
def description(self) -> str:
return self._description
[docs]
def execute(self) -> None:
self._old_parent.remove_child(self._node)
self._new_parent.add_child(self._node)
[docs]
def undo(self) -> None:
self._new_parent.remove_child(self._node)
self._old_parent.add_child(self._node)
_restore_child_index(self._old_parent, self._node, self._old_index)
# ---------------------------------------------------------------------------
# Undo stack
# ---------------------------------------------------------------------------
[docs]
class UndoStack:
"""Maintains undo and redo history with an optional size limit.
Parameters
----------
max_size:
Maximum number of commands kept in the undo history. When exceeded
the oldest entry is silently dropped. Defaults to 100.
"""
def __init__(self, max_size: int = 100) -> None:
self._undo: list[Command] = []
self._redo: list[Command] = []
self._max_size = max_size
self.changed = Signal()
# -- public API ---------------------------------------------------------
[docs]
def push(self, command: Command) -> None:
"""Execute *command* and record it on the undo stack."""
command.execute()
self._record(command)
[docs]
def push_without_execute(self, command: Command) -> None:
"""Record *command* on the undo stack without executing it.
Use for operations whose effect has already been applied directly
(e.g. gizmo drag, where intermediate frames mutated state). The
command's ``undo()`` must still restore the pre-operation state.
"""
self._record(command)
def _record(self, command: Command) -> None:
"""Append *command* to undo history and trim to ``max_size``."""
self._undo.append(command)
self._redo.clear()
if len(self._undo) > self._max_size:
self._undo.pop(0)
self.changed.emit()
[docs]
def undo(self) -> bool:
"""Undo the most recent command. Returns *True* if an action was undone."""
if not self._undo:
return False
cmd = self._undo.pop()
cmd.undo()
self._redo.append(cmd)
self.changed.emit()
return True
[docs]
def redo(self) -> bool:
"""Redo the most recently undone command. Returns *True* if an action was redone."""
if not self._redo:
return False
cmd = self._redo.pop()
cmd.execute()
self._undo.append(cmd)
self.changed.emit()
return True
[docs]
def clear(self) -> None:
"""Discard all undo and redo history."""
self._undo.clear()
self._redo.clear()
self.changed.emit()
# -- properties ---------------------------------------------------------
[docs]
@property
def can_undo(self) -> bool:
return bool(self._undo)
[docs]
@property
def can_redo(self) -> bool:
return bool(self._redo)
[docs]
@property
def undo_description(self) -> str | None:
"""Description of the next action that would be undone, or *None*."""
return self._undo[-1].description if self._undo else None
[docs]
@property
def redo_description(self) -> str | None:
"""Description of the next action that would be redone, or *None*."""
return self._redo[-1].description if self._redo else None
@property
def max_size(self) -> int:
return self._max_size
[docs]
@max_size.setter
def max_size(self, value: int) -> None:
self._max_size = value
while len(self._undo) > self._max_size:
self._undo.pop(0)