ExampleDifficulty: Intermediate2 min

Command Example: Undo in a Text Editor

Insert and delete are commands, and undo pops the last one off a stack.

Undo in a text editor is a natural Command, and this example maps the template onto it. Every edit is a command object pushed onto a history stack, and undo pops the last one and asks it to reverse itself. The example also shows what each command must remember: an insert can undo itself from its own arguments, but a delete must keep the text it removed.

The template's participants in the editor example
TemplateEditor example
ReceiverDocument, which holds the text
CommandEditCommand, a Protocol with execute and undo
ConcreteCommandInsert, which stores its position and text, and Delete, which stores the text it removed
InvokerEditor, with a history list and do and undo
py
from typing import Protocol


class Document:
    def __init__(self, text: str = "") -> None:
        self.text = text


class EditCommand(Protocol):
    def execute(self) -> None: ...
    def undo(self) -> None: ...


class Insert:
    def __init__(self, doc: Document, position: int, text: str) -> None:
        self.doc, self.position, self.inserted = doc, position, text

    def execute(self) -> None:
        t = self.doc.text
        self.doc.text = t[: self.position] + self.inserted + t[self.position :]

    def undo(self) -> None:
        t = self.doc.text
        self.doc.text = t[: self.position] + t[self.position + len(self.inserted) :]

    def __repr__(self) -> str:
        return f"Insert({self.position}, {self.inserted!r})"


class Delete:
    def __init__(self, doc: Document, start: int, end: int) -> None:
        self.doc, self.start, self.end = doc, start, end
        self.removed = ""

    def execute(self) -> None:
        t = self.doc.text
        self.removed = t[self.start : self.end]
        self.doc.text = t[: self.start] + t[self.end :]

    def undo(self) -> None:
        t = self.doc.text
        self.doc.text = t[: self.start] + self.removed + t[self.start :]

    def __repr__(self) -> str:
        return f"Delete({self.start}, {self.end}, removed={self.removed!r})"


class Editor:
    def __init__(self, doc: Document) -> None:
        self.doc = doc
        self.history: list[EditCommand] = []

    def do(self, command: EditCommand) -> None:
        command.execute()
        self.history.append(command)
        print(f"{self.doc.text!r:24} history={self.history}")

    def undo(self) -> None:
        self.history.pop().undo()
        print(f"{self.doc.text!r:24} history={self.history}")


doc = Document()
editor = Editor(doc)
editor.do(Insert(doc, 0, "Hello world"))
editor.do(Insert(doc, 5, ","))
editor.do(Delete(doc, 6, 12))
editor.undo()
editor.undo()
OutputRan with python:3.12-slim (Python 3.12.13)
'Hello world'            history=[Insert(0, 'Hello world')]
'Hello, world'           history=[Insert(0, 'Hello world'), Insert(5, ',')]
'Hello,'                 history=[Insert(0, 'Hello world'), Insert(5, ','), Delete(6, 12, removed=' world')]
'Hello, world'           history=[Insert(0, 'Hello world'), Insert(5, ',')]
'Hello world'            history=[Insert(0, 'Hello world')]
Each line is the text and the history after one edit or undo.

The trace reads top to bottom. Two inserts build 'Hello, world', and the delete of positions 6 to 12 leaves 'Hello,'. Its entry in the history shows removed=' world', captured in execute at the moment of deleting. The first undo pops that delete and puts ' world' back; the second pops the comma's insert and removes it.

The two commands differ in what they know when they are created. Insert knows everything its undo needs when it is created: the position and the text. Delete is created knowing only positions, and learns what it removed only when it runs, so it records it then. A redo button would add a second stack: undo pushes the command it reversed onto it, and redo pops it and calls execute again.