Courses › Low-Level Design › Problem Walkthroughs
Design a Text Editor (Undo/Redo)
The Command pattern, undo/redo stacks, grouping edits and gap buffers vs ropes.
"Design a text editor with undo and redo" is the canonical Command pattern question. Each edit becomes an object that knows how to apply itself and how to reverse itself. Two stacks of those objects give you unlimited undo and redo. Follow-ups test your choice of text data structure and how you group edits together.
Requirements
- Insert text at the cursor, delete characters, and move the cursor.
undo()reverses the most recent edit.redo()re-applies the most recently undone edit.- A new edit after an undo clears the redo history.
- Possible extras: select and replace, grouping typed characters into one undo step, a history limit.
Command pattern in one picture
[execute(cmd)] -> (undo stack) -> [undo] -> (redo stack) -> [redo] -> (undo stack)
[execute(cmd)] -> [clear redo stack]Each command implements execute() and undo(). Crucially, a command records whatever it needs to reverse itself. For a delete, that's the deleted text, which you only know at execution time.
Implementation
from abc import ABC, abstractmethod
class Document:
"""The receiver: plain text plus a cursor, with no history logic."""
def __init__(self):
self.text = ""
self.cursor = 0
def insert(self, pos, s):
self.text = self.text[:pos] + s + self.text[pos:]
def delete(self, pos, length):
removed = self.text[pos:pos + length]
self.text = self.text[:pos] + self.text[pos + length:]
return removed
class Command(ABC):
@abstractmethod
def execute(self, doc): ...
@abstractmethod
def undo(self, doc): ...
class InsertCommand(Command):
def __init__(self, s):
self.s, self.pos = s, None
def execute(self, doc):
if self.pos is None: # first run: capture the position once
self.pos = doc.cursor
doc.insert(self.pos, self.s)
doc.cursor = self.pos + len(self.s)
def undo(self, doc):
doc.delete(self.pos, len(self.s))
doc.cursor = self.pos
class BackspaceCommand(Command):
def __init__(self, count):
self.count, self.pos, self.removed = count, None, ""
def execute(self, doc):
if self.pos is None: # first run: work out the range and remember it
self.pos = max(0, doc.cursor - self.count)
self.removed = doc.delete(self.pos, doc.cursor - self.pos)
else: # redo: replay exactly the same range
doc.delete(self.pos, len(self.removed))
doc.cursor = self.pos
def undo(self, doc):
doc.insert(self.pos, self.removed)
doc.cursor = self.pos + len(self.removed)
class Editor:
def __init__(self, history_limit=1000):
self.doc = Document()
self.undo_stack, self.redo_stack = [], []
self.history_limit = history_limit
def _run(self, cmd):
cmd.execute(self.doc)
self.undo_stack.append(cmd)
if len(self.undo_stack) > self.history_limit:
self.undo_stack.pop(0)
self.redo_stack.clear() # a new edit invalidates the redo history
def type(self, s): self._run(InsertCommand(s))
def backspace(self, n=1): self._run(BackspaceCommand(n))
def move_cursor(self, pos): # navigation is not an undoable edit
self.doc.cursor = max(0, min(pos, len(self.doc.text)))
def undo(self):
if self.undo_stack:
cmd = self.undo_stack.pop()
cmd.undo(self.doc)
self.redo_stack.append(cmd)
def redo(self):
if self.redo_stack:
cmd = self.redo_stack.pop()
cmd.execute(self.doc)
self.undo_stack.append(cmd)e = Editor()
e.type("hello")
e.type(" world")
e.backspace(6)
assert e.doc.text == "hello"
e.undo()
assert e.doc.text == "hello world"
e.undo(); e.redo()
assert e.doc.text == "hello world"[!WARNING] Redo must replay the same edit, not a new one The user can move the cursor between an undo and a redo. If execute read doc.cursor every time, redo would insert the text somewhere else. So each command captures its position (and, for deletes, the removed text) on its first run and replays exactly that on redo. Point this out. It shows you've thought about redo, not just undo.
Grouping keystrokes
Users expect one undo to remove a whole word, not a single letter. Two common approaches:
- Merge adjacent commands: if a new
InsertCommandstarts where the previous one ended, and it arrives within a short time window, append its text to the previous command instead of pushing a new one. - Composite command / transactions:
begin_group()…end_group()collects commands into aMacroCommandthat executes them in order and undoes them in reverse order. "Replace all" uses this.
Choosing the text structure
Rebuilding a Python string costs O(n) per edit, which is fine for an interview. For large documents, the interviewer may ask about alternatives:
Alternative: snapshots (Memento)
Instead of reversible commands, store a snapshot of the document before each edit. That's simpler, and it works for edits that are hard to reverse. The cost is memory: O(document size) per step. In practice you store the text structure immutably and share unchanged parts, or snapshot only every N commands.
Follow-ups
- Collaborative editing: local undo has to undo *your* edit, not the latest edit from anyone. That leads to operational transformation or CRDTs. See Merging Concurrent Edits.
- Persisting history: serialize commands into an append-only log. Replaying the log rebuilds the document.
- Selection and formatting: formatting becomes another command type (
ApplyStyle(range, style)) that stores the previous style so it can be undone.
[!TIP] Practice Text Editor with Undo and Redo · Spreadsheet with Formulas · Design Patterns