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


from __future__ import annotations

import logging
from collections.abc import Callable, Sequence
from typing import Any, Protocol, runtime_checkable

log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Lightweight signal (avoids circular import from engine.Signal)
# ---------------------------------------------------------------------------


class _Signal:
    """Minimal signal for notifying listeners of undo/redo state changes."""

    def __init__(self) -> None:
        self._callbacks: list[Callable[[], None]] = []

    def connect(self, fn: Callable[[], None]) -> None:
        self._callbacks.append(fn)

    def disconnect(self, fn: Callable[[], None]) -> None:
        self._callbacks.remove(fn)

    def emit(self) -> None:
        for cb in self._callbacks:
            cb()


# ---------------------------------------------------------------------------
# Command protocol
# ---------------------------------------------------------------------------


[docs] @runtime_checkable class Command(Protocol): """Interface for undoable operations.""" @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}" @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" @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" @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()
# --------------------------------------------------------------------------- # 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._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 --------------------------------------------------------- @property def can_undo(self) -> bool: return bool(self._undo) @property def can_redo(self) -> bool: return bool(self._redo) @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 @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 @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)