Source code for simvx.core.undo

"""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)