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