Card 04/ 09
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.
| Template | Editor example |
|---|---|
| Receiver | Document, which holds the text |
| Command | EditCommand, a Protocol with execute and undo |
| ConcreteCommand | Insert, which stores its position and text, and Delete, which stores the text it removed |
| Invoker | Editor, with a history list and do and undo |
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()'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')]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.
← → move through sections and cards