Low Level DesignWorked problems
Design a text editor with undo and redo
Design a text editor with undo and redo step by step for a low level design interview. Grow from snapshots of the whole text to edits as commands with two history stacks, typing merged into word-sized undo steps under a memory limit, a gap buffer that doesn't copy on every keystroke, and autosave from a background thread that never marks unsaved text as saved, with code in Java, Python, C#, Go and TypeScript.
- Read first
- CommandThread safety basics
- Related
- File system
“Design a text editor that supports typing, deleting, undo and redo.” Then: “what happens to memory when the file is large?”
Every editor has undo: press it, and your last change disappears. Redo brings it back. It feels simple, but a good undo needs answers to several questions. What does a delete have to remember, so it can be undone? Why should one undo remove a word, not a letter? How much history can we keep, when one paste can be a megabyte? And how do we keep typing fast in a large file?
The core idea is the Command pattern, where every action is an object that can do and undo itself (the Command page shows the basic shape). This problem is about everything around that idea that makes an editor feel right and stay fast.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- Each edit is a command that holds only what changed. An insert holds its text and position. A delete holds the text it removed. Undo does the opposite of the edit, and nothing copies the whole document.
- History is two stacks: undo and redo. A new edit clears redo, because those edits were made to a text that no longer exists.
- Typing joins into one undo step, until a space, a pause, or a cursor move ends it. A paste is always its own step. History is limited by characters held, not by number of steps.
- The text lives behind a
TextBufferinterface. A gap buffer keeps an empty space at the cursor, so typing and backspace don’t move any other characters. - Autosave copies the text under a lock, writes it outside the lock, and saves by version. So text typed during a save is never marked as saved.
We’ll build this in four stages, then add autosave on another thread.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a text editor with undo and redo.
Whether you pin down which operations, and how big the documents are. “Plain text, one cursor, files up to a few megabytes” points the interview at the history and the buffer. Without it, candidates drift into fonts, drawing the screen, and plugins.
Here are the questions that matter:
- Rich text or plain text? Plain text. Rich text adds formatting, and edits that change style.
- One cursor, or selections too? One cursor. Selections come up at the end.
- How big are documents? Up to a few megabytes. Gigabyte logs need a different structure.
- How much undo history? As much as fits in a memory limit, keeping the newest.
- Does it save in the background? Yes, autosave runs on another thread.
These answers give us one promise that matters more than the others: an edit costs the size of the change, not the size of the document, both to make and to remember. Two smaller promises come with it. Undo and redo are exact: undoing everything gives back the original text. And the editor never says text is saved unless that text is on disk.
We leave out the screen, fonts, key bindings, and several people editing at once.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Several people editing at once? | No | Collaborative editing needs OT or CRDTs: a different design |
| Undo across a save and reopen? | No, history is in memory | Save the history with the file, as some editors do |
| Find and replace? | Out of scope | “Replace all” is one compound command holding many edits |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram). Time comes from a clock the editor is given, so the tests can control it.
Stage 1: A snapshot before every edit
Section titled “Stage 1: A snapshot before every edit”Start simple: typing, backspace and undo. What’s the quickest version that works?
Whether you can build the obvious version fast, and then put numbers on what’s wrong with it. “A copy per keystroke” is easy to say. “A megabyte file and ten thousand keystrokes is ten gigabytes” shows you’ve thought about it.
Start with the smallest thing that works. The text is a string, and the cursor is a position in it. Before every edit, we push a copy of the whole text onto a stack. Undo takes the last copy off the stack and puts it back. Saving a whole copy of the state like this is the Memento pattern.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// The text is a String, and undo keeps a copy of the whole text from before every edit.class Editor { private String text = ""; private int cursor; private final Deque<String> snapshots = new ArrayDeque<>();
// flaw 1: every keystroke copies the whole text twice, once to build the new String and once // onto the undo stack. A 1 MB file and 10,000 keystrokes hold 10 GB of copies. // flaw 2: each keystroke is its own undo step, so undoing a sentence takes a press per letter. void type(String s) { snapshots.push(text); text = text.substring(0, cursor) + s + text.substring(cursor); cursor += s.length(); }
void backspace() { if (cursor == 0) return; snapshots.push(text); text = text.substring(0, cursor - 1) + text.substring(cursor); cursor--; }
void moveCursor(int to) { cursor = Math.max(0, Math.min(to, text.length())); }
// flaw 3: the cursor isn't put back where the edit was, and there is no redo: the text that // was undone is gone. boolean undo() { if (snapshots.isEmpty()) return false; text = snapshots.pop(); cursor = Math.min(cursor, text.length()); return true; }
String text() { return text; }
int cursor() { return cursor; }}# The text is a str, and undo keeps a copy of the whole text from before every edit.class Editor: def __init__(self): self._text = "" self._cursor = 0 self._snapshots: list[str] = []
# flaw 1: every keystroke copies the whole text twice, once to build the new str and once # onto the undo stack. A 1 MB file and 10,000 keystrokes hold 10 GB of copies. # flaw 2: each keystroke is its own undo step, so undoing a sentence takes a press per # letter. def type(self, s: str) -> None: self._snapshots.append(self._text) self._text = self._text[: self._cursor] + s + self._text[self._cursor :] self._cursor += len(s)
def backspace(self) -> None: if self._cursor == 0: return self._snapshots.append(self._text) self._text = self._text[: self._cursor - 1] + self._text[self._cursor :] self._cursor -= 1
def move_cursor(self, to: int) -> None: self._cursor = max(0, min(to, len(self._text)))
# flaw 3: the cursor isn't put back where the edit was, and there is no redo: the text # that was undone is gone. def undo(self) -> bool: if not self._snapshots: return False self._text = self._snapshots.pop() self._cursor = min(self._cursor, len(self._text)) return True
@property def text(self) -> str: return self._text
@property def cursor(self) -> int: return self._cursor// The text is a string, and undo keeps a copy of the whole text from before every edit.public sealed class Editor{ private readonly Stack<string> _snapshots = new();
public string Text { get; private set; } = ""; public int Cursor { get; private set; }
// flaw 1: every keystroke copies the whole text twice, once to build the new string and once // onto the undo stack. A 1 MB file and 10,000 keystrokes hold 10 GB of copies. // flaw 2: each keystroke is its own undo step, so undoing a sentence takes a press per letter. public void Type(string s) { _snapshots.Push(Text); Text = Text[..Cursor] + s + Text[Cursor..]; Cursor += s.Length; }
public void Backspace() { if (Cursor == 0) return; _snapshots.Push(Text); Text = Text[..(Cursor - 1)] + Text[Cursor..]; Cursor--; }
public void MoveCursor(int to) => Cursor = Math.Clamp(to, 0, Text.Length);
// flaw 3: the cursor isn't put back where the edit was, and there is no redo: the text that // was undone is gone. public bool Undo() { if (!_snapshots.TryPop(out var previous)) return false; Text = previous; Cursor = Math.Min(Cursor, Text.Length); return true; }}// Editor keeps the text as a string, and undo keeps a copy of the whole text from before every// edit.type Editor struct { text string cursor int snapshots []string}
// Type has two flaws.// flaw 1: every keystroke copies the whole text twice, once to build the new string and once// onto the undo stack. A 1 MB file and 10,000 keystrokes hold 10 GB of copies.// flaw 2: each keystroke is its own undo step, so undoing a sentence takes a press per letter.func (e *Editor) Type(s string) { e.snapshots = append(e.snapshots, e.text) e.text = e.text[:e.cursor] + s + e.text[e.cursor:] e.cursor += len(s)}
func (e *Editor) Backspace() { if e.cursor == 0 { return } e.snapshots = append(e.snapshots, e.text) e.text = e.text[:e.cursor-1] + e.text[e.cursor:] e.cursor--}
func (e *Editor) MoveCursor(to int) { e.cursor = max(0, min(to, len(e.text))) }
// Undo has a third flaw: the cursor isn't put back where the edit was, and there is no redo,// so the text that was undone is gone.func (e *Editor) Undo() bool { if len(e.snapshots) == 0 { return false } e.text = e.snapshots[len(e.snapshots)-1] e.snapshots = e.snapshots[:len(e.snapshots)-1] e.cursor = min(e.cursor, len(e.text)) return true}
func (e *Editor) Text() string { return e.text }
func (e *Editor) Cursor() int { return e.cursor }// The text is a string, and undo keeps a copy of the whole text from before every edit.class Editor { private text_ = ''; private cursor_ = 0; private readonly snapshots: string[] = [];
// flaw 1: every keystroke copies the whole text twice, once to build the new string and once // onto the undo stack. A 1 MB file and 10,000 keystrokes hold 10 GB of copies. // flaw 2: each keystroke is its own undo step, so undoing a sentence takes a press per letter. type(s: string): void { this.snapshots.push(this.text_); this.text_ = this.text_.slice(0, this.cursor_) + s + this.text_.slice(this.cursor_); this.cursor_ += s.length; }
backspace(): void { if (this.cursor_ === 0) return; this.snapshots.push(this.text_); this.text_ = this.text_.slice(0, this.cursor_ - 1) + this.text_.slice(this.cursor_); this.cursor_--; }
moveCursor(to: number): void { this.cursor_ = Math.max(0, Math.min(to, this.text_.length)); }
// flaw 3: the cursor isn't put back where the edit was, and there is no redo: the text that // was undone is gone. undo(): boolean { const previous = this.snapshots.pop(); if (previous === undefined) return false; this.text_ = previous; this.cursor_ = Math.min(this.cursor_, previous.length); return true; }
get text(): string { return this.text_; }
get cursor(): number { return this.cursor_; }}It works, but it has three problems, and we leave them in on purpose. Every keystroke copies the whole text twice: once to build the new string, and once onto the stack. A 1 MB file and 10,000 keystrokes hold 10 GB. Every keystroke is its own undo step, so undoing a sentence takes one press per letter. And undo doesn’t put the cursor back where the edit was, and there’s no redo, because the undone text is thrown away.
Stage 2: Edits as commands, on two stacks
Section titled “Stage 2: Edits as commands, on two stacks”Make undo cheap, and add redo. What does each undo step store?
Whether you store edits instead of whole states, and know what a delete must keep. The red flag is a delete command that stores only a position and a length: it can’t put back what it removed.
We store what changed, instead of what the text was. An Edit is a command that can
apply itself to the text, and revert itself (undo what it did). It also says where the cursor
goes after each. There are two kinds:
Insert(at, text)applies by inserting the text, and reverts by deleting the same number of characters at the same place.Delete(at, removed)applies by deleting, and reverts by insertingremovedback. It keeps the removed characters, read from the text before the delete, because nothing else will have them afterwards.
An edit holds only its own characters, so typing one letter into a megabyte file costs one letter of history.
History keeps two stacks. Recording an edit pushes it on the undo stack. Undo takes it off,
reverts it, and pushes it on the redo stack. Redo takes it off the redo stack, applies it again,
and pushes it back on undo.
A new edit clears redo. Why? The edits on the redo stack were made to a version of the text that the new edit has just replaced. Applying them now would put characters in the wrong places.
- Color key
- Entity
- Facade
The text now sits behind a TextBuffer interface, with insert, delete, slice and length. For
now, a simple buffer wraps a string builder. Stage 4 replaces it without touching anything else.
Go deeperFollowing a short session through both stacks2 min
Let’s follow a few keystrokes, with the text, the undo stack and the redo stack after each. Assume each edit is its own step for now; stage 3 joins them.
- Type “cat”. One
Insert(0, "cat"). Text:cat. Undo:[Insert "cat"]. Redo: empty. - Backspace twice. The first backspace removes “t”, so the edit is
Delete(2, "t"). The second removes “a”:Delete(1, "a"). Text:c. Undo has three edits. Notice that each delete reads the character before removing it. That’s what lets it be undone. - Undo. The top edit,
Delete(1, "a"), reverts by inserting “a” at position 1. Text:ca. That edit moves to redo. - Undo again.
Delete(2, "t")puts “t” back. Text:cat. Redo now holds both deletes. - Redo.
Delete(2, "t")applies again. Text:ca. It moves back to undo. - Type “r”. A new edit,
Insert(2, "r"). Text:car. Redo is cleared. Its last edit,Delete(1, "a"), was planned for the textca, and the text is nowcar. Applying it would remove the “a” from “car”, which the user never asked for.
At no point did anything copy the whole text. Each step touched one or two characters.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What the editor needs from the text, whatever holds it. A faster one can be swapped in// without touching the editor.interface TextBuffer { void insert(int at, String text); void delete(int at, int length); String slice(int from, int to); int length(); String text();}
class SimpleBuffer implements TextBuffer { private final StringBuilder chars = new StringBuilder();
public void insert(int at, String text) { chars.insert(at, text); } public void delete(int at, int length) { chars.delete(at, at + length); } public String slice(int from, int to) { return chars.substring(from, to); } public int length() { return chars.length(); } public String text() { return chars.toString(); }}
// An edit is a command: it can apply itself and undo itself, and it says where the cursor goes// either way. It holds only what changed, not a copy of the text.interface Edit { void apply(TextBuffer buffer); void revert(TextBuffer buffer); int cursorAfterApply(); int cursorAfterRevert();}
record Insert(int at, String text) implements Edit { public void apply(TextBuffer b) { b.insert(at, text); } public void revert(TextBuffer b) { b.delete(at, text.length()); } public int cursorAfterApply() { return at + text.length(); } public int cursorAfterRevert() { return at; }}
// A delete must remember what it removed, or it couldn't put it back.record Delete(int at, String removed) implements Edit { public void apply(TextBuffer b) { b.delete(at, removed.length()); } public void revert(TextBuffer b) { b.insert(at, removed); } public int cursorAfterApply() { return at; } public int cursorAfterRevert() { return at + removed.length(); }}
// Two stacks. Undo moves the newest edit to the redo stack; redo moves it back. A new edit// clears redo, because the edits on it were made to a text that no longer exists.class History { private final Deque<Edit> undo = new ArrayDeque<>(); private final Deque<Edit> redo = new ArrayDeque<>();
void record(Edit e) { redo.clear(); undo.push(e); }
Edit undo() { Edit e = undo.poll(); if (e != null) redo.push(e); return e; }
Edit redo() { Edit e = redo.poll(); if (e != null) undo.push(e); return e; }}
class Editor { private final TextBuffer buffer; private final History history; private int cursor;
Editor(TextBuffer buffer, History history) { this.buffer = buffer; this.history = history; }
void type(String text) { run(new Insert(cursor, text)); }
void backspace() { if (cursor > 0) run(new Delete(cursor - 1, buffer.slice(cursor - 1, cursor))); }
void moveCursor(int to) { cursor = Math.max(0, Math.min(to, buffer.length())); }
boolean undo() { Edit e = history.undo(); if (e == null) return false; e.revert(buffer); cursor = e.cursorAfterRevert(); return true; }
boolean redo() { Edit e = history.redo(); if (e == null) return false; e.apply(buffer); cursor = e.cursorAfterApply(); return true; }
String text() { return buffer.text(); }
int cursor() { return cursor; }
private void run(Edit e) { e.apply(buffer); cursor = e.cursorAfterApply(); history.record(e); }}# What the editor needs from the text, whatever holds it. A faster one can be swapped in# without touching the editor.class TextBuffer(Protocol): def insert(self, at: int, text: str) -> None: ... def delete(self, at: int, length: int) -> None: ... def slice(self, start: int, end: int) -> str: ... def __len__(self) -> int: ... def text(self) -> str: ...
# A list of characters. Python strings are immutable, so a list is the simplest thing# that can change in place.class SimpleBuffer: def __init__(self): self._chars: list[str] = []
def insert(self, at: int, text: str) -> None: self._chars[at:at] = text
def delete(self, at: int, length: int) -> None: del self._chars[at : at + length]
def slice(self, start: int, end: int) -> str: return "".join(self._chars[start:end])
def __len__(self) -> int: return len(self._chars)
def text(self) -> str: return "".join(self._chars)
# A command: it can apply itself and undo itself, and it says where the cursor goes either# way. It holds only what changed, not a copy of the text.class Edit(Protocol): def apply(self, buffer: TextBuffer) -> None: ... def revert(self, buffer: TextBuffer) -> None: ... def cursor_after_apply(self) -> int: ... def cursor_after_revert(self) -> int: ...
@dataclass(frozen=True)class Insert: at: int text: str
def apply(self, b: TextBuffer) -> None: b.insert(self.at, self.text)
def revert(self, b: TextBuffer) -> None: b.delete(self.at, len(self.text))
def cursor_after_apply(self) -> int: return self.at + len(self.text)
def cursor_after_revert(self) -> int: return self.at
# A delete must remember what it removed, or it couldn't put it back.@dataclass(frozen=True)class Delete: at: int removed: str
def apply(self, b: TextBuffer) -> None: b.delete(self.at, len(self.removed))
def revert(self, b: TextBuffer) -> None: b.insert(self.at, self.removed)
def cursor_after_apply(self) -> int: return self.at
def cursor_after_revert(self) -> int: return self.at + len(self.removed)
# Two stacks. Undo moves the newest edit to the redo stack; redo moves it back. A new edit# clears redo, because the edits on it were made to a text that no longer exists.class History: def __init__(self): self._undo: list[Edit] = [] self._redo: list[Edit] = []
def record(self, e: Edit) -> None: self._redo.clear() self._undo.append(e)
def undo(self) -> Edit | None: if not self._undo: return None e = self._undo.pop() self._redo.append(e) return e
def redo(self) -> Edit | None: if not self._redo: return None e = self._redo.pop() self._undo.append(e) return e
class Editor: def __init__(self, buffer: TextBuffer, history: History): self._buffer = buffer self._history = history self._cursor = 0
def type(self, text: str) -> None: self._run(Insert(self._cursor, text))
def backspace(self) -> None: if self._cursor > 0: self._run(Delete(self._cursor - 1, self._buffer.slice(self._cursor - 1, self._cursor)))
def move_cursor(self, to: int) -> None: self._cursor = max(0, min(to, len(self._buffer)))
def undo(self) -> bool: e = self._history.undo() if e is None: return False e.revert(self._buffer) self._cursor = e.cursor_after_revert() return True
def redo(self) -> bool: e = self._history.redo() if e is None: return False e.apply(self._buffer) self._cursor = e.cursor_after_apply() return True
@property def text(self) -> str: return self._buffer.text()
@property def cursor(self) -> int: return self._cursor
def _run(self, e: Edit) -> None: e.apply(self._buffer) self._cursor = e.cursor_after_apply() self._history.record(e)// What the editor needs from the text, whatever holds it. A faster one can be swapped in// without touching the editor.public interface ITextBuffer{ void Insert(int at, string text); void Delete(int at, int length); string Slice(int from, int to); int Length { get; } string Text();}
public sealed class SimpleBuffer : ITextBuffer{ private readonly StringBuilder _chars = new();
public void Insert(int at, string text) => _chars.Insert(at, text); public void Delete(int at, int length) => _chars.Remove(at, length); public string Slice(int from, int to) => _chars.ToString(from, to - from); public int Length => _chars.Length; public string Text() => _chars.ToString();}
// An edit is a command: it can apply itself and undo itself, and it says where the cursor goes// either way. It holds only what changed, not a copy of the text.public interface IEdit{ void Apply(ITextBuffer buffer); void Revert(ITextBuffer buffer); int CursorAfterApply { get; } int CursorAfterRevert { get; }}
public sealed record Insert(int At, string Text) : IEdit{ public void Apply(ITextBuffer b) => b.Insert(At, Text); public void Revert(ITextBuffer b) => b.Delete(At, Text.Length); public int CursorAfterApply => At + Text.Length; public int CursorAfterRevert => At;}
// A delete must remember what it removed, or it couldn't put it back.public sealed record Delete(int At, string Removed) : IEdit{ public void Apply(ITextBuffer b) => b.Delete(At, Removed.Length); public void Revert(ITextBuffer b) => b.Insert(At, Removed); public int CursorAfterApply => At; public int CursorAfterRevert => At + Removed.Length;}
// Two stacks. Undo moves the newest edit to the redo stack; redo moves it back. A new edit// clears redo, because the edits on it were made to a text that no longer exists.public sealed class History{ private readonly Stack<IEdit> _undo = new(); private readonly Stack<IEdit> _redo = new();
public void Record(IEdit e) { _redo.Clear(); _undo.Push(e); }
public IEdit? Undo() { if (!_undo.TryPop(out var e)) return null; _redo.Push(e); return e; }
public IEdit? Redo() { if (!_redo.TryPop(out var e)) return null; _undo.Push(e); return e; }}
public sealed class Editor(ITextBuffer buffer, History history){ public int Cursor { get; private set; } public string Text => buffer.Text();
public void Type(string text) => Run(new Insert(Cursor, text));
public void Backspace() { if (Cursor > 0) Run(new Delete(Cursor - 1, buffer.Slice(Cursor - 1, Cursor))); }
public void MoveCursor(int to) => Cursor = Math.Clamp(to, 0, buffer.Length);
public bool Undo() { if (history.Undo() is not { } e) return false; e.Revert(buffer); Cursor = e.CursorAfterRevert; return true; }
public bool Redo() { if (history.Redo() is not { } e) return false; e.Apply(buffer); Cursor = e.CursorAfterApply; return true; }
private void Run(IEdit e) { e.Apply(buffer); Cursor = e.CursorAfterApply; history.Record(e); }}// TextBuffer is what the editor needs from the text, whatever holds it. Positions count runes,// so a multi-byte character is one position. A faster buffer can be swapped in without touching// the editor.type TextBuffer interface { Insert(at int, text string) Delete(at, length int) Slice(from, to int) string Len() int Text() string}
// SimpleBuffer is a slice of runes.type SimpleBuffer struct{ runes []rune }
func (b *SimpleBuffer) Insert(at int, text string) { b.runes = slices.Insert(b.runes, at, []rune(text)...)}func (b *SimpleBuffer) Delete(at, length int) { b.runes = slices.Delete(b.runes, at, at+length) }func (b *SimpleBuffer) Slice(from, to int) string { return string(b.runes[from:to]) }func (b *SimpleBuffer) Len() int { return len(b.runes) }func (b *SimpleBuffer) Text() string { return string(b.runes) }
// Edit is a command: it can apply itself and undo itself, and it says where the cursor goes// either way. It holds only what changed, not a copy of the text.type Edit interface { Apply(b TextBuffer) Revert(b TextBuffer) CursorAfterApply() int CursorAfterRevert() int}
type Insert struct { At int Text string}
func (i Insert) Apply(b TextBuffer) { b.Insert(i.At, i.Text) }func (i Insert) Revert(b TextBuffer) { b.Delete(i.At, utf8.RuneCountInString(i.Text)) }func (i Insert) CursorAfterApply() int { return i.At + utf8.RuneCountInString(i.Text) }func (i Insert) CursorAfterRevert() int { return i.At }
// Delete must remember what it removed, or it couldn't put it back.type Delete struct { At int Removed string}
func (d Delete) Apply(b TextBuffer) { b.Delete(d.At, utf8.RuneCountInString(d.Removed)) }func (d Delete) Revert(b TextBuffer) { b.Insert(d.At, d.Removed) }func (d Delete) CursorAfterApply() int { return d.At }func (d Delete) CursorAfterRevert() int { return d.At + utf8.RuneCountInString(d.Removed) }
// History is two stacks. Undo moves the newest edit to the redo stack; redo moves it back. A// new edit clears redo, because the edits on it were made to a text that no longer exists.type History struct { undo, redo []Edit}
func (h *History) Record(e Edit) { h.redo = nil h.undo = append(h.undo, e)}
func (h *History) Undo() Edit { if len(h.undo) == 0 { return nil } e := h.undo[len(h.undo)-1] h.undo = h.undo[:len(h.undo)-1] h.redo = append(h.redo, e) return e}
func (h *History) Redo() Edit { if len(h.redo) == 0 { return nil } e := h.redo[len(h.redo)-1] h.redo = h.redo[:len(h.redo)-1] h.undo = append(h.undo, e) return e}
type Editor struct { buffer TextBuffer history *History cursor int}
func NewEditor(buffer TextBuffer, history *History) *Editor { return &Editor{buffer: buffer, history: history}}
func (e *Editor) Type(text string) { e.run(Insert{e.cursor, text}) }
func (e *Editor) Backspace() { if e.cursor > 0 { e.run(Delete{e.cursor - 1, e.buffer.Slice(e.cursor-1, e.cursor)}) }}
func (e *Editor) MoveCursor(to int) { e.cursor = max(0, min(to, e.buffer.Len())) }
func (e *Editor) Undo() bool { ed := e.history.Undo() if ed == nil { return false } ed.Revert(e.buffer) e.cursor = ed.CursorAfterRevert() return true}
func (e *Editor) Redo() bool { ed := e.history.Redo() if ed == nil { return false } ed.Apply(e.buffer) e.cursor = ed.CursorAfterApply() return true}
func (e *Editor) Text() string { return e.buffer.Text() }
func (e *Editor) Cursor() int { return e.cursor }
func (e *Editor) run(ed Edit) { ed.Apply(e.buffer) e.cursor = ed.CursorAfterApply() e.history.Record(ed)}// What the editor needs from the text, whatever holds it. A faster one can be swapped in// without touching the editor.interface TextBuffer { insert(at: number, text: string): void; delete(at: number, length: number): void; slice(from: number, to: number): string; readonly length: number; text(): string;}
// An array of characters. JavaScript strings are immutable, so an array is the simplest thing// that can change in place.class SimpleBuffer implements TextBuffer { private readonly chars: string[] = [];
insert(at: number, text: string): void { this.chars.splice(at, 0, ...text); }
delete(at: number, length: number): void { this.chars.splice(at, length); }
slice(from: number, to: number): string { return this.chars.slice(from, to).join(''); }
get length(): number { return this.chars.length; }
text(): string { return this.chars.join(''); }}
// An edit is a command: it can apply itself and undo itself, and it says where the cursor goes// either way. It holds only what changed, not a copy of the text.interface Edit { apply(buffer: TextBuffer): void; revert(buffer: TextBuffer): void; cursorAfterApply(): number; cursorAfterRevert(): number;}
class Insert implements Edit { constructor( readonly at: number, readonly text: string, ) {}
apply(b: TextBuffer): void { b.insert(this.at, this.text); }
revert(b: TextBuffer): void { b.delete(this.at, this.text.length); }
cursorAfterApply(): number { return this.at + this.text.length; }
cursorAfterRevert(): number { return this.at; }}
// A delete must remember what it removed, or it couldn't put it back.class Delete implements Edit { constructor( readonly at: number, readonly removed: string, ) {}
apply(b: TextBuffer): void { b.delete(this.at, this.removed.length); }
revert(b: TextBuffer): void { b.insert(this.at, this.removed); }
cursorAfterApply(): number { return this.at; }
cursorAfterRevert(): number { return this.at + this.removed.length; }}
// Two stacks. Undo moves the newest edit to the redo stack; redo moves it back. A new edit// clears redo, because the edits on it were made to a text that no longer exists.class History { private readonly undoStack: Edit[] = []; private redoStack: Edit[] = [];
record(e: Edit): void { this.redoStack = []; this.undoStack.push(e); }
undo(): Edit | undefined { const e = this.undoStack.pop(); if (e) this.redoStack.push(e); return e; }
redo(): Edit | undefined { const e = this.redoStack.pop(); if (e) this.undoStack.push(e); return e; }}
class Editor { private cursor_ = 0;
constructor( private readonly buffer: TextBuffer, private readonly history: History, ) {}
type(text: string): void { this.run(new Insert(this.cursor_, text)); }
backspace(): void { if (this.cursor_ > 0) this.run(new Delete(this.cursor_ - 1, this.buffer.slice(this.cursor_ - 1, this.cursor_))); }
moveCursor(to: number): void { this.cursor_ = Math.max(0, Math.min(to, this.buffer.length)); }
undo(): boolean { const e = this.history.undo(); if (!e) return false; e.revert(this.buffer); this.cursor_ = e.cursorAfterRevert(); return true; }
redo(): boolean { const e = this.history.redo(); if (!e) return false; e.apply(this.buffer); this.cursor_ = e.cursorAfterApply(); return true; }
get text(): string { return this.buffer.text(); }
get cursor(): number { return this.cursor_; }
private run(e: Edit): void { e.apply(this.buffer); this.cursor_ = e.cursorAfterApply(); this.history.record(e); }}Stage 3: Undo steps a person expects
Section titled “Stage 3: Undo steps a person expects”A user types a sentence and presses undo. It removes one letter. Then they paste a 50 MB log file, and the editor’s memory doubles. Fix both.
Whether you join edits by a rule you can state, and limit the history by what it holds, not by how many steps. A candidate who caps history at “100 steps” has to explain why 100 pastes of 50 MB is fine.
Joining edits. When an edit is recorded, it may join the edit on top of the undo stack, instead of becoming a new step. This is called merging. Each edit decides whether it can absorb the next one:
- An insert absorbs a one-character insert right after it, unless the text so far ends in a space or a new line. So typing “hello world” gives two steps, “hello “ and “world”, which is how most editors behave.
- A delete absorbs a backspace right before it, so holding backspace down is one step.
The history adds two conditions of its own. The edit must come within a merge window (one second here) of the last one, so a pause ends the step. And the top step must not be sealed. Moving the cursor, pasting and undoing all seal it, because typing after any of them is a new thought. A paste seals before and after itself, so it’s always exactly one step.
The limit. Each edit has a weight: the number of characters it holds. The history keeps a running total. When a new edit pushes it over the limit, the history drops the oldest edits, from the bottom of the undo stack, until it fits. The newest edit always stays, even if it alone is over the limit, so the last thing the user did can always be undone.
- Color key
- Value object
The undo stack becomes a double-ended queue (a list you can add to or remove from at both ends), because the limit removes from the bottom while undo removes from the top.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// One change to the text, as an object that can be done and undone (the Command pattern). The// history stores edits, not copies of the text, so a long history stays small.interface Edit { void apply(TextBuffer buffer); void revert(TextBuffer buffer); // Where the cursor goes after the edit is done (on redo), and after it is undone. int cursorAfterApply(); int cursorAfterRevert();
// The edit that does this one and then next, if the two read as one step to a person, or // null if they don't. Edit mergedWith(Edit next);
// Characters held, which is what the history's limit counts. int weight();}
// Text typed or pasted at a position. Undoing it deletes the same characters again.record Insert(int at, String text) implements Edit { public void apply(TextBuffer b) { b.insert(at, text); } public void revert(TextBuffer b) { b.delete(at, text.length()); } public int cursorAfterApply() { return at + text.length(); } public int cursorAfterRevert() { return at; } public int weight() { return text.length(); }
// A keystroke right after the last one joins it, until a space or newline ends the word: // "hello world" undoes as "world", then "hello ". public Edit mergedWith(Edit next) { if (!(next instanceof Insert i) || i.at != at + text.length() || i.text.length() != 1) return null; if (Character.isWhitespace(text.charAt(text.length() - 1))) return null; return new Insert(at, text + i.text); }}
// Characters removed at a position. It keeps the removed text, so undo can put it back.record Delete(int at, String removed) implements Edit { public void apply(TextBuffer b) { b.delete(at, removed.length()); } public void revert(TextBuffer b) { b.insert(at, removed); } public int cursorAfterApply() { return at; } public int cursorAfterRevert() { return at + removed.length(); } public int weight() { return removed.length(); }
// A backspace right before the last one joins it: holding backspace is one step. public Edit mergedWith(Edit next) { if (!(next instanceof Delete d) || d.at + d.removed.length() != at) return null; return new Delete(d.at, d.removed + removed); }}
// Merges edits that a person thinks of as one, and keeps at most limit characters of history,// dropping the oldest edits first.class History { private final Deque<Edit> undo = new ArrayDeque<>(); private final Deque<Edit> redo = new ArrayDeque<>(); private final LongSupplier clock; private final long mergeWindowMillis; private final int limit; private int weight; // characters held on the undo stack private long lastEditAt; private boolean sealed = true; // the top edit is closed to merging
History(LongSupplier clock, long mergeWindowMillis, int limit) { this.clock = clock; this.mergeWindowMillis = mergeWindowMillis; this.limit = limit; }
// Typing after a pause starts a new step, and so does typing after anything that sealed the // last one. void record(Edit e) { redo.clear(); long now = clock.getAsLong(); Edit top = undo.peek(); Edit merged = !sealed && top != null && now - lastEditAt <= mergeWindowMillis ? top.mergedWith(e) : null; if (merged != null) { undo.pop(); weight -= top.weight(); e = merged; } push(e); lastEditAt = now; sealed = false; }
// Closes the newest step, so the next edit starts a new one. Moving the cursor, pasting and // undoing all seal: typing after them is a new thought. void seal() { sealed = true; }
// Moves the newest step to the redo stack. Any new edit clears redo, in record. Edit undo() { seal(); Edit e = undo.poll(); if (e == null) return null; weight -= e.weight(); redo.push(e); return e; }
Edit redo() { seal(); Edit e = redo.poll(); if (e != null) push(e); return e; }
// The newest edit always stays, even alone over the limit; older ones go until it fits. private void push(Edit e) { undo.push(e); weight += e.weight(); while (weight > limit && undo.size() > 1) weight -= undo.removeLast().weight(); }}
// What the keyboard calls. Every change goes through run, which applies the edit, moves the// cursor and records it in the history, all in one place.class Editor { private final TextBuffer buffer; private final History history; private int cursor;
Editor(TextBuffer buffer, History history) { this.buffer = buffer; this.history = history; }
void type(String text) { run(new Insert(cursor, text)); }
// A paste is one step of its own: it never joins the typing before or after it. void paste(String text) { history.seal(); run(new Insert(cursor, text)); history.seal(); }
// Removes the character before the cursor, and keeps it so undo can bring it back. void backspace() { if (cursor > 0) run(new Delete(cursor - 1, buffer.slice(cursor - 1, cursor))); }
// Kept inside the text. Moving the cursor ends the current undo step. void moveCursor(int to) { cursor = Math.max(0, Math.min(to, buffer.length())); history.seal(); }
// Reverts the newest step and puts the cursor back where that step began. boolean undo() { Edit e = history.undo(); if (e == null) return false; e.revert(buffer); cursor = e.cursorAfterRevert(); return true; }
// Does the step again and puts the cursor after it. boolean redo() { Edit e = history.redo(); if (e == null) return false; e.apply(buffer); cursor = e.cursorAfterApply(); return true; }
String text() { return buffer.text(); }
int cursor() { return cursor; }
private void run(Edit e) { e.apply(buffer); cursor = e.cursorAfterApply(); history.record(e); }}# One change to the text, as an object that can be done and undone (the Command pattern). The# history stores edits, not copies of the text, so a long history stays small.class Edit(Protocol): def apply(self, buffer: TextBuffer) -> None: ... def revert(self, buffer: TextBuffer) -> None: ... # Where the cursor goes after the edit is done (on redo), and after it is undone. def cursor_after_apply(self) -> int: ... def cursor_after_revert(self) -> int: ...
# The edit that does this one and then nxt, if the two read as one step to a person. def merged_with(self, nxt: "Edit") -> "Edit | None": ...
# Characters held, which is what the history's limit counts. def weight(self) -> int: ...
# Text typed or pasted at a position. Undoing it deletes the same characters again.@dataclass(frozen=True)class Insert: at: int text: str
def apply(self, b: TextBuffer) -> None: b.insert(self.at, self.text)
def revert(self, b: TextBuffer) -> None: b.delete(self.at, len(self.text))
def cursor_after_apply(self) -> int: return self.at + len(self.text)
def cursor_after_revert(self) -> int: return self.at
def weight(self) -> int: return len(self.text)
# A keystroke right after the last one joins it, until a space or newline ends the # word: "hello world" undoes as "world", then "hello ". def merged_with(self, nxt: Edit) -> Edit | None: if not isinstance(nxt, Insert) or nxt.at != self.at + len(self.text) or len(nxt.text) != 1: return None if self.text[-1].isspace(): return None return Insert(self.at, self.text + nxt.text)
# Characters removed at a position. It keeps the removed text, so undo can put it back.@dataclass(frozen=True)class Delete: at: int removed: str
def apply(self, b: TextBuffer) -> None: b.delete(self.at, len(self.removed))
def revert(self, b: TextBuffer) -> None: b.insert(self.at, self.removed)
def cursor_after_apply(self) -> int: return self.at
def cursor_after_revert(self) -> int: return self.at + len(self.removed)
def weight(self) -> int: return len(self.removed)
# A backspace right before the last one joins it: holding backspace is one step. def merged_with(self, nxt: Edit) -> Edit | None: if not isinstance(nxt, Delete) or nxt.at + len(nxt.removed) != self.at: return None return Delete(nxt.at, nxt.removed + self.removed)
# Merges edits that a person thinks of as one, and keeps at most limit characters of# history, dropping the oldest edits first.class History: def __init__(self, clock: Callable[[], int], merge_window_ms: int, limit: int): self._undo: deque[Edit] = deque() self._redo: list[Edit] = [] self._clock = clock self._merge_window_ms = merge_window_ms self._limit = limit self._weight = 0 # characters held on the undo stack self._last_edit_at = 0 self._sealed = True # the top edit is closed to merging
# Typing after a pause starts a new step, and so does typing after anything that # sealed the last one. def record(self, e: Edit) -> None: self._redo.clear() now = self._clock() top = self._undo[-1] if self._undo else None merged = None if not self._sealed and top is not None and now - self._last_edit_at <= self._merge_window_ms: merged = top.merged_with(e) if merged is not None: self._undo.pop() self._weight -= top.weight() e = merged self._push(e) self._last_edit_at = now self._sealed = False
# Closes the newest step, so the next edit starts a new one. Moving the cursor, # pasting and undoing all seal: typing after them is a new thought. def seal(self) -> None: self._sealed = True
# Moves the newest step to the redo stack. Any new edit clears redo, in record. def undo(self) -> Edit | None: self.seal() if not self._undo: return None e = self._undo.pop() self._weight -= e.weight() self._redo.append(e) return e
def redo(self) -> Edit | None: self.seal() if not self._redo: return None e = self._redo.pop() self._push(e) return e
# The newest edit always stays, even alone over the limit; older ones go until it fits. def _push(self, e: Edit) -> None: self._undo.append(e) self._weight += e.weight() while self._weight > self._limit and len(self._undo) > 1: self._weight -= self._undo.popleft().weight()
# What the keyboard calls. Every change goes through _run, which applies the edit, moves the# cursor and records it in the history, all in one place.class Editor: def __init__(self, buffer: TextBuffer, history: History): self._buffer = buffer self._history = history self._cursor = 0
def type(self, text: str) -> None: self._run(Insert(self._cursor, text))
# A paste is one step of its own: it never joins the typing before or after it. def paste(self, text: str) -> None: self._history.seal() self._run(Insert(self._cursor, text)) self._history.seal()
# Removes the character before the cursor, and keeps it so undo can bring it back. def backspace(self) -> None: if self._cursor > 0: self._run(Delete(self._cursor - 1, self._buffer.slice(self._cursor - 1, self._cursor)))
# Kept inside the text. Moving the cursor ends the current undo step. def move_cursor(self, to: int) -> None: self._cursor = max(0, min(to, len(self._buffer))) self._history.seal()
# Reverts the newest step and puts the cursor back where that step began. def undo(self) -> bool: e = self._history.undo() if e is None: return False e.revert(self._buffer) self._cursor = e.cursor_after_revert() return True
# Does the step again and puts the cursor after it. def redo(self) -> bool: e = self._history.redo() if e is None: return False e.apply(self._buffer) self._cursor = e.cursor_after_apply() return True
@property def text(self) -> str: return self._buffer.text()
@property def cursor(self) -> int: return self._cursor
def _run(self, e: Edit) -> None: e.apply(self._buffer) self._cursor = e.cursor_after_apply() self._history.record(e)// One change to the text, as an object that can be done and undone (the Command pattern). The// history stores edits, not copies of the text, so a long history stays small.public interface IEdit{ void Apply(ITextBuffer buffer); void Revert(ITextBuffer buffer); // Where the cursor goes after the edit is done (on redo), and after it is undone. int CursorAfterApply { get; } int CursorAfterRevert { get; }
// The edit that does this one and then next, if the two read as one step to a person, or // null if they don't. IEdit? MergedWith(IEdit next);
// Characters held, which is what the history's limit counts. int Weight { get; }}
// Text typed or pasted at a position. Undoing it deletes the same characters again.public sealed record Insert(int At, string Text) : IEdit{ public void Apply(ITextBuffer b) => b.Insert(At, Text); public void Revert(ITextBuffer b) => b.Delete(At, Text.Length); public int CursorAfterApply => At + Text.Length; public int CursorAfterRevert => At; public int Weight => Text.Length;
// A keystroke right after the last one joins it, until a space or newline ends the word: // "hello world" undoes as "world", then "hello ". public IEdit? MergedWith(IEdit next) => next is Insert i && i.At == At + Text.Length && i.Text.Length == 1 && !char.IsWhiteSpace(Text[^1]) ? new Insert(At, Text + i.Text) : null;}
// Characters removed at a position. It keeps the removed text, so undo can put it back.public sealed record Delete(int At, string Removed) : IEdit{ public void Apply(ITextBuffer b) => b.Delete(At, Removed.Length); public void Revert(ITextBuffer b) => b.Insert(At, Removed); public int CursorAfterApply => At; public int CursorAfterRevert => At + Removed.Length; public int Weight => Removed.Length;
// A backspace right before the last one joins it: holding backspace is one step. public IEdit? MergedWith(IEdit next) => next is Delete d && d.At + d.Removed.Length == At ? new Delete(d.At, d.Removed + Removed) : null;}
// Merges edits that a person thinks of as one, and keeps at most limit characters of history,// dropping the oldest edits first. The undo stack is a linked list so the oldest edit can be// dropped from the bottom.public sealed class History(Func<long> clock, long mergeWindowMs, int limit){ private readonly LinkedList<IEdit> _undo = new(); // newest last private readonly Stack<IEdit> _redo = new(); private int _weight; // characters held on the undo stack private long _lastEditAt; private bool _sealed = true; // the top edit is closed to merging
// Typing after a pause starts a new step, and so does typing after anything that sealed the // last one. public void Record(IEdit e) { _redo.Clear(); var now = clock(); var top = _undo.Last?.Value; var merged = !_sealed && top is not null && now - _lastEditAt <= mergeWindowMs ? top.MergedWith(e) : null; if (merged is not null) { _undo.RemoveLast(); _weight -= top!.Weight; e = merged; } Push(e); _lastEditAt = now; _sealed = false; }
// Closes the newest step, so the next edit starts a new one. Moving the cursor, pasting and // undoing all seal: typing after them is a new thought. public void Seal() => _sealed = true;
// Moves the newest step to the redo stack. Any new edit clears redo, in Record. public IEdit? Undo() { Seal(); if (_undo.Last is not { } node) return null; _undo.RemoveLast(); _weight -= node.Value.Weight; _redo.Push(node.Value); return node.Value; }
public IEdit? Redo() { Seal(); if (!_redo.TryPop(out var e)) return null; Push(e); return e; }
// The newest edit always stays, even alone over the limit; older ones go until it fits. private void Push(IEdit e) { _undo.AddLast(e); _weight += e.Weight; while (_weight > limit && _undo.Count > 1) { _weight -= _undo.First!.Value.Weight; _undo.RemoveFirst(); } }}
// What the keyboard calls. Every change goes through Run, which applies the edit, moves the// cursor and records it in the history, all in one place.public sealed class Editor(ITextBuffer buffer, History history){ public int Cursor { get; private set; } public string Text => buffer.Text();
public void Type(string text) => Run(new Insert(Cursor, text));
// A paste is one step of its own: it never joins the typing before or after it. public void Paste(string text) { history.Seal(); Run(new Insert(Cursor, text)); history.Seal(); }
// Removes the character before the cursor, and keeps it so undo can bring it back. public void Backspace() { if (Cursor > 0) Run(new Delete(Cursor - 1, buffer.Slice(Cursor - 1, Cursor))); }
// Kept inside the text. Moving the cursor ends the current undo step. public void MoveCursor(int to) { Cursor = Math.Clamp(to, 0, buffer.Length); history.Seal(); }
// Reverts the newest step and puts the cursor back where that step began. public bool Undo() { if (history.Undo() is not { } e) return false; e.Revert(buffer); Cursor = e.CursorAfterRevert; return true; }
// Does the step again and puts the cursor after it. public bool Redo() { if (history.Redo() is not { } e) return false; e.Apply(buffer); Cursor = e.CursorAfterApply; return true; }
private void Run(IEdit e) { e.Apply(buffer); Cursor = e.CursorAfterApply; history.Record(e); }}// Edit is one change to the text, as an object that can be done and undone (the Command// pattern). The history stores edits, not copies of the text, so a long history stays small.type Edit interface { Apply(b TextBuffer) Revert(b TextBuffer) // Where the cursor goes after the edit is done (on redo), and after it is undone. CursorAfterApply() int CursorAfterRevert() int // MergedWith returns the edit that does this one and then next, if the two read as one step // to a person, or nil if they don't. MergedWith(next Edit) Edit // Weight is the characters held, which is what the history's limit counts. Weight() int}
// Insert is text typed or pasted at a position. Undoing it deletes the same characters again.type Insert struct { At int Text string}
func (i Insert) Apply(b TextBuffer) { b.Insert(i.At, i.Text) }func (i Insert) Revert(b TextBuffer) { b.Delete(i.At, i.Weight()) }func (i Insert) CursorAfterApply() int { return i.At + i.Weight() }func (i Insert) CursorAfterRevert() int { return i.At }func (i Insert) Weight() int { return utf8.RuneCountInString(i.Text) }
// MergedWith joins a keystroke right after the last one, until a space or newline ends the// word: "hello world" undoes as "world", then "hello ".func (i Insert) MergedWith(next Edit) Edit { n, ok := next.(Insert) if !ok || n.At != i.At+i.Weight() || n.Weight() != 1 { return nil } if last, _ := utf8.DecodeLastRuneInString(i.Text); unicode.IsSpace(last) { return nil } return Insert{i.At, i.Text + n.Text}}
// Delete is characters removed at a position. It keeps the removed text, so undo can put it// back.type Delete struct { At int Removed string}
func (d Delete) Apply(b TextBuffer) { b.Delete(d.At, d.Weight()) }func (d Delete) Revert(b TextBuffer) { b.Insert(d.At, d.Removed) }func (d Delete) CursorAfterApply() int { return d.At }func (d Delete) CursorAfterRevert() int { return d.At + d.Weight() }func (d Delete) Weight() int { return utf8.RuneCountInString(d.Removed) }
// MergedWith joins a backspace right before the last one: holding backspace is one step.func (d Delete) MergedWith(next Edit) Edit { n, ok := next.(Delete) if !ok || n.At+n.Weight() != d.At { return nil } return Delete{n.At, n.Removed + d.Removed}}
// History merges edits that a person thinks of as one, and keeps at most limit characters of// history, dropping the oldest edits first.type History struct { undo, redo []Edit clock func() int64 mergeWindow int64 limit int weight int // characters held on the undo stack lastEditAt int64 open bool // the top edit can still take a merge}
func NewHistory(clock func() int64, mergeWindowMillis int64, limit int) *History { return &History{clock: clock, mergeWindow: mergeWindowMillis, limit: limit}}
// Record starts a new step after a pause, and after anything that sealed the last one.func (h *History) Record(e Edit) { h.redo = nil now := h.clock() if n := len(h.undo); h.open && n > 0 && now-h.lastEditAt <= h.mergeWindow { if merged := h.undo[n-1].MergedWith(e); merged != nil { h.weight -= h.undo[n-1].Weight() h.undo = h.undo[:n-1] e = merged } } h.push(e) h.lastEditAt = now h.open = true}
// Seal closes the newest step, so the next edit starts a new one. Moving the cursor, pasting// and undoing all seal: typing after them is a new thought.func (h *History) Seal() { h.open = false }
// Undo moves the newest step to the redo stack. Any new edit clears redo, in Record.func (h *History) Undo() Edit { h.Seal() if len(h.undo) == 0 { return nil } e := h.undo[len(h.undo)-1] h.undo = h.undo[:len(h.undo)-1] h.weight -= e.Weight() h.redo = append(h.redo, e) return e}
func (h *History) Redo() Edit { h.Seal() if len(h.redo) == 0 { return nil } e := h.redo[len(h.redo)-1] h.redo = h.redo[:len(h.redo)-1] h.push(e) return e}
// push keeps the newest edit even if it alone is over the limit; older ones go until it fits.func (h *History) push(e Edit) { h.undo = append(h.undo, e) h.weight += e.Weight() for h.weight > h.limit && len(h.undo) > 1 { h.weight -= h.undo[0].Weight() h.undo = h.undo[1:] }}
// Editor is what the keyboard calls. Every change goes through run, which applies the edit,// moves the cursor and records it in the history, all in one place.type Editor struct { buffer TextBuffer history *History cursor int}
func NewEditor(buffer TextBuffer, history *History) *Editor { return &Editor{buffer: buffer, history: history}}
func (e *Editor) Type(text string) { e.run(Insert{e.cursor, text}) }
// Paste is one step of its own: it never joins the typing before or after it.func (e *Editor) Paste(text string) { e.history.Seal() e.run(Insert{e.cursor, text}) e.history.Seal()}
// Backspace removes the character before the cursor, and keeps it so undo can bring it back.func (e *Editor) Backspace() { if e.cursor > 0 { e.run(Delete{e.cursor - 1, e.buffer.Slice(e.cursor-1, e.cursor)}) }}
// MoveCursor keeps the cursor inside the text. Moving the cursor ends the current undo step.func (e *Editor) MoveCursor(to int) { e.cursor = max(0, min(to, e.buffer.Len())) e.history.Seal()}
// Undo reverts the newest step and puts the cursor back where that step began.func (e *Editor) Undo() bool { ed := e.history.Undo() if ed == nil { return false } ed.Revert(e.buffer) e.cursor = ed.CursorAfterRevert() return true}
// Redo does the step again and puts the cursor after it.func (e *Editor) Redo() bool { ed := e.history.Redo() if ed == nil { return false } ed.Apply(e.buffer) e.cursor = ed.CursorAfterApply() return true}
func (e *Editor) Text() string { return e.buffer.Text() }
func (e *Editor) Cursor() int { return e.cursor }
func (e *Editor) run(ed Edit) { ed.Apply(e.buffer) e.cursor = ed.CursorAfterApply() e.history.Record(ed)}// One change to the text, as an object that can be done and undone (the Command pattern). The// history stores edits, not copies of the text, so a long history stays small.interface Edit { apply(buffer: TextBuffer): void; revert(buffer: TextBuffer): void; // Where the cursor goes after the edit is done (on redo), and after it is undone. cursorAfterApply(): number; cursorAfterRevert(): number; // The edit that does this one and then next, if the two read as one step to a person. mergedWith(next: Edit): Edit | undefined; // Characters held, which is what the history's limit counts. weight(): number;}
// Text typed or pasted at a position. Undoing it deletes the same characters again.class Insert implements Edit { constructor( readonly at: number, readonly text: string, ) {}
apply(b: TextBuffer): void { b.insert(this.at, this.text); }
revert(b: TextBuffer): void { b.delete(this.at, this.text.length); }
cursorAfterApply(): number { return this.at + this.text.length; }
cursorAfterRevert(): number { return this.at; }
weight(): number { return this.text.length; }
// A keystroke right after the last one joins it, until a space or newline ends the word: // "hello world" undoes as "world", then "hello ". mergedWith(next: Edit): Edit | undefined { if (!(next instanceof Insert) || next.at !== this.at + this.text.length || next.text.length !== 1) return undefined; if (/\s$/.test(this.text)) return undefined; return new Insert(this.at, this.text + next.text); }}
// Characters removed at a position. It keeps the removed text, so undo can put it back.class Delete implements Edit { constructor( readonly at: number, readonly removed: string, ) {}
apply(b: TextBuffer): void { b.delete(this.at, this.removed.length); }
revert(b: TextBuffer): void { b.insert(this.at, this.removed); }
cursorAfterApply(): number { return this.at; }
cursorAfterRevert(): number { return this.at + this.removed.length; }
weight(): number { return this.removed.length; }
// A backspace right before the last one joins it: holding backspace is one step. mergedWith(next: Edit): Edit | undefined { if (!(next instanceof Delete) || next.at + next.removed.length !== this.at) return undefined; return new Delete(next.at, next.removed + this.removed); }}
// Merges edits that a person thinks of as one, and keeps at most limit characters of history,// dropping the oldest edits first.class History { private readonly undoStack: Edit[] = []; private redoStack: Edit[] = []; private weight = 0; // characters held on the undo stack private lastEditAt = 0; private sealed = true; // the top edit is closed to merging
constructor( private readonly clock: () => number, private readonly mergeWindowMs: number, private readonly limit: number, ) {}
// Typing after a pause starts a new step, and so does typing after anything that sealed the // last one. record(e: Edit): void { this.redoStack = []; const now = this.clock(); const top = this.undoStack.at(-1); const merged = !this.sealed && top && now - this.lastEditAt <= this.mergeWindowMs ? top.mergedWith(e) : undefined; if (top && merged) { this.undoStack.pop(); this.weight -= top.weight(); e = merged; } this.push(e); this.lastEditAt = now; this.sealed = false; }
// Closes the newest step, so the next edit starts a new one. Moving the cursor, pasting and // undoing all seal: typing after them is a new thought. seal(): void { this.sealed = true; }
// Moves the newest step to the redo stack. Any new edit clears redo, in record. undo(): Edit | undefined { this.seal(); const e = this.undoStack.pop(); if (!e) return undefined; this.weight -= e.weight(); this.redoStack.push(e); return e; }
redo(): Edit | undefined { this.seal(); const e = this.redoStack.pop(); if (e) this.push(e); return e; }
// The newest edit always stays, even alone over the limit; older ones go until it fits. private push(e: Edit): void { this.undoStack.push(e); this.weight += e.weight(); while (this.weight > this.limit && this.undoStack.length > 1) this.weight -= this.undoStack.shift()!.weight(); }}
// What the keyboard calls. Every change goes through run, which applies the edit, moves the// cursor and records it in the history, all in one place.class Editor { private cursor_ = 0;
constructor( private readonly buffer: TextBuffer, private readonly history: History, ) {}
type(text: string): void { this.run(new Insert(this.cursor_, text)); }
// A paste is one step of its own: it never joins the typing before or after it. paste(text: string): void { this.history.seal(); this.run(new Insert(this.cursor_, text)); this.history.seal(); }
// Removes the character before the cursor, and keeps it so undo can bring it back. backspace(): void { if (this.cursor_ > 0) this.run(new Delete(this.cursor_ - 1, this.buffer.slice(this.cursor_ - 1, this.cursor_))); }
// Kept inside the text. Moving the cursor ends the current undo step. moveCursor(to: number): void { this.cursor_ = Math.max(0, Math.min(to, this.buffer.length)); this.history.seal(); }
// Reverts the newest step and puts the cursor back where that step began. undo(): boolean { const e = this.history.undo(); if (!e) return false; e.revert(this.buffer); this.cursor_ = e.cursorAfterRevert(); return true; }
// Does the step again and puts the cursor after it. redo(): boolean { const e = this.history.redo(); if (!e) return false; e.apply(this.buffer); this.cursor_ = e.cursorAfterApply(); return true; }
get text(): string { return this.buffer.text(); }
get cursor(): number { return this.cursor_; }
private run(e: Edit): void { e.apply(this.buffer); this.cursor_ = e.cursorAfterApply(); this.history.record(e); }}Go deeperLimit by steps, or by characters held1 min
| A number of steps | Characters held (our choice) | |
|---|---|---|
| Memory limit | None: one step can be huge | Yes, within one edit |
| Easy for the user to predict | “100 undos” | “about the last N characters of changes” |
| Cost to track | A count | A running sum, updated on push and drop |
| What a huge paste does | Takes one of the 100 slots | Pushes older history out |
Some editors use both: a step count that’s easy to predict, and a size limit as a safety net.
Stage 4: A buffer that doesn’t copy on every keystroke
Section titled “Stage 4: A buffer that doesn’t copy on every keystroke”History is cheap now, but each keystroke still inserts into a string builder in the middle of a 4 MB file. What does that cost, and what would you use instead?
Whether you know that inserting into the middle of an array moves everything after it, and can describe one structure that avoids it. A gap buffer is the expected answer for one cursor. Knowing that piece tables and ropes exist, and when they win, is the senior part.
A string builder is an array. Inserting a character in the middle shifts every character after it by one place. So typing near the top of a 4 MB file moves up to 4 MB per keystroke. But people type at one place for a long time, and a gap buffer takes advantage of that.
The text is one array, with an empty gap at the cursor. Typing writes into the gap from the left, and the gap shrinks. Backspace makes the gap wider. Neither moves any other character. Moving the cursor moves the gap, which copies only the characters between the old position and the new one. When the gap runs out, the array doubles in size, so growing is rare.
- Color key
- Implementation
GapBuffer is a second implementation of TextBuffer. So the editor, the edits and the history
don’t change, because each edit already talks to the buffer only through insert, delete and
slice. Swapping one implementation for another behind an interface is the Strategy pattern,
and the interface from stage 2 is what makes it free now.
Go deeperFollowing the gap as you type and move2 min
Say the array has room for 16 characters and holds hello world, with the cursor after hello.
The 11 characters are split around a gap of 5 empty slots: hello on the left, then the gap, then
world on the right.
- Type “,” The comma goes into the first slot of the gap. The gap is now 4 slots. Nothing else
moved. Text:
hello, world. - Backspace. The gap simply grows back by one slot. The comma is still in memory, but it’s inside the gap now, so it doesn’t count. Nothing moved.
- Move the cursor to the end. The gap must move to the end. The 6 characters of
worldare copied from the right of the gap to its left. That’s 6 characters, not the whole file. - Type “!” five times, then once more. The first five fill the gap. The sixth finds no room, so the array doubles to 32 slots and the text is copied once. Then typing carries on, free again.
So the cost of typing is the distance the cursor moved since the last edit, plus a rare copy when the array grows. For a person typing, that’s almost always tiny.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A gap buffer: the text in one array with an empty gap at the cursor. Typing fills the gap from// the left, and backspace widens it, so neither moves any other character. Moving the cursor// moves the gap, copying only the characters between the old and new position. Editors type at// one place for a long time, which is why Emacs uses this.class GapBuffer implements TextBuffer { private char[] chars = new char[16]; private int gapStart; // first empty slot private int gapEnd = chars.length; // first slot after the gap
public void insert(int at, String text) { moveGap(at); if (text.length() > gapEnd - gapStart) grow(text.length()); text.getChars(0, text.length(), chars, gapStart); gapStart += text.length(); }
// The deleted characters are left in place and become part of the gap. public void delete(int at, int length) { moveGap(at); gapEnd += length; }
// Positions after the gap are shifted by the gap's width. public String slice(int from, int to) { StringBuilder out = new StringBuilder(to - from); for (int i = from; i < to; i++) out.append(chars[i < gapStart ? i : i + gapEnd - gapStart]); return out.toString(); }
public int length() { return chars.length - (gapEnd - gapStart); }
public String text() { return new String(chars, 0, gapStart) + new String(chars, gapEnd, chars.length - gapEnd); }
private void moveGap(int at) { if (at < gapStart) { // move the characters between at and the gap to after the gap int n = gapStart - at; System.arraycopy(chars, at, chars, gapEnd - n, n); gapStart -= n; gapEnd -= n; } else if (at > gapStart) { // move the characters after the gap to before it int n = at - gapStart; System.arraycopy(chars, gapEnd, chars, gapStart, n); gapStart += n; gapEnd += n; } }
// Doubling keeps growth rare: typing n characters copies the text O(log n) times in all. private void grow(int needed) { int tail = chars.length - gapEnd; char[] bigger = new char[Math.max(chars.length * 2, length() + needed + 16)]; System.arraycopy(chars, 0, bigger, 0, gapStart); System.arraycopy(chars, gapEnd, bigger, bigger.length - tail, tail); gapEnd = bigger.length - tail; chars = bigger; }}# The text in one list with an empty gap at the cursor. Typing fills the gap from the left,# and backspace widens it, so neither moves any other character. Moving the cursor moves the# gap, copying only the characters between the old and new position. Editors type at one# place for a long time, which is why Emacs uses this.class GapBuffer: def __init__(self): self._chars: list[str] = [""] * 16 self._gap_start = 0 # first empty slot self._gap_end = 16 # first slot after the gap
def insert(self, at: int, text: str) -> None: self._move_gap(at) if len(text) > self._gap_end - self._gap_start: self._grow(len(text)) self._chars[self._gap_start : self._gap_start + len(text)] = text self._gap_start += len(text)
# The deleted characters are left in place and become part of the gap. def delete(self, at: int, length: int) -> None: self._move_gap(at) self._gap_end += length
# Positions after the gap are shifted by the gap's width. def slice(self, start: int, end: int) -> str: gap = self._gap_end - self._gap_start return "".join(self._chars[i if i < self._gap_start else i + gap] for i in range(start, end))
def __len__(self) -> int: return len(self._chars) - (self._gap_end - self._gap_start)
def text(self) -> str: return "".join(self._chars[: self._gap_start]) + "".join(self._chars[self._gap_end :])
def _move_gap(self, at: int) -> None: c = self._chars if at < self._gap_start: # move the characters between at and the gap to after the gap n = self._gap_start - at c[self._gap_end - n : self._gap_end] = c[at : self._gap_start] self._gap_start -= n self._gap_end -= n elif at > self._gap_start: # move the characters after the gap to before it n = at - self._gap_start c[self._gap_start : self._gap_start + n] = c[self._gap_end : self._gap_end + n] self._gap_start += n self._gap_end += n
# Doubling keeps growth rare: typing n characters copies the text O(log n) times. def _grow(self, needed: int) -> None: size = max(len(self._chars) * 2, len(self) + needed + 16) tail = self._chars[self._gap_end :] self._chars = self._chars[: self._gap_start] + [""] * (size - self._gap_start - len(tail)) + tail self._gap_end = size - len(tail)// A gap buffer: the text in one array with an empty gap at the cursor. Typing fills the gap from// the left, and backspace widens it, so neither moves any other character. Moving the cursor// moves the gap, copying only the characters between the old and new position. Editors type at// one place for a long time, which is why Emacs uses this.public sealed class GapBuffer : ITextBuffer{ private char[] _chars = new char[16]; private int _gapStart; // first empty slot private int _gapEnd = 16; // first slot after the gap
public int Length => _chars.Length - (_gapEnd - _gapStart);
public void Insert(int at, string text) { MoveGap(at); if (text.Length > _gapEnd - _gapStart) Grow(text.Length); text.CopyTo(0, _chars, _gapStart, text.Length); _gapStart += text.Length; }
// The deleted characters are left in place and become part of the gap. public void Delete(int at, int length) { MoveGap(at); _gapEnd += length; }
// Positions after the gap are shifted by the gap's width. public string Slice(int from, int to) { var sb = new StringBuilder(to - from); for (var i = from; i < to; i++) sb.Append(_chars[i < _gapStart ? i : i + _gapEnd - _gapStart]); return sb.ToString(); }
public string Text() => new string(_chars, 0, _gapStart) + new string(_chars, _gapEnd, _chars.Length - _gapEnd);
private void MoveGap(int at) { if (at < _gapStart) // move the characters between at and the gap to after the gap { var n = _gapStart - at; Array.Copy(_chars, at, _chars, _gapEnd - n, n); _gapStart -= n; _gapEnd -= n; } else if (at > _gapStart) // move the characters after the gap to before it { var n = at - _gapStart; Array.Copy(_chars, _gapEnd, _chars, _gapStart, n); _gapStart += n; _gapEnd += n; } }
// Doubling keeps growth rare: typing n characters copies the text O(log n) times in all. private void Grow(int needed) { var tail = _chars.Length - _gapEnd; var bigger = new char[Math.Max(_chars.Length * 2, Length + needed + 16)]; Array.Copy(_chars, 0, bigger, 0, _gapStart); Array.Copy(_chars, _gapEnd, bigger, bigger.Length - tail, tail); _gapEnd = bigger.Length - tail; _chars = bigger; }}// GapBuffer holds the text in one slice with an empty gap at the cursor. Typing fills the gap// from the left, and backspace widens it, so neither moves any other character. Moving the// cursor moves the gap, copying only the characters between the old and new position. Editors// type at one place for a long time, which is why Emacs uses this.type GapBuffer struct { runes []rune gapStart int // first empty slot gapEnd int // first slot after the gap}
func NewGapBuffer() *GapBuffer { return &GapBuffer{runes: make([]rune, 16), gapEnd: 16} }
func (g *GapBuffer) Insert(at int, text string) { r := []rune(text) g.moveGap(at) if len(r) > g.gapEnd-g.gapStart { g.grow(len(r)) } copy(g.runes[g.gapStart:], r) g.gapStart += len(r)}
// Delete leaves the deleted characters in place, as part of the gap.func (g *GapBuffer) Delete(at, length int) { g.moveGap(at) g.gapEnd += length}
// Slice: positions after the gap are shifted by the gap's width.func (g *GapBuffer) Slice(from, to int) string { out := make([]rune, 0, to-from) for i := from; i < to; i++ { if i < g.gapStart { out = append(out, g.runes[i]) } else { out = append(out, g.runes[i+g.gapEnd-g.gapStart]) } } return string(out)}
func (g *GapBuffer) Len() int { return len(g.runes) - (g.gapEnd - g.gapStart) }
func (g *GapBuffer) Text() string { return string(g.runes[:g.gapStart]) + string(g.runes[g.gapEnd:])}
func (g *GapBuffer) moveGap(at int) { if at < g.gapStart { // move the characters between at and the gap to after the gap n := g.gapStart - at copy(g.runes[g.gapEnd-n:g.gapEnd], g.runes[at:g.gapStart]) g.gapStart -= n g.gapEnd -= n } else if at > g.gapStart { // move the characters after the gap to before it n := at - g.gapStart copy(g.runes[g.gapStart:g.gapStart+n], g.runes[g.gapEnd:g.gapEnd+n]) g.gapStart += n g.gapEnd += n }}
// grow doubles, which keeps growth rare: typing n characters copies the text O(log n) times.func (g *GapBuffer) grow(needed int) { tail := len(g.runes) - g.gapEnd bigger := make([]rune, max(len(g.runes)*2, g.Len()+needed+16)) copy(bigger, g.runes[:g.gapStart]) copy(bigger[len(bigger)-tail:], g.runes[g.gapEnd:]) g.gapEnd = len(bigger) - tail g.runes = bigger}// A gap buffer: the text in one array with an empty gap at the cursor. Typing fills the gap from// the left, and backspace widens it, so neither moves any other character. Moving the cursor// moves the gap, copying only the characters between the old and new position. Editors type at// one place for a long time, which is why Emacs uses this.class GapBuffer implements TextBuffer { private chars: string[] = new Array(16).fill(''); private gapStart = 0; // first empty slot private gapEnd = 16; // first slot after the gap
insert(at: number, text: string): void { this.moveGap(at); if (text.length > this.gapEnd - this.gapStart) this.grow(text.length); for (const c of text) this.chars[this.gapStart++] = c; }
// The deleted characters are left in place and become part of the gap. delete(at: number, length: number): void { this.moveGap(at); this.gapEnd += length; }
// Positions after the gap are shifted by the gap's width. slice(from: number, to: number): string { let out = ''; for (let i = from; i < to; i++) out += this.chars[i < this.gapStart ? i : i + this.gapEnd - this.gapStart]; return out; }
get length(): number { return this.chars.length - (this.gapEnd - this.gapStart); }
text(): string { return this.chars.slice(0, this.gapStart).join('') + this.chars.slice(this.gapEnd).join(''); }
private moveGap(at: number): void { if (at < this.gapStart) { // move the characters between at and the gap to after the gap const n = this.gapStart - at; this.chars.copyWithin(this.gapEnd - n, at, this.gapStart); this.gapStart -= n; this.gapEnd -= n; } else if (at > this.gapStart) { // move the characters after the gap to before it const n = at - this.gapStart; this.chars.copyWithin(this.gapStart, this.gapEnd, this.gapEnd + n); this.gapStart += n; this.gapEnd += n; } }
// Doubling keeps growth rare: typing n characters copies the text O(log n) times in all. private grow(needed: number): void { const tail = this.chars.slice(this.gapEnd); const size = Math.max(this.chars.length * 2, this.length + needed + 16); const gap = new Array(size - this.gapStart - tail.length).fill(''); this.chars = [...this.chars.slice(0, this.gapStart), ...gap, ...tail]; this.gapEnd = size - tail.length; }}Go deeperGap buffer, piece table or rope2 min
| Buffer | Type at the cursor | Jump far, then type | Strength | Used by |
|---|---|---|---|---|
| String or array | O(n) | O(n) | Simplest | Small text fields |
| Gap buffer | O(1) on average | O(distance moved) | Fast typing at one place, simple code | Emacs |
| Piece table | O(pieces) | O(pieces) | Original file never copied; undo is cheap | VS Code, Word |
| Rope | O(log n) | O(log n) | Fast everywhere, many cursors, huge files | Xi, some IDEs |
When would you pick a piece table over a gap buffer? When files are large, and opened mostly to read or to make small changes. A piece table never copies the original file. It keeps it read-only, adds new text to the end of a second buffer, and describes the document as a list of pieces pointing into the two. Opening a 1 GB log is instant. And every version of the document is just a different list of pieces, which makes undo cheap too.
A rope is a balanced tree of short strings. Every operation costs O(log n), wherever the cursor is, which suits many cursors and very large files, at the price of more complex code.
This is enough for a mid-level answer. Edits as commands with what they need to undo, two stacks, redo cleared on a new edit, and typing merged into steps. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Editor is what the screen calls. History decides what one undo step
is. Each Edit knows how to do and undo itself, and TextBuffer hides how the text is stored.
SharedEditor lets an autosave thread read the editor safely.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.function.*;
// What the keyboard calls. Every change goes through run, which applies the edit, moves the// cursor and records it in the history, all in one place.class Editor { private final TextBuffer buffer; private final History history; private int cursor;
Editor(TextBuffer buffer, History history) { this.buffer = buffer; this.history = history; }
void type(String text) { run(new Insert(cursor, text)); }
// A paste is one step of its own: it never joins the typing before or after it. void paste(String text) { history.seal(); run(new Insert(cursor, text)); history.seal(); }
// Removes the character before the cursor, and keeps it so undo can bring it back. void backspace() { if (cursor > 0) run(new Delete(cursor - 1, buffer.slice(cursor - 1, cursor))); }
// Kept inside the text. Moving the cursor ends the current undo step. void moveCursor(int to) { cursor = Math.max(0, Math.min(to, buffer.length())); history.seal(); }
// Reverts the newest step and puts the cursor back where that step began. boolean undo() { Edit e = history.undo(); if (e == null) return false; e.revert(buffer); cursor = e.cursorAfterRevert(); return true; }
// Does the step again and puts the cursor after it. boolean redo() { Edit e = history.redo(); if (e == null) return false; e.apply(buffer); cursor = e.cursorAfterApply(); return true; }
String text() { return buffer.text(); }
int cursor() { return cursor; }
private void run(Edit e) { e.apply(buffer); cursor = e.cursorAfterApply(); history.record(e); }}
// What the editor needs from the text, whatever holds it. A faster one can be swapped in// without touching the editor.interface TextBuffer { void insert(int at, String text); void delete(int at, int length); String slice(int from, int to); int length(); String text();}
// One change to the text, as an object that can be done and undone (the Command pattern). The// history stores edits, not copies of the text, so a long history stays small.interface Edit { void apply(TextBuffer buffer); void revert(TextBuffer buffer); // Where the cursor goes after the edit is done (on redo), and after it is undone. int cursorAfterApply(); int cursorAfterRevert();
// The edit that does this one and then next, if the two read as one step to a person, or // null if they don't. Edit mergedWith(Edit next);
// Characters held, which is what the history's limit counts. int weight();}
// Text typed or pasted at a position. Undoing it deletes the same characters again.record Insert(int at, String text) implements Edit { public void apply(TextBuffer b) { b.insert(at, text); } public void revert(TextBuffer b) { b.delete(at, text.length()); } public int cursorAfterApply() { return at + text.length(); } public int cursorAfterRevert() { return at; } public int weight() { return text.length(); }
// A keystroke right after the last one joins it, until a space or newline ends the word: // "hello world" undoes as "world", then "hello ". public Edit mergedWith(Edit next) { if (!(next instanceof Insert i) || i.at != at + text.length() || i.text.length() != 1) return null; if (Character.isWhitespace(text.charAt(text.length() - 1))) return null; return new Insert(at, text + i.text); }}
// Characters removed at a position. It keeps the removed text, so undo can put it back.record Delete(int at, String removed) implements Edit { public void apply(TextBuffer b) { b.delete(at, removed.length()); } public void revert(TextBuffer b) { b.insert(at, removed); } public int cursorAfterApply() { return at; } public int cursorAfterRevert() { return at + removed.length(); } public int weight() { return removed.length(); }
// A backspace right before the last one joins it: holding backspace is one step. public Edit mergedWith(Edit next) { if (!(next instanceof Delete d) || d.at + d.removed.length() != at) return null; return new Delete(d.at, d.removed + removed); }}
// Merges edits that a person thinks of as one, and keeps at most limit characters of history,// dropping the oldest edits first.class History { private final Deque<Edit> undo = new ArrayDeque<>(); private final Deque<Edit> redo = new ArrayDeque<>(); private final LongSupplier clock; private final long mergeWindowMillis; private final int limit; private int weight; // characters held on the undo stack private long lastEditAt; private boolean sealed = true; // the top edit is closed to merging
History(LongSupplier clock, long mergeWindowMillis, int limit) { this.clock = clock; this.mergeWindowMillis = mergeWindowMillis; this.limit = limit; }
// Typing after a pause starts a new step, and so does typing after anything that sealed the // last one. void record(Edit e) { redo.clear(); long now = clock.getAsLong(); Edit top = undo.peek(); Edit merged = !sealed && top != null && now - lastEditAt <= mergeWindowMillis ? top.mergedWith(e) : null; if (merged != null) { undo.pop(); weight -= top.weight(); e = merged; } push(e); lastEditAt = now; sealed = false; }
// Closes the newest step, so the next edit starts a new one. Moving the cursor, pasting and // undoing all seal: typing after them is a new thought. void seal() { sealed = true; }
// Moves the newest step to the redo stack. Any new edit clears redo, in record. Edit undo() { seal(); Edit e = undo.poll(); if (e == null) return null; weight -= e.weight(); redo.push(e); return e; }
Edit redo() { seal(); Edit e = redo.poll(); if (e != null) push(e); return e; }
// The newest edit always stays, even alone over the limit; older ones go until it fits. private void push(Edit e) { undo.push(e); weight += e.weight(); while (weight > limit && undo.size() > 1) weight -= undo.removeLast().weight(); }}
// A gap buffer: the text in one array with an empty gap at the cursor. Typing fills the gap from// the left, and backspace widens it, so neither moves any other character. Moving the cursor// moves the gap, copying only the characters between the old and new position. Editors type at// one place for a long time, which is why Emacs uses this.class GapBuffer implements TextBuffer { private char[] chars = new char[16]; private int gapStart; // first empty slot private int gapEnd = chars.length; // first slot after the gap
public void insert(int at, String text) { moveGap(at); if (text.length() > gapEnd - gapStart) grow(text.length()); text.getChars(0, text.length(), chars, gapStart); gapStart += text.length(); }
// The deleted characters are left in place and become part of the gap. public void delete(int at, int length) { moveGap(at); gapEnd += length; }
// Positions after the gap are shifted by the gap's width. public String slice(int from, int to) { StringBuilder out = new StringBuilder(to - from); for (int i = from; i < to; i++) out.append(chars[i < gapStart ? i : i + gapEnd - gapStart]); return out.toString(); }
public int length() { return chars.length - (gapEnd - gapStart); }
public String text() { return new String(chars, 0, gapStart) + new String(chars, gapEnd, chars.length - gapEnd); }
private void moveGap(int at) { if (at < gapStart) { // move the characters between at and the gap to after the gap int n = gapStart - at; System.arraycopy(chars, at, chars, gapEnd - n, n); gapStart -= n; gapEnd -= n; } else if (at > gapStart) { // move the characters after the gap to before it int n = at - gapStart; System.arraycopy(chars, gapEnd, chars, gapStart, n); gapStart += n; gapEnd += n; } }
// Doubling keeps growth rare: typing n characters copies the text O(log n) times in all. private void grow(int needed) { int tail = chars.length - gapEnd; char[] bigger = new char[Math.max(chars.length * 2, length() + needed + 16)]; System.arraycopy(chars, 0, bigger, 0, gapStart); System.arraycopy(chars, gapEnd, bigger, bigger.length - tail, tail); gapEnd = bigger.length - tail; chars = bigger; }}
// What autosave took: the text, and the version it was at.record Snapshot(long version, String text) {}
// The editor shared with background threads. The UI thread edits; an autosave thread takes a// snapshot, writes it to disk with no lock held, then reports which version it saved. Every// call holds one lock, so a snapshot never sees a gap buffer halfway through moving its gap.//// The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it// finished, anything typed during the write would be marked saved without being on disk, and// closing the editor would lose it. Recording the saved version means the editor is clean only// while the text is still at that version.class SharedEditor { private final Editor editor; private long version; private long savedVersion;
SharedEditor(Editor editor) { this.editor = editor; }
synchronized void type(String text) { editor.type(text); version++; }
synchronized void paste(String text) { editor.paste(text); version++; }
synchronized void backspace() { if (editor.cursor() == 0) return; editor.backspace(); version++; }
synchronized void moveCursor(int to) { editor.moveCursor(to); }
synchronized boolean undo() { boolean changed = editor.undo(); if (changed) version++; return changed; }
synchronized boolean redo() { boolean changed = editor.redo(); if (changed) version++; return changed; }
// Taken under the lock, so the text and its version always match. synchronized Snapshot snapshot() { return new Snapshot(version, editor.text()); }
// A save that finishes late, after a newer one, mustn't move the saved version backwards. synchronized void markSaved(long savedAt) { savedVersion = Math.max(savedVersion, savedAt); }
// True while the text is at a different version from the last one saved. synchronized boolean dirty() { return version != savedVersion; }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom collections import dequefrom dataclasses import dataclassfrom typing import Callable, Protocol
# What the keyboard calls. Every change goes through _run, which applies the edit, moves the# cursor and records it in the history, all in one place.class Editor: def __init__(self, buffer: TextBuffer, history: History): self._buffer = buffer self._history = history self._cursor = 0
def type(self, text: str) -> None: self._run(Insert(self._cursor, text))
# A paste is one step of its own: it never joins the typing before or after it. def paste(self, text: str) -> None: self._history.seal() self._run(Insert(self._cursor, text)) self._history.seal()
# Removes the character before the cursor, and keeps it so undo can bring it back. def backspace(self) -> None: if self._cursor > 0: self._run(Delete(self._cursor - 1, self._buffer.slice(self._cursor - 1, self._cursor)))
# Kept inside the text. Moving the cursor ends the current undo step. def move_cursor(self, to: int) -> None: self._cursor = max(0, min(to, len(self._buffer))) self._history.seal()
# Reverts the newest step and puts the cursor back where that step began. def undo(self) -> bool: e = self._history.undo() if e is None: return False e.revert(self._buffer) self._cursor = e.cursor_after_revert() return True
# Does the step again and puts the cursor after it. def redo(self) -> bool: e = self._history.redo() if e is None: return False e.apply(self._buffer) self._cursor = e.cursor_after_apply() return True
@property def text(self) -> str: return self._buffer.text()
@property def cursor(self) -> int: return self._cursor
def _run(self, e: Edit) -> None: e.apply(self._buffer) self._cursor = e.cursor_after_apply() self._history.record(e)
# What the editor needs from the text, whatever holds it. A faster one can be swapped in# without touching the editor.class TextBuffer(Protocol): def insert(self, at: int, text: str) -> None: ... def delete(self, at: int, length: int) -> None: ... def slice(self, start: int, end: int) -> str: ... def __len__(self) -> int: ... def text(self) -> str: ...
# One change to the text, as an object that can be done and undone (the Command pattern). The# history stores edits, not copies of the text, so a long history stays small.class Edit(Protocol): def apply(self, buffer: TextBuffer) -> None: ... def revert(self, buffer: TextBuffer) -> None: ... # Where the cursor goes after the edit is done (on redo), and after it is undone. def cursor_after_apply(self) -> int: ... def cursor_after_revert(self) -> int: ...
# The edit that does this one and then nxt, if the two read as one step to a person. def merged_with(self, nxt: "Edit") -> "Edit | None": ...
# Characters held, which is what the history's limit counts. def weight(self) -> int: ...
# Text typed or pasted at a position. Undoing it deletes the same characters again.@dataclass(frozen=True)class Insert: at: int text: str
def apply(self, b: TextBuffer) -> None: b.insert(self.at, self.text)
def revert(self, b: TextBuffer) -> None: b.delete(self.at, len(self.text))
def cursor_after_apply(self) -> int: return self.at + len(self.text)
def cursor_after_revert(self) -> int: return self.at
def weight(self) -> int: return len(self.text)
# A keystroke right after the last one joins it, until a space or newline ends the # word: "hello world" undoes as "world", then "hello ". def merged_with(self, nxt: Edit) -> Edit | None: if not isinstance(nxt, Insert) or nxt.at != self.at + len(self.text) or len(nxt.text) != 1: return None if self.text[-1].isspace(): return None return Insert(self.at, self.text + nxt.text)
# Characters removed at a position. It keeps the removed text, so undo can put it back.@dataclass(frozen=True)class Delete: at: int removed: str
def apply(self, b: TextBuffer) -> None: b.delete(self.at, len(self.removed))
def revert(self, b: TextBuffer) -> None: b.insert(self.at, self.removed)
def cursor_after_apply(self) -> int: return self.at
def cursor_after_revert(self) -> int: return self.at + len(self.removed)
def weight(self) -> int: return len(self.removed)
# A backspace right before the last one joins it: holding backspace is one step. def merged_with(self, nxt: Edit) -> Edit | None: if not isinstance(nxt, Delete) or nxt.at + len(nxt.removed) != self.at: return None return Delete(nxt.at, nxt.removed + self.removed)
# Merges edits that a person thinks of as one, and keeps at most limit characters of# history, dropping the oldest edits first.class History: def __init__(self, clock: Callable[[], int], merge_window_ms: int, limit: int): self._undo: deque[Edit] = deque() self._redo: list[Edit] = [] self._clock = clock self._merge_window_ms = merge_window_ms self._limit = limit self._weight = 0 # characters held on the undo stack self._last_edit_at = 0 self._sealed = True # the top edit is closed to merging
# Typing after a pause starts a new step, and so does typing after anything that # sealed the last one. def record(self, e: Edit) -> None: self._redo.clear() now = self._clock() top = self._undo[-1] if self._undo else None merged = None if not self._sealed and top is not None and now - self._last_edit_at <= self._merge_window_ms: merged = top.merged_with(e) if merged is not None: self._undo.pop() self._weight -= top.weight() e = merged self._push(e) self._last_edit_at = now self._sealed = False
# Closes the newest step, so the next edit starts a new one. Moving the cursor, # pasting and undoing all seal: typing after them is a new thought. def seal(self) -> None: self._sealed = True
# Moves the newest step to the redo stack. Any new edit clears redo, in record. def undo(self) -> Edit | None: self.seal() if not self._undo: return None e = self._undo.pop() self._weight -= e.weight() self._redo.append(e) return e
def redo(self) -> Edit | None: self.seal() if not self._redo: return None e = self._redo.pop() self._push(e) return e
# The newest edit always stays, even alone over the limit; older ones go until it fits. def _push(self, e: Edit) -> None: self._undo.append(e) self._weight += e.weight() while self._weight > self._limit and len(self._undo) > 1: self._weight -= self._undo.popleft().weight()
# The text in one list with an empty gap at the cursor. Typing fills the gap from the left,# and backspace widens it, so neither moves any other character. Moving the cursor moves the# gap, copying only the characters between the old and new position. Editors type at one# place for a long time, which is why Emacs uses this.class GapBuffer: def __init__(self): self._chars: list[str] = [""] * 16 self._gap_start = 0 # first empty slot self._gap_end = 16 # first slot after the gap
def insert(self, at: int, text: str) -> None: self._move_gap(at) if len(text) > self._gap_end - self._gap_start: self._grow(len(text)) self._chars[self._gap_start : self._gap_start + len(text)] = text self._gap_start += len(text)
# The deleted characters are left in place and become part of the gap. def delete(self, at: int, length: int) -> None: self._move_gap(at) self._gap_end += length
# Positions after the gap are shifted by the gap's width. def slice(self, start: int, end: int) -> str: gap = self._gap_end - self._gap_start return "".join(self._chars[i if i < self._gap_start else i + gap] for i in range(start, end))
def __len__(self) -> int: return len(self._chars) - (self._gap_end - self._gap_start)
def text(self) -> str: return "".join(self._chars[: self._gap_start]) + "".join(self._chars[self._gap_end :])
def _move_gap(self, at: int) -> None: c = self._chars if at < self._gap_start: # move the characters between at and the gap to after the gap n = self._gap_start - at c[self._gap_end - n : self._gap_end] = c[at : self._gap_start] self._gap_start -= n self._gap_end -= n elif at > self._gap_start: # move the characters after the gap to before it n = at - self._gap_start c[self._gap_start : self._gap_start + n] = c[self._gap_end : self._gap_end + n] self._gap_start += n self._gap_end += n
# Doubling keeps growth rare: typing n characters copies the text O(log n) times. def _grow(self, needed: int) -> None: size = max(len(self._chars) * 2, len(self) + needed + 16) tail = self._chars[self._gap_end :] self._chars = self._chars[: self._gap_start] + [""] * (size - self._gap_start - len(tail)) + tail self._gap_end = size - len(tail)
# What autosave took: the text, and the version it was at.@dataclass(frozen=True)class Snapshot: version: int text: str
# The editor shared with background threads. The UI thread edits; an autosave thread takes a# snapshot, writes it to disk with no lock held, then reports which version it saved. Every# call holds one lock, so a snapshot never sees a gap buffer halfway through moving its gap.## The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it# finished, anything typed during the write would be marked saved without being on disk, and# closing the editor would lose it. Recording the saved version means the editor is clean only# while the text is still at that version.class SharedEditor: def __init__(self, editor: Editor): self._editor = editor self._lock = threading.Lock() self._version = 0 self._saved_version = 0
def type(self, text: str) -> None: with self._lock: self._editor.type(text) self._version += 1
def paste(self, text: str) -> None: with self._lock: self._editor.paste(text) self._version += 1
def backspace(self) -> None: with self._lock: if self._editor.cursor > 0: self._editor.backspace() self._version += 1
def move_cursor(self, to: int) -> None: with self._lock: self._editor.move_cursor(to)
def undo(self) -> bool: with self._lock: changed = self._editor.undo() self._version += changed return changed
def redo(self) -> bool: with self._lock: changed = self._editor.redo() self._version += changed return changed
# Taken under the lock, so the text and its version always match. def snapshot(self) -> Snapshot: with self._lock: return Snapshot(self._version, self._editor.text)
# A save that finishes late, after a newer one, mustn't move the saved version back. def mark_saved(self, saved_at: int) -> None: with self._lock: self._saved_version = max(self._saved_version, saved_at)
# True while the text is at a different version from the last one saved. @property def dirty(self) -> bool: with self._lock: return self._version != self._saved_version// The complete design, built from every stage on this page.
using System.Text;
// What the keyboard calls. Every change goes through Run, which applies the edit, moves the// cursor and records it in the history, all in one place.public sealed class Editor(ITextBuffer buffer, History history){ public int Cursor { get; private set; } public string Text => buffer.Text();
public void Type(string text) => Run(new Insert(Cursor, text));
// A paste is one step of its own: it never joins the typing before or after it. public void Paste(string text) { history.Seal(); Run(new Insert(Cursor, text)); history.Seal(); }
// Removes the character before the cursor, and keeps it so undo can bring it back. public void Backspace() { if (Cursor > 0) Run(new Delete(Cursor - 1, buffer.Slice(Cursor - 1, Cursor))); }
// Kept inside the text. Moving the cursor ends the current undo step. public void MoveCursor(int to) { Cursor = Math.Clamp(to, 0, buffer.Length); history.Seal(); }
// Reverts the newest step and puts the cursor back where that step began. public bool Undo() { if (history.Undo() is not { } e) return false; e.Revert(buffer); Cursor = e.CursorAfterRevert; return true; }
// Does the step again and puts the cursor after it. public bool Redo() { if (history.Redo() is not { } e) return false; e.Apply(buffer); Cursor = e.CursorAfterApply; return true; }
private void Run(IEdit e) { e.Apply(buffer); Cursor = e.CursorAfterApply; history.Record(e); }}
// What the editor needs from the text, whatever holds it. A faster one can be swapped in// without touching the editor.public interface ITextBuffer{ void Insert(int at, string text); void Delete(int at, int length); string Slice(int from, int to); int Length { get; } string Text();}
// One change to the text, as an object that can be done and undone (the Command pattern). The// history stores edits, not copies of the text, so a long history stays small.public interface IEdit{ void Apply(ITextBuffer buffer); void Revert(ITextBuffer buffer); // Where the cursor goes after the edit is done (on redo), and after it is undone. int CursorAfterApply { get; } int CursorAfterRevert { get; }
// The edit that does this one and then next, if the two read as one step to a person, or // null if they don't. IEdit? MergedWith(IEdit next);
// Characters held, which is what the history's limit counts. int Weight { get; }}
// Text typed or pasted at a position. Undoing it deletes the same characters again.public sealed record Insert(int At, string Text) : IEdit{ public void Apply(ITextBuffer b) => b.Insert(At, Text); public void Revert(ITextBuffer b) => b.Delete(At, Text.Length); public int CursorAfterApply => At + Text.Length; public int CursorAfterRevert => At; public int Weight => Text.Length;
// A keystroke right after the last one joins it, until a space or newline ends the word: // "hello world" undoes as "world", then "hello ". public IEdit? MergedWith(IEdit next) => next is Insert i && i.At == At + Text.Length && i.Text.Length == 1 && !char.IsWhiteSpace(Text[^1]) ? new Insert(At, Text + i.Text) : null;}
// Characters removed at a position. It keeps the removed text, so undo can put it back.public sealed record Delete(int At, string Removed) : IEdit{ public void Apply(ITextBuffer b) => b.Delete(At, Removed.Length); public void Revert(ITextBuffer b) => b.Insert(At, Removed); public int CursorAfterApply => At; public int CursorAfterRevert => At + Removed.Length; public int Weight => Removed.Length;
// A backspace right before the last one joins it: holding backspace is one step. public IEdit? MergedWith(IEdit next) => next is Delete d && d.At + d.Removed.Length == At ? new Delete(d.At, d.Removed + Removed) : null;}
// Merges edits that a person thinks of as one, and keeps at most limit characters of history,// dropping the oldest edits first. The undo stack is a linked list so the oldest edit can be// dropped from the bottom.public sealed class History(Func<long> clock, long mergeWindowMs, int limit){ private readonly LinkedList<IEdit> _undo = new(); // newest last private readonly Stack<IEdit> _redo = new(); private int _weight; // characters held on the undo stack private long _lastEditAt; private bool _sealed = true; // the top edit is closed to merging
// Typing after a pause starts a new step, and so does typing after anything that sealed the // last one. public void Record(IEdit e) { _redo.Clear(); var now = clock(); var top = _undo.Last?.Value; var merged = !_sealed && top is not null && now - _lastEditAt <= mergeWindowMs ? top.MergedWith(e) : null; if (merged is not null) { _undo.RemoveLast(); _weight -= top!.Weight; e = merged; } Push(e); _lastEditAt = now; _sealed = false; }
// Closes the newest step, so the next edit starts a new one. Moving the cursor, pasting and // undoing all seal: typing after them is a new thought. public void Seal() => _sealed = true;
// Moves the newest step to the redo stack. Any new edit clears redo, in Record. public IEdit? Undo() { Seal(); if (_undo.Last is not { } node) return null; _undo.RemoveLast(); _weight -= node.Value.Weight; _redo.Push(node.Value); return node.Value; }
public IEdit? Redo() { Seal(); if (!_redo.TryPop(out var e)) return null; Push(e); return e; }
// The newest edit always stays, even alone over the limit; older ones go until it fits. private void Push(IEdit e) { _undo.AddLast(e); _weight += e.Weight; while (_weight > limit && _undo.Count > 1) { _weight -= _undo.First!.Value.Weight; _undo.RemoveFirst(); } }}
// A gap buffer: the text in one array with an empty gap at the cursor. Typing fills the gap from// the left, and backspace widens it, so neither moves any other character. Moving the cursor// moves the gap, copying only the characters between the old and new position. Editors type at// one place for a long time, which is why Emacs uses this.public sealed class GapBuffer : ITextBuffer{ private char[] _chars = new char[16]; private int _gapStart; // first empty slot private int _gapEnd = 16; // first slot after the gap
public int Length => _chars.Length - (_gapEnd - _gapStart);
public void Insert(int at, string text) { MoveGap(at); if (text.Length > _gapEnd - _gapStart) Grow(text.Length); text.CopyTo(0, _chars, _gapStart, text.Length); _gapStart += text.Length; }
// The deleted characters are left in place and become part of the gap. public void Delete(int at, int length) { MoveGap(at); _gapEnd += length; }
// Positions after the gap are shifted by the gap's width. public string Slice(int from, int to) { var sb = new StringBuilder(to - from); for (var i = from; i < to; i++) sb.Append(_chars[i < _gapStart ? i : i + _gapEnd - _gapStart]); return sb.ToString(); }
public string Text() => new string(_chars, 0, _gapStart) + new string(_chars, _gapEnd, _chars.Length - _gapEnd);
private void MoveGap(int at) { if (at < _gapStart) // move the characters between at and the gap to after the gap { var n = _gapStart - at; Array.Copy(_chars, at, _chars, _gapEnd - n, n); _gapStart -= n; _gapEnd -= n; } else if (at > _gapStart) // move the characters after the gap to before it { var n = at - _gapStart; Array.Copy(_chars, _gapEnd, _chars, _gapStart, n); _gapStart += n; _gapEnd += n; } }
// Doubling keeps growth rare: typing n characters copies the text O(log n) times in all. private void Grow(int needed) { var tail = _chars.Length - _gapEnd; var bigger = new char[Math.Max(_chars.Length * 2, Length + needed + 16)]; Array.Copy(_chars, 0, bigger, 0, _gapStart); Array.Copy(_chars, _gapEnd, bigger, bigger.Length - tail, tail); _gapEnd = bigger.Length - tail; _chars = bigger; }}
// What autosave took: the text, and the version it was at.public sealed record Snapshot(long Version, string Text);
// The editor shared with background threads. The UI thread edits; an autosave thread takes a// snapshot, writes it to disk with no lock held, then reports which version it saved. Every// call holds one lock, so a snapshot never sees a gap buffer halfway through moving its gap.//// The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it// finished, anything typed during the write would be marked saved without being on disk, and// closing the editor would lose it. Recording the saved version means the editor is clean only// while the text is still at that version.public sealed class SharedEditor(Editor editor){ private readonly Lock _lock = new(); private long _version; private long _savedVersion;
public void Type(string text) { lock (_lock) { editor.Type(text); _version++; } }
public void Paste(string text) { lock (_lock) { editor.Paste(text); _version++; } }
public void Backspace() { lock (_lock) { if (editor.Cursor == 0) return; editor.Backspace(); _version++; } }
public void MoveCursor(int to) { lock (_lock) editor.MoveCursor(to); }
public bool Undo() { lock (_lock) { var changed = editor.Undo(); if (changed) _version++; return changed; } }
public bool Redo() { lock (_lock) { var changed = editor.Redo(); if (changed) _version++; return changed; } }
// Taken under the lock, so the text and its version always match. public Snapshot Snapshot() { lock (_lock) return new Snapshot(_version, editor.Text); }
// A save that finishes late, after a newer one, mustn't move the saved version backwards. public void MarkSaved(long savedAt) { lock (_lock) _savedVersion = Math.Max(_savedVersion, savedAt); }
public bool Dirty { get { lock (_lock) return _version != _savedVersion; } }}// The complete design, built from every stage on this page.
package texteditorundo
import ( "sync" "unicode" "unicode/utf8")
// TextBuffer is what the editor needs from the text, whatever holds it. Positions count runes,// so a multi-byte character is one position. A faster buffer can be swapped in without touching// the editor.type TextBuffer interface { Insert(at int, text string) Delete(at, length int) Slice(from, to int) string Len() int Text() string}
// Edit is one change to the text, as an object that can be done and undone (the Command// pattern). The history stores edits, not copies of the text, so a long history stays small.type Edit interface { Apply(b TextBuffer) Revert(b TextBuffer) // Where the cursor goes after the edit is done (on redo), and after it is undone. CursorAfterApply() int CursorAfterRevert() int // MergedWith returns the edit that does this one and then next, if the two read as one step // to a person, or nil if they don't. MergedWith(next Edit) Edit // Weight is the characters held, which is what the history's limit counts. Weight() int}
// Insert is text typed or pasted at a position. Undoing it deletes the same characters again.type Insert struct { At int Text string}
func (i Insert) Apply(b TextBuffer) { b.Insert(i.At, i.Text) }
func (i Insert) Revert(b TextBuffer) { b.Delete(i.At, i.Weight()) }
func (i Insert) CursorAfterApply() int { return i.At + i.Weight() }
func (i Insert) CursorAfterRevert() int { return i.At }
func (i Insert) Weight() int { return utf8.RuneCountInString(i.Text) }
// MergedWith joins a keystroke right after the last one, until a space or newline ends the// word: "hello world" undoes as "world", then "hello ".func (i Insert) MergedWith(next Edit) Edit { n, ok := next.(Insert) if !ok || n.At != i.At+i.Weight() || n.Weight() != 1 { return nil } if last, _ := utf8.DecodeLastRuneInString(i.Text); unicode.IsSpace(last) { return nil } return Insert{i.At, i.Text + n.Text}}
// Delete is characters removed at a position. It keeps the removed text, so undo can put it// back.type Delete struct { At int Removed string}
func (d Delete) Apply(b TextBuffer) { b.Delete(d.At, d.Weight()) }
func (d Delete) Revert(b TextBuffer) { b.Insert(d.At, d.Removed) }
func (d Delete) CursorAfterApply() int { return d.At }
func (d Delete) CursorAfterRevert() int { return d.At + d.Weight() }
func (d Delete) Weight() int { return utf8.RuneCountInString(d.Removed) }
// MergedWith joins a backspace right before the last one: holding backspace is one step.func (d Delete) MergedWith(next Edit) Edit { n, ok := next.(Delete) if !ok || n.At+n.Weight() != d.At { return nil } return Delete{n.At, n.Removed + d.Removed}}
// History merges edits that a person thinks of as one, and keeps at most limit characters of// history, dropping the oldest edits first.type History struct { undo, redo []Edit clock func() int64 mergeWindow int64 limit int weight int // characters held on the undo stack lastEditAt int64 open bool // the top edit can still take a merge}
func NewHistory(clock func() int64, mergeWindowMillis int64, limit int) *History { return &History{clock: clock, mergeWindow: mergeWindowMillis, limit: limit}}
// Record starts a new step after a pause, and after anything that sealed the last one.func (h *History) Record(e Edit) { h.redo = nil now := h.clock() if n := len(h.undo); h.open && n > 0 && now-h.lastEditAt <= h.mergeWindow { if merged := h.undo[n-1].MergedWith(e); merged != nil { h.weight -= h.undo[n-1].Weight() h.undo = h.undo[:n-1] e = merged } } h.push(e) h.lastEditAt = now h.open = true}
// Seal closes the newest step, so the next edit starts a new one. Moving the cursor, pasting// and undoing all seal: typing after them is a new thought.func (h *History) Seal() { h.open = false }
// Undo moves the newest step to the redo stack. Any new edit clears redo, in Record.func (h *History) Undo() Edit { h.Seal() if len(h.undo) == 0 { return nil } e := h.undo[len(h.undo)-1] h.undo = h.undo[:len(h.undo)-1] h.weight -= e.Weight() h.redo = append(h.redo, e) return e}
func (h *History) Redo() Edit { h.Seal() if len(h.redo) == 0 { return nil } e := h.redo[len(h.redo)-1] h.redo = h.redo[:len(h.redo)-1] h.push(e) return e}
// push keeps the newest edit even if it alone is over the limit; older ones go until it fits.func (h *History) push(e Edit) { h.undo = append(h.undo, e) h.weight += e.Weight() for h.weight > h.limit && len(h.undo) > 1 { h.weight -= h.undo[0].Weight() h.undo = h.undo[1:] }}
// Editor is what the keyboard calls. Every change goes through run, which applies the edit,// moves the cursor and records it in the history, all in one place.type Editor struct { buffer TextBuffer history *History cursor int}
func NewEditor(buffer TextBuffer, history *History) *Editor { return &Editor{buffer: buffer, history: history}}
func (e *Editor) Type(text string) { e.run(Insert{e.cursor, text}) }
// Paste is one step of its own: it never joins the typing before or after it.func (e *Editor) Paste(text string) { e.history.Seal() e.run(Insert{e.cursor, text}) e.history.Seal()}
// Backspace removes the character before the cursor, and keeps it so undo can bring it back.func (e *Editor) Backspace() { if e.cursor > 0 { e.run(Delete{e.cursor - 1, e.buffer.Slice(e.cursor-1, e.cursor)}) }}
// MoveCursor keeps the cursor inside the text. Moving the cursor ends the current undo step.func (e *Editor) MoveCursor(to int) { e.cursor = max(0, min(to, e.buffer.Len())) e.history.Seal()}
// Undo reverts the newest step and puts the cursor back where that step began.func (e *Editor) Undo() bool { ed := e.history.Undo() if ed == nil { return false } ed.Revert(e.buffer) e.cursor = ed.CursorAfterRevert() return true}
// Redo does the step again and puts the cursor after it.func (e *Editor) Redo() bool { ed := e.history.Redo() if ed == nil { return false } ed.Apply(e.buffer) e.cursor = ed.CursorAfterApply() return true}
func (e *Editor) Text() string { return e.buffer.Text() }
func (e *Editor) Cursor() int { return e.cursor }
func (e *Editor) run(ed Edit) { ed.Apply(e.buffer) e.cursor = ed.CursorAfterApply() e.history.Record(ed)}
// GapBuffer holds the text in one slice with an empty gap at the cursor. Typing fills the gap// from the left, and backspace widens it, so neither moves any other character. Moving the// cursor moves the gap, copying only the characters between the old and new position. Editors// type at one place for a long time, which is why Emacs uses this.type GapBuffer struct { runes []rune gapStart int // first empty slot gapEnd int // first slot after the gap}
func NewGapBuffer() *GapBuffer { return &GapBuffer{runes: make([]rune, 16), gapEnd: 16} }
func (g *GapBuffer) Insert(at int, text string) { r := []rune(text) g.moveGap(at) if len(r) > g.gapEnd-g.gapStart { g.grow(len(r)) } copy(g.runes[g.gapStart:], r) g.gapStart += len(r)}
// Delete leaves the deleted characters in place, as part of the gap.func (g *GapBuffer) Delete(at, length int) { g.moveGap(at) g.gapEnd += length}
// Slice: positions after the gap are shifted by the gap's width.func (g *GapBuffer) Slice(from, to int) string { out := make([]rune, 0, to-from) for i := from; i < to; i++ { if i < g.gapStart { out = append(out, g.runes[i]) } else { out = append(out, g.runes[i+g.gapEnd-g.gapStart]) } } return string(out)}
func (g *GapBuffer) Len() int { return len(g.runes) - (g.gapEnd - g.gapStart) }
func (g *GapBuffer) Text() string { return string(g.runes[:g.gapStart]) + string(g.runes[g.gapEnd:])}
func (g *GapBuffer) moveGap(at int) { if at < g.gapStart { // move the characters between at and the gap to after the gap n := g.gapStart - at copy(g.runes[g.gapEnd-n:g.gapEnd], g.runes[at:g.gapStart]) g.gapStart -= n g.gapEnd -= n } else if at > g.gapStart { // move the characters after the gap to before it n := at - g.gapStart copy(g.runes[g.gapStart:g.gapStart+n], g.runes[g.gapEnd:g.gapEnd+n]) g.gapStart += n g.gapEnd += n }}
// grow doubles, which keeps growth rare: typing n characters copies the text O(log n) times.func (g *GapBuffer) grow(needed int) { tail := len(g.runes) - g.gapEnd bigger := make([]rune, max(len(g.runes)*2, g.Len()+needed+16)) copy(bigger, g.runes[:g.gapStart]) copy(bigger[len(bigger)-tail:], g.runes[g.gapEnd:]) g.gapEnd = len(bigger) - tail g.runes = bigger}
// Snapshot is what autosave took: the text, and the version it was at.type Snapshot struct { Version int64 Text string}
// SharedEditor is the editor shared with background goroutines. The UI edits; an autosave// goroutine takes a snapshot, writes it to disk with no lock held, then reports which version it// saved. Every call holds one mutex, so a snapshot never sees a gap buffer halfway through// moving its gap.//// The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it// finished, anything typed during the write would be marked saved without being on disk, and// closing the editor would lose it. Recording the saved version means the editor is clean only// while the text is still at that version.type SharedEditor struct { mu sync.Mutex editor *Editor version int64 savedVersion int64}
func NewSharedEditor(editor *Editor) *SharedEditor { return &SharedEditor{editor: editor} }
func (s *SharedEditor) Type(text string) { s.mu.Lock() defer s.mu.Unlock() s.editor.Type(text) s.version++}
func (s *SharedEditor) Paste(text string) { s.mu.Lock() defer s.mu.Unlock() s.editor.Paste(text) s.version++}
func (s *SharedEditor) Backspace() { s.mu.Lock() defer s.mu.Unlock() if s.editor.Cursor() > 0 { s.editor.Backspace() s.version++ }}
func (s *SharedEditor) MoveCursor(to int) { s.mu.Lock() defer s.mu.Unlock() s.editor.MoveCursor(to)}
func (s *SharedEditor) Undo() bool { s.mu.Lock() defer s.mu.Unlock() changed := s.editor.Undo() if changed { s.version++ } return changed}
func (s *SharedEditor) Redo() bool { s.mu.Lock() defer s.mu.Unlock() changed := s.editor.Redo() if changed { s.version++ } return changed}
// Snapshot is taken under the lock, so the text and its version always match.func (s *SharedEditor) Snapshot() Snapshot { s.mu.Lock() defer s.mu.Unlock() return Snapshot{s.version, s.editor.Text()}}
// MarkSaved ignores a save that finishes late, after a newer one, so the saved version never// moves backwards.func (s *SharedEditor) MarkSaved(savedAt int64) { s.mu.Lock() defer s.mu.Unlock() s.savedVersion = max(s.savedVersion, savedAt)}
// Dirty is true while the text is at a different version from the last one saved.func (s *SharedEditor) Dirty() bool { s.mu.Lock() defer s.mu.Unlock() return s.version != s.savedVersion}// The complete design, built from every stage on this page.
// What the keyboard calls. Every change goes through run, which applies the edit, moves the// cursor and records it in the history, all in one place.class Editor { private cursor_ = 0;
constructor( private readonly buffer: TextBuffer, private readonly history: History, ) {}
type(text: string): void { this.run(new Insert(this.cursor_, text)); }
// A paste is one step of its own: it never joins the typing before or after it. paste(text: string): void { this.history.seal(); this.run(new Insert(this.cursor_, text)); this.history.seal(); }
// Removes the character before the cursor, and keeps it so undo can bring it back. backspace(): void { if (this.cursor_ > 0) this.run(new Delete(this.cursor_ - 1, this.buffer.slice(this.cursor_ - 1, this.cursor_))); }
// Kept inside the text. Moving the cursor ends the current undo step. moveCursor(to: number): void { this.cursor_ = Math.max(0, Math.min(to, this.buffer.length)); this.history.seal(); }
// Reverts the newest step and puts the cursor back where that step began. undo(): boolean { const e = this.history.undo(); if (!e) return false; e.revert(this.buffer); this.cursor_ = e.cursorAfterRevert(); return true; }
// Does the step again and puts the cursor after it. redo(): boolean { const e = this.history.redo(); if (!e) return false; e.apply(this.buffer); this.cursor_ = e.cursorAfterApply(); return true; }
get text(): string { return this.buffer.text(); }
get cursor(): number { return this.cursor_; }
private run(e: Edit): void { e.apply(this.buffer); this.cursor_ = e.cursorAfterApply(); this.history.record(e); }}
// What the editor needs from the text, whatever holds it. A faster one can be swapped in// without touching the editor.interface TextBuffer { insert(at: number, text: string): void; delete(at: number, length: number): void; slice(from: number, to: number): string; readonly length: number; text(): string;}
// One change to the text, as an object that can be done and undone (the Command pattern). The// history stores edits, not copies of the text, so a long history stays small.interface Edit { apply(buffer: TextBuffer): void; revert(buffer: TextBuffer): void; // Where the cursor goes after the edit is done (on redo), and after it is undone. cursorAfterApply(): number; cursorAfterRevert(): number; // The edit that does this one and then next, if the two read as one step to a person. mergedWith(next: Edit): Edit | undefined; // Characters held, which is what the history's limit counts. weight(): number;}
// Text typed or pasted at a position. Undoing it deletes the same characters again.class Insert implements Edit { constructor( readonly at: number, readonly text: string, ) {}
apply(b: TextBuffer): void { b.insert(this.at, this.text); }
revert(b: TextBuffer): void { b.delete(this.at, this.text.length); }
cursorAfterApply(): number { return this.at + this.text.length; }
cursorAfterRevert(): number { return this.at; }
weight(): number { return this.text.length; }
// A keystroke right after the last one joins it, until a space or newline ends the word: // "hello world" undoes as "world", then "hello ". mergedWith(next: Edit): Edit | undefined { if (!(next instanceof Insert) || next.at !== this.at + this.text.length || next.text.length !== 1) return undefined; if (/\s$/.test(this.text)) return undefined; return new Insert(this.at, this.text + next.text); }}
// Characters removed at a position. It keeps the removed text, so undo can put it back.class Delete implements Edit { constructor( readonly at: number, readonly removed: string, ) {}
apply(b: TextBuffer): void { b.delete(this.at, this.removed.length); }
revert(b: TextBuffer): void { b.insert(this.at, this.removed); }
cursorAfterApply(): number { return this.at; }
cursorAfterRevert(): number { return this.at + this.removed.length; }
weight(): number { return this.removed.length; }
// A backspace right before the last one joins it: holding backspace is one step. mergedWith(next: Edit): Edit | undefined { if (!(next instanceof Delete) || next.at + next.removed.length !== this.at) return undefined; return new Delete(next.at, next.removed + this.removed); }}
// Merges edits that a person thinks of as one, and keeps at most limit characters of history,// dropping the oldest edits first.class History { private readonly undoStack: Edit[] = []; private redoStack: Edit[] = []; private weight = 0; // characters held on the undo stack private lastEditAt = 0; private sealed = true; // the top edit is closed to merging
constructor( private readonly clock: () => number, private readonly mergeWindowMs: number, private readonly limit: number, ) {}
// Typing after a pause starts a new step, and so does typing after anything that sealed the // last one. record(e: Edit): void { this.redoStack = []; const now = this.clock(); const top = this.undoStack.at(-1); const merged = !this.sealed && top && now - this.lastEditAt <= this.mergeWindowMs ? top.mergedWith(e) : undefined; if (top && merged) { this.undoStack.pop(); this.weight -= top.weight(); e = merged; } this.push(e); this.lastEditAt = now; this.sealed = false; }
// Closes the newest step, so the next edit starts a new one. Moving the cursor, pasting and // undoing all seal: typing after them is a new thought. seal(): void { this.sealed = true; }
// Moves the newest step to the redo stack. Any new edit clears redo, in record. undo(): Edit | undefined { this.seal(); const e = this.undoStack.pop(); if (!e) return undefined; this.weight -= e.weight(); this.redoStack.push(e); return e; }
redo(): Edit | undefined { this.seal(); const e = this.redoStack.pop(); if (e) this.push(e); return e; }
// The newest edit always stays, even alone over the limit; older ones go until it fits. private push(e: Edit): void { this.undoStack.push(e); this.weight += e.weight(); while (this.weight > this.limit && this.undoStack.length > 1) this.weight -= this.undoStack.shift()!.weight(); }}
// A gap buffer: the text in one array with an empty gap at the cursor. Typing fills the gap from// the left, and backspace widens it, so neither moves any other character. Moving the cursor// moves the gap, copying only the characters between the old and new position. Editors type at// one place for a long time, which is why Emacs uses this.class GapBuffer implements TextBuffer { private chars: string[] = new Array(16).fill(''); private gapStart = 0; // first empty slot private gapEnd = 16; // first slot after the gap
insert(at: number, text: string): void { this.moveGap(at); if (text.length > this.gapEnd - this.gapStart) this.grow(text.length); for (const c of text) this.chars[this.gapStart++] = c; }
// The deleted characters are left in place and become part of the gap. delete(at: number, length: number): void { this.moveGap(at); this.gapEnd += length; }
// Positions after the gap are shifted by the gap's width. slice(from: number, to: number): string { let out = ''; for (let i = from; i < to; i++) out += this.chars[i < this.gapStart ? i : i + this.gapEnd - this.gapStart]; return out; }
get length(): number { return this.chars.length - (this.gapEnd - this.gapStart); }
text(): string { return this.chars.slice(0, this.gapStart).join('') + this.chars.slice(this.gapEnd).join(''); }
private moveGap(at: number): void { if (at < this.gapStart) { // move the characters between at and the gap to after the gap const n = this.gapStart - at; this.chars.copyWithin(this.gapEnd - n, at, this.gapStart); this.gapStart -= n; this.gapEnd -= n; } else if (at > this.gapStart) { // move the characters after the gap to before it const n = at - this.gapStart; this.chars.copyWithin(this.gapStart, this.gapEnd, this.gapEnd + n); this.gapStart += n; this.gapEnd += n; } }
// Doubling keeps growth rare: typing n characters copies the text O(log n) times in all. private grow(needed: number): void { const tail = this.chars.slice(this.gapEnd); const size = Math.max(this.chars.length * 2, this.length + needed + 16); const gap = new Array(size - this.gapStart - tail.length).fill(''); this.chars = [...this.chars.slice(0, this.gapStart), ...gap, ...tail]; this.gapEnd = size - tail.length; }}
// What autosave took: the text, and the version it was at.interface Snapshot { version: number; text: string;}
// One thread runs this code, so a snapshot can never see a half-finished edit, and no lock is// needed. Autosave still has a race, because writing to disk awaits: the user keeps typing while// the write is in flight. If save simply cleared a "dirty" flag when the write finished,// anything typed during the write would be marked saved without being on disk, and closing the// editor would lose it. Recording the saved version means the editor is clean only while the// text is still at that version. The Java, Python, C# and Go versions add a lock, because their// autosave runs on another thread.class SharedEditor { private version = 0; private savedVersion = 0;
constructor(private readonly editor: Editor) {}
type(text: string): void { this.editor.type(text); this.version++; }
paste(text: string): void { this.editor.paste(text); this.version++; }
backspace(): void { if (this.editor.cursor === 0) return; this.editor.backspace(); this.version++; }
moveCursor(to: number): void { this.editor.moveCursor(to); }
undo(): boolean { const changed = this.editor.undo(); if (changed) this.version++; return changed; }
redo(): boolean { const changed = this.editor.redo(); if (changed) this.version++; return changed; }
// Taken under the lock, so the text and its version always match. snapshot(): Snapshot { return { version: this.version, text: this.editor.text }; }
// A save that finishes late, after a newer one, mustn't move the saved version backwards. markSaved(savedAt: number): void { this.savedVersion = Math.max(this.savedVersion, savedAt); }
// True while the text is at a different version from the last one saved. get dirty(): boolean { return this.version !== this.savedVersion; }
// Takes a snapshot, waits for the write, then marks only that snapshot's version as saved. async save(write: (text: string) => Promise<void>): Promise<void> { const s = this.snapshot(); await write(s.text); this.markSaved(s.version); }}Three ideas hold it together:
- Nothing copies the document. Edits hold their own characters (Command), the buffer moves only what it must, and only autosave copies the text, once per save.
- Every change goes through an edit. The editor never touches the buffer directly, so
everything the user does can be undone. The screen calls
typeandundo, never the history or the buffer (Facade). - Each rule lives in one place. Edits decide whether they merge. The history decides when merging is allowed and what to forget. The buffer decides how text is stored (Strategy).
Stress it
Section titled “Stress it”So far, one thread uses the editor. Now autosave runs every few seconds on a second thread.
Typing while autosave writes
Section titled “Typing while autosave writes”Autosave runs every few seconds on a background thread and writes the text to disk. The title bar shows a dot when there are unsaved changes. What can go wrong?
Two things. Whether you see that the autosave thread reads the buffer while the screen’s thread changes it. And whether you catch the lost update: typing during the write, then autosave clearing “unsaved” for text that never reached the disk.
Two threads touch the editor. The screen’s thread makes edits. The autosave thread reads the text, writes it to disk, and clears the “unsaved” mark. Two things go wrong without care.
A torn read. A gap buffer in the middle of moving its gap, or growing its array, is in a state that no reader should see. Autosave could write half-moved text to disk. The clearly correct fix is that every call to the editor, edits and reads alike, holds one lock. And the slow disk write happens with no lock held, on a snapshot: a copy of the text, taken under the lock.
A lost update. That fix still leaves a gap. Picture this:
- Autosave takes a snapshot of “draft”.
- While it writes, the user types “!”.
- The write finishes, and autosave clears the “unsaved” mark.
- The title bar now says everything is saved. The user closes the editor, and “!” is gone.
The better fix is a version number. Every edit adds one to it. A snapshot carries the version it was taken at, and autosave reports which version it saved. The editor counts as saved only while its current version still equals the saved one. In our example, the snapshot was version 5, “!” made version 6, so the editor stays unsaved. A save that finishes late, after a newer save, can’t move the saved version backwards.
- Color key
- Database
- Facade
| Option | How | Lost update? | Cost |
|---|---|---|---|
| 1. Lock, snapshot, save the version (our choice) | Copy under the lock, write outside it | No | One copy of the text per save |
| 2. Lock, and write to disk while holding it | Autosave holds the lock for the whole write | No | Typing freezes for as long as the disk takes |
| 3. An “unsaved” flag cleared after the write | No version | Yes | Unsaved text is reported as saved |
| 4. Text that never changes in place | A persistent rope or piece table; snapshots are free | No | A more complex buffer |
// What autosave took: the text, and the version it was at.record Snapshot(long version, String text) {}
// The editor shared with background threads. The UI thread edits; an autosave thread takes a// snapshot, writes it to disk with no lock held, then reports which version it saved. Every// call holds one lock, so a snapshot never sees a gap buffer halfway through moving its gap.//// The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it// finished, anything typed during the write would be marked saved without being on disk, and// closing the editor would lose it. Recording the saved version means the editor is clean only// while the text is still at that version.class SharedEditor { private final Editor editor; private long version; private long savedVersion;
SharedEditor(Editor editor) { this.editor = editor; }
synchronized void type(String text) { editor.type(text); version++; }
synchronized void paste(String text) { editor.paste(text); version++; }
synchronized void backspace() { if (editor.cursor() == 0) return; editor.backspace(); version++; }
synchronized void moveCursor(int to) { editor.moveCursor(to); }
synchronized boolean undo() { boolean changed = editor.undo(); if (changed) version++; return changed; }
synchronized boolean redo() { boolean changed = editor.redo(); if (changed) version++; return changed; }
// Taken under the lock, so the text and its version always match. synchronized Snapshot snapshot() { return new Snapshot(version, editor.text()); }
// A save that finishes late, after a newer one, mustn't move the saved version backwards. synchronized void markSaved(long savedAt) { savedVersion = Math.max(savedVersion, savedAt); }
// True while the text is at a different version from the last one saved. synchronized boolean dirty() { return version != savedVersion; }}# What autosave took: the text, and the version it was at.@dataclass(frozen=True)class Snapshot: version: int text: str
# The editor shared with background threads. The UI thread edits; an autosave thread takes a# snapshot, writes it to disk with no lock held, then reports which version it saved. Every# call holds one lock, so a snapshot never sees a gap buffer halfway through moving its gap.## The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it# finished, anything typed during the write would be marked saved without being on disk, and# closing the editor would lose it. Recording the saved version means the editor is clean only# while the text is still at that version.class SharedEditor: def __init__(self, editor: Editor): self._editor = editor self._lock = threading.Lock() self._version = 0 self._saved_version = 0
def type(self, text: str) -> None: with self._lock: self._editor.type(text) self._version += 1
def paste(self, text: str) -> None: with self._lock: self._editor.paste(text) self._version += 1
def backspace(self) -> None: with self._lock: if self._editor.cursor > 0: self._editor.backspace() self._version += 1
def move_cursor(self, to: int) -> None: with self._lock: self._editor.move_cursor(to)
def undo(self) -> bool: with self._lock: changed = self._editor.undo() self._version += changed return changed
def redo(self) -> bool: with self._lock: changed = self._editor.redo() self._version += changed return changed
# Taken under the lock, so the text and its version always match. def snapshot(self) -> Snapshot: with self._lock: return Snapshot(self._version, self._editor.text)
# A save that finishes late, after a newer one, mustn't move the saved version back. def mark_saved(self, saved_at: int) -> None: with self._lock: self._saved_version = max(self._saved_version, saved_at)
# True while the text is at a different version from the last one saved. @property def dirty(self) -> bool: with self._lock: return self._version != self._saved_version// What autosave took: the text, and the version it was at.public sealed record Snapshot(long Version, string Text);
// The editor shared with background threads. The UI thread edits; an autosave thread takes a// snapshot, writes it to disk with no lock held, then reports which version it saved. Every// call holds one lock, so a snapshot never sees a gap buffer halfway through moving its gap.//// The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it// finished, anything typed during the write would be marked saved without being on disk, and// closing the editor would lose it. Recording the saved version means the editor is clean only// while the text is still at that version.public sealed class SharedEditor(Editor editor){ private readonly Lock _lock = new(); private long _version; private long _savedVersion;
public void Type(string text) { lock (_lock) { editor.Type(text); _version++; } }
public void Paste(string text) { lock (_lock) { editor.Paste(text); _version++; } }
public void Backspace() { lock (_lock) { if (editor.Cursor == 0) return; editor.Backspace(); _version++; } }
public void MoveCursor(int to) { lock (_lock) editor.MoveCursor(to); }
public bool Undo() { lock (_lock) { var changed = editor.Undo(); if (changed) _version++; return changed; } }
public bool Redo() { lock (_lock) { var changed = editor.Redo(); if (changed) _version++; return changed; } }
// Taken under the lock, so the text and its version always match. public Snapshot Snapshot() { lock (_lock) return new Snapshot(_version, editor.Text); }
// A save that finishes late, after a newer one, mustn't move the saved version backwards. public void MarkSaved(long savedAt) { lock (_lock) _savedVersion = Math.Max(_savedVersion, savedAt); }
public bool Dirty { get { lock (_lock) return _version != _savedVersion; } }}// Snapshot is what autosave took: the text, and the version it was at.type Snapshot struct { Version int64 Text string}
// SharedEditor is the editor shared with background goroutines. The UI edits; an autosave// goroutine takes a snapshot, writes it to disk with no lock held, then reports which version it// saved. Every call holds one mutex, so a snapshot never sees a gap buffer halfway through// moving its gap.//// The version is what makes saving correct. If autosave simply cleared a "dirty" flag when it// finished, anything typed during the write would be marked saved without being on disk, and// closing the editor would lose it. Recording the saved version means the editor is clean only// while the text is still at that version.type SharedEditor struct { mu sync.Mutex editor *Editor version int64 savedVersion int64}
func NewSharedEditor(editor *Editor) *SharedEditor { return &SharedEditor{editor: editor} }
func (s *SharedEditor) Type(text string) { s.mu.Lock() defer s.mu.Unlock() s.editor.Type(text) s.version++}
func (s *SharedEditor) Paste(text string) { s.mu.Lock() defer s.mu.Unlock() s.editor.Paste(text) s.version++}
func (s *SharedEditor) Backspace() { s.mu.Lock() defer s.mu.Unlock() if s.editor.Cursor() > 0 { s.editor.Backspace() s.version++ }}
func (s *SharedEditor) MoveCursor(to int) { s.mu.Lock() defer s.mu.Unlock() s.editor.MoveCursor(to)}
func (s *SharedEditor) Undo() bool { s.mu.Lock() defer s.mu.Unlock() changed := s.editor.Undo() if changed { s.version++ } return changed}
func (s *SharedEditor) Redo() bool { s.mu.Lock() defer s.mu.Unlock() changed := s.editor.Redo() if changed { s.version++ } return changed}
// Snapshot is taken under the lock, so the text and its version always match.func (s *SharedEditor) Snapshot() Snapshot { s.mu.Lock() defer s.mu.Unlock() return Snapshot{s.version, s.editor.Text()}}
// MarkSaved ignores a save that finishes late, after a newer one, so the saved version never// moves backwards.func (s *SharedEditor) MarkSaved(savedAt int64) { s.mu.Lock() defer s.mu.Unlock() s.savedVersion = max(s.savedVersion, savedAt)}
// Dirty is true while the text is at a different version from the last one saved.func (s *SharedEditor) Dirty() bool { s.mu.Lock() defer s.mu.Unlock() return s.version != s.savedVersion}// What autosave took: the text, and the version it was at.interface Snapshot { version: number; text: string;}
// One thread runs this code, so a snapshot can never see a half-finished edit, and no lock is// needed. Autosave still has a race, because writing to disk awaits: the user keeps typing while// the write is in flight. If save simply cleared a "dirty" flag when the write finished,// anything typed during the write would be marked saved without being on disk, and closing the// editor would lose it. Recording the saved version means the editor is clean only while the// text is still at that version. The Java, Python, C# and Go versions add a lock, because their// autosave runs on another thread.class SharedEditor { private version = 0; private savedVersion = 0;
constructor(private readonly editor: Editor) {}
type(text: string): void { this.editor.type(text); this.version++; }
paste(text: string): void { this.editor.paste(text); this.version++; }
backspace(): void { if (this.editor.cursor === 0) return; this.editor.backspace(); this.version++; }
moveCursor(to: number): void { this.editor.moveCursor(to); }
undo(): boolean { const changed = this.editor.undo(); if (changed) this.version++; return changed; }
redo(): boolean { const changed = this.editor.redo(); if (changed) this.version++; return changed; }
// Taken under the lock, so the text and its version always match. snapshot(): Snapshot { return { version: this.version, text: this.editor.text }; }
// A save that finishes late, after a newer one, mustn't move the saved version backwards. markSaved(savedAt: number): void { this.savedVersion = Math.max(this.savedVersion, savedAt); }
// True while the text is at a different version from the last one saved. get dirty(): boolean { return this.version !== this.savedVersion; }
// Takes a snapshot, waits for the write, then marks only that snapshot's version as saved. async save(write: (text: string) => Promise<void>): Promise<void> { const s = this.snapshot(); await write(s.text); this.markSaved(s.version); }}Why is this correct? The snapshot’s text and its version are read under the same lock, so they always match. Any edit after the snapshot raises the version above the saved one, so the editor can only look saved when the text on screen is exactly the text that was written.
What does it cost? One copy of the text per save. Copying a few megabytes in memory takes about a millisecond, so typing never notices.
Go deeperHow each language handles it1 min
In Java, Python, C# and Go, autosave runs on another thread, so the lock is needed. TypeScript runs
on one thread, and nothing can interrupt an edit, so it needs no lock. But its write to disk still
waits with await, and typing during that wait is the same lost update. So the version check is
the same in all five languages.
How you’d prove it works
Section titled “How you’d prove it works”The history reads time from a clock the test controls. So a test “types” one character every 100 ms by moving its clock, and makes a pause by moving it five seconds. No test waits in real time. The tests that matter:
- Typing and backspace happen at the cursor, wherever it is.
- Undo and redo put back both the text and the cursor. A new edit clears redo.
- “hello world” undoes in two steps: “world”, then “hello “.
- A pause, a cursor move and a paste each start a new step.
- Three backspaces undo as one step, with the cursor back where it was.
- With a 10-character limit, three 5-character pastes leave two steps that can be undone.
- A gap buffer and a plain string agree after 5,000 random inserts and deletes.
- Undo, redo and backspace with nothing to act on change nothing.
- A save of version 5 doesn’t mark the editor saved after version 6 was typed.
- Snapshots taken while another thread types always hold the start of the typing, and their version always matches their length.
Go deeperThe tests in five languages2 min
The last test fails within a few runs in Java if the lock is removed: a snapshot comes back with a version that doesn’t match its text.
long now = 0; // the test's clock, in millisecondsEditor editor;
@BeforeEachvoid setUp() { editor = new Editor(new GapBuffer(), new History(() -> now, 1000, 1_000_000)); }
// Types one character at a time, 100 ms apart, as a person does.void typeSlowly(String s) { for (char c : s.toCharArray()) { now += 100; editor.type(String.valueOf(c)); }}
@Testvoid editsHappenAtTheCursor() { typeSlowly("helo"); editor.moveCursor(3); editor.type("l"); assertEquals("hello", editor.text()); assertEquals(4, editor.cursor()); editor.moveCursor(99); editor.backspace(); assertEquals("hell", editor.text());}
@Testvoid undoAndRedoRestoreTextAndCursor() { typeSlowly("abc"); editor.moveCursor(1); editor.type("X"); assertTrue(editor.undo()); assertEquals("abc", editor.text()); assertEquals(1, editor.cursor()); assertTrue(editor.redo()); assertEquals("aXbc", editor.text()); assertEquals(2, editor.cursor()); editor.undo(); editor.type("Y"); // a new edit assertFalse(editor.redo()); // clears what could be redone assertEquals("aYbc", editor.text());}
@Testvoid typingAWordIsOneUndoStep() { typeSlowly("hello world"); editor.undo(); assertEquals("hello ", editor.text()); editor.undo(); assertEquals("", editor.text());}
@Testvoid aPauseACursorMoveOrAPasteStartsANewStep() { typeSlowly("ab"); now += 5000; // a pause typeSlowly("c"); editor.moveCursor(0); typeSlowly("d"); editor.moveCursor(4); editor.paste("e"); typeSlowly("f"); for (String expected : List.of("dabce", "dabc", "abc", "ab", "")) { editor.undo(); assertEquals(expected, editor.text()); }}
@Testvoid backspacesMergeIntoOneStep() { typeSlowly("hello"); editor.moveCursor(5); // seal the typing for (int i = 0; i < 3; i++) { now += 100; editor.backspace(); } assertEquals("he", editor.text()); editor.undo(); assertEquals("hello", editor.text()); assertEquals(5, editor.cursor());}
@Testvoid historyKeepsTheNewestEditsWithinItsLimit() { editor = new Editor(new GapBuffer(), new History(() -> now, 1000, 10)); editor.paste("aaaaa"); editor.paste("bbbbb"); editor.paste("ccccc"); // 15 characters of history; the limit is 10 assertTrue(editor.undo()); assertTrue(editor.undo()); assertFalse(editor.undo()); // the first paste was dropped assertEquals("aaaaa", editor.text());}
@Testvoid gapBufferAgreesWithAStringUnderRandomEdits() { Random random = new Random(42); GapBuffer gap = new GapBuffer(); StringBuilder expected = new StringBuilder(); for (int i = 0; i < 5000; i++) { int at = random.nextInt(expected.length() + 1); if (random.nextInt(3) > 0 || expected.length() == at) { String s = "xyz".substring(0, 1 + random.nextInt(3)); gap.insert(at, s); expected.insert(at, s); } else { int n = 1 + random.nextInt(Math.min(3, expected.length() - at)); assertEquals(expected.substring(at, at + n), gap.slice(at, at + n)); gap.delete(at, n); expected.delete(at, at + n); } } assertEquals(expected.toString(), gap.text()); assertEquals(expected.length(), gap.length());}
@Testvoid nothingToUndoOrDeleteChangesNothing() { assertFalse(editor.undo()); assertFalse(editor.redo()); editor.backspace(); assertFalse(editor.undo()); // a backspace at the start recorded nothing assertEquals("", editor.text());}
@Testvoid savingMarksOnlyTheVersionThatWasSaved() { SharedEditor shared = new SharedEditor(editor); shared.type("draft"); Snapshot s = shared.snapshot(); // autosave starts writing "draft" shared.type("!"); // typed while the write is in progress shared.markSaved(s.version()); assertTrue(shared.dirty()); // "!" isn't on disk yet shared.markSaved(shared.snapshot().version()); assertFalse(shared.dirty());}
@Testvoid snapshotsTakenWhileTypingAreAlwaysConsistent() throws Exception { SharedEditor shared = new SharedEditor(editor); String alphabet = "abcdefghijklmnopqrstuvwxyz"; StringBuilder all = new StringBuilder(); for (int i = 0; i < 400; i++) all.append(alphabet); String expected = all.toString(); Thread typist = new Thread(() -> { for (char c : expected.toCharArray()) shared.type(String.valueOf(c)); }); typist.start(); while (typist.isAlive()) { Snapshot seen = shared.snapshot(); assertTrue(expected.startsWith(seen.text())); // never a torn or half-moved buffer assertEquals(seen.version(), seen.text().length()); // the version matches the text it labels } typist.join(); assertEquals(expected, shared.snapshot().text());}class World: def __init__(self, limit: int = 1_000_000): self.now = 0 # the test's clock, in milliseconds self.editor = Editor(GapBuffer(), History(lambda: self.now, 1000, limit))
# Types one character at a time, 100 ms apart, as a person does. def type_slowly(self, s: str) -> None: for c in s: self.now += 100 self.editor.type(c)
def test_edits_happen_at_the_cursor(): w = World() w.type_slowly("helo") w.editor.move_cursor(3) w.editor.type("l") assert w.editor.text == "hello" assert w.editor.cursor == 4 w.editor.move_cursor(99) w.editor.backspace() assert w.editor.text == "hell"
def test_undo_and_redo_restore_text_and_cursor(): w = World() e = w.editor w.type_slowly("abc") e.move_cursor(1) e.type("X") assert e.undo() assert (e.text, e.cursor) == ("abc", 1) assert e.redo() assert (e.text, e.cursor) == ("aXbc", 2) e.undo() e.type("Y") # a new edit assert not e.redo() # clears what could be redone assert e.text == "aYbc"
def test_typing_a_word_is_one_undo_step(): w = World() w.type_slowly("hello world") w.editor.undo() assert w.editor.text == "hello " w.editor.undo() assert w.editor.text == ""
def test_a_pause_a_cursor_move_or_a_paste_starts_a_new_step(): w = World() e = w.editor w.type_slowly("ab") w.now += 5000 # a pause w.type_slowly("c") e.move_cursor(0) w.type_slowly("d") e.move_cursor(4) e.paste("e") w.type_slowly("f") for expected in ["dabce", "dabc", "abc", "ab", ""]: e.undo() assert e.text == expected
def test_backspaces_merge_into_one_step(): w = World() e = w.editor w.type_slowly("hello") e.move_cursor(5) # seal the typing for _ in range(3): w.now += 100 e.backspace() assert e.text == "he" e.undo() assert (e.text, e.cursor) == ("hello", 5)
def test_history_keeps_the_newest_edits_within_its_limit(): e = World(limit=10).editor e.paste("aaaaa") e.paste("bbbbb") e.paste("ccccc") # 15 characters of history; the limit is 10 assert e.undo() assert e.undo() assert not e.undo() # the first paste was dropped assert e.text == "aaaaa"
def test_gap_buffer_agrees_with_a_string_under_random_edits(): rng = random.Random(42) gap = GapBuffer() expected = "" for _ in range(5000): at = rng.randint(0, len(expected)) if rng.randint(0, 2) > 0 or at == len(expected): s = "xyz"[: rng.randint(1, 3)] gap.insert(at, s) expected = expected[:at] + s + expected[at:] else: n = rng.randint(1, min(3, len(expected) - at)) assert gap.slice(at, at + n) == expected[at : at + n] gap.delete(at, n) expected = expected[:at] + expected[at + n :] assert gap.text() == expected assert len(gap) == len(expected)
def test_nothing_to_undo_or_delete_changes_nothing(): e = World().editor assert not e.undo() assert not e.redo() e.backspace() assert not e.undo() # a backspace at the start recorded nothing assert e.text == ""
def test_saving_marks_only_the_version_that_was_saved(): shared = SharedEditor(World().editor) shared.type("draft") s = shared.snapshot() # autosave starts writing "draft" shared.type("!") # typed while the write is in progress shared.mark_saved(s.version) assert shared.dirty # "!" isn't on disk yet shared.mark_saved(shared.snapshot().version) assert not shared.dirty
def test_snapshots_taken_while_typing_are_always_consistent(): shared = SharedEditor(World().editor) expected = "abcdefghijklmnopqrstuvwxyz" * 200
def typist() -> None: for c in expected: shared.type(c)
t = threading.Thread(target=typist) t.start() while t.is_alive(): seen = shared.snapshot() assert expected.startswith(seen.text) # never a torn or half-moved buffer assert seen.version == len(seen.text) # the version matches the text it labels t.join() assert shared.snapshot().text == expectedprivate long _now; // the test's clock, in millisecondsprivate Editor _editor;
public EditorTests() => _editor = NewEditor(1_000_000);
private Editor NewEditor(int limit) => new(new GapBuffer(), new History(() => _now, 1000, limit));
// Types one character at a time, 100 ms apart, as a person does.private void TypeSlowly(string s){ foreach (var c in s) { _now += 100; _editor.Type(c.ToString()); }}
[Fact]public void EditsHappenAtTheCursor(){ TypeSlowly("helo"); _editor.MoveCursor(3); _editor.Type("l"); Assert.Equal("hello", _editor.Text); Assert.Equal(4, _editor.Cursor); _editor.MoveCursor(99); _editor.Backspace(); Assert.Equal("hell", _editor.Text);}
[Fact]public void UndoAndRedoRestoreTextAndCursor(){ TypeSlowly("abc"); _editor.MoveCursor(1); _editor.Type("X"); Assert.True(_editor.Undo()); Assert.Equal(("abc", 1), (_editor.Text, _editor.Cursor)); Assert.True(_editor.Redo()); Assert.Equal(("aXbc", 2), (_editor.Text, _editor.Cursor)); _editor.Undo(); _editor.Type("Y"); // a new edit Assert.False(_editor.Redo()); // clears what could be redone Assert.Equal("aYbc", _editor.Text);}
[Fact]public void TypingAWordIsOneUndoStep(){ TypeSlowly("hello world"); _editor.Undo(); Assert.Equal("hello ", _editor.Text); _editor.Undo(); Assert.Equal("", _editor.Text);}
[Fact]public void APauseACursorMoveOrAPasteStartsANewStep(){ TypeSlowly("ab"); _now += 5000; // a pause TypeSlowly("c"); _editor.MoveCursor(0); TypeSlowly("d"); _editor.MoveCursor(4); _editor.Paste("e"); TypeSlowly("f"); foreach (var expected in new[] { "dabce", "dabc", "abc", "ab", "" }) { _editor.Undo(); Assert.Equal(expected, _editor.Text); }}
[Fact]public void BackspacesMergeIntoOneStep(){ TypeSlowly("hello"); _editor.MoveCursor(5); // seal the typing for (var i = 0; i < 3; i++) { _now += 100; _editor.Backspace(); } Assert.Equal("he", _editor.Text); _editor.Undo(); Assert.Equal(("hello", 5), (_editor.Text, _editor.Cursor));}
[Fact]public void HistoryKeepsTheNewestEditsWithinItsLimit(){ _editor = NewEditor(10); _editor.Paste("aaaaa"); _editor.Paste("bbbbb"); _editor.Paste("ccccc"); // 15 characters of history; the limit is 10 Assert.True(_editor.Undo()); Assert.True(_editor.Undo()); Assert.False(_editor.Undo()); // the first paste was dropped Assert.Equal("aaaaa", _editor.Text);}
[Fact]public void GapBufferAgreesWithAStringUnderRandomEdits(){ var random = new Random(42); var gap = new GapBuffer(); var expected = new StringBuilder(); for (var i = 0; i < 5000; i++) { var at = random.Next(expected.Length + 1); if (random.Next(3) > 0 || at == expected.Length) { var s = "xyz"[..(1 + random.Next(3))]; gap.Insert(at, s); expected.Insert(at, s); } else { var n = 1 + random.Next(Math.Min(3, expected.Length - at)); Assert.Equal(expected.ToString(at, n), gap.Slice(at, at + n)); gap.Delete(at, n); expected.Remove(at, n); } } Assert.Equal(expected.ToString(), gap.Text()); Assert.Equal(expected.Length, gap.Length);}
[Fact]public void NothingToUndoOrDeleteChangesNothing(){ Assert.False(_editor.Undo()); Assert.False(_editor.Redo()); _editor.Backspace(); Assert.False(_editor.Undo()); // a backspace at the start recorded nothing Assert.Equal("", _editor.Text);}
[Fact]public void SavingMarksOnlyTheVersionThatWasSaved(){ var shared = new SharedEditor(_editor); shared.Type("draft"); var s = shared.Snapshot(); // autosave starts writing "draft" shared.Type("!"); // typed while the write is in progress shared.MarkSaved(s.Version); Assert.True(shared.Dirty); // "!" isn't on disk yet shared.MarkSaved(shared.Snapshot().Version); Assert.False(shared.Dirty);}
[Fact]public void SnapshotsTakenWhileTypingAreAlwaysConsistent(){ var shared = new SharedEditor(_editor); var expected = string.Concat(Enumerable.Repeat("abcdefghijklmnopqrstuvwxyz", 400)); var typist = new Thread(() => { foreach (var c in expected) shared.Type(c.ToString()); }); typist.Start(); while (typist.IsAlive) { var seen = shared.Snapshot(); Assert.StartsWith(seen.Text, expected); // never a torn or half-moved buffer Assert.Equal(seen.Version, seen.Text.Length); // the version matches the text it labels } typist.Join(); Assert.Equal(expected, shared.Snapshot().Text);}type world struct { now int64 // the test's clock, in milliseconds editor *Editor}
func newWorld(limit int) *world { w := &world{} w.editor = NewEditor(NewGapBuffer(), NewHistory(func() int64 { return w.now }, 1000, limit)) return w}
// typeSlowly types one character at a time, 100 ms apart, as a person does.func (w *world) typeSlowly(s string) { for _, c := range s { w.now += 100 w.editor.Type(string(c)) }}
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestEditsHappenAtTheCursor(t *testing.T) { w := newWorld(1_000_000) w.typeSlowly("helo") w.editor.MoveCursor(3) w.editor.Type("l") expect(t, "hello", w.editor.Text()) expect(t, 4, w.editor.Cursor()) w.editor.MoveCursor(99) w.editor.Backspace() expect(t, "hell", w.editor.Text())}
func TestUndoAndRedoRestoreTextAndCursor(t *testing.T) { w := newWorld(1_000_000) e := w.editor w.typeSlowly("abc") e.MoveCursor(1) e.Type("X") expect(t, true, e.Undo()) expect(t, "abc", e.Text()) expect(t, 1, e.Cursor()) expect(t, true, e.Redo()) expect(t, "aXbc", e.Text()) expect(t, 2, e.Cursor()) e.Undo() e.Type("Y") // a new edit expect(t, false, e.Redo()) // clears what could be redone expect(t, "aYbc", e.Text())}
func TestTypingAWordIsOneUndoStep(t *testing.T) { w := newWorld(1_000_000) w.typeSlowly("hello world") w.editor.Undo() expect(t, "hello ", w.editor.Text()) w.editor.Undo() expect(t, "", w.editor.Text())}
func TestAPauseACursorMoveOrAPasteStartsANewStep(t *testing.T) { w := newWorld(1_000_000) e := w.editor w.typeSlowly("ab") w.now += 5000 // a pause w.typeSlowly("c") e.MoveCursor(0) w.typeSlowly("d") e.MoveCursor(4) e.Paste("e") w.typeSlowly("f") for _, want := range []string{"dabce", "dabc", "abc", "ab", ""} { e.Undo() expect(t, want, e.Text()) }}
func TestBackspacesMergeIntoOneStep(t *testing.T) { w := newWorld(1_000_000) e := w.editor w.typeSlowly("hello") e.MoveCursor(5) // seal the typing for range 3 { w.now += 100 e.Backspace() } expect(t, "he", e.Text()) e.Undo() expect(t, "hello", e.Text()) expect(t, 5, e.Cursor())}
func TestHistoryKeepsTheNewestEditsWithinItsLimit(t *testing.T) { e := newWorld(10).editor e.Paste("aaaaa") e.Paste("bbbbb") e.Paste("ccccc") // 15 characters of history; the limit is 10 expect(t, true, e.Undo()) expect(t, true, e.Undo()) expect(t, false, e.Undo()) // the first paste was dropped expect(t, "aaaaa", e.Text())}
func TestGapBufferAgreesWithAStringUnderRandomEdits(t *testing.T) { rng := rand.New(rand.NewPCG(42, 42)) gap := NewGapBuffer() want := "" for range 5000 { at := rng.IntN(len(want) + 1) if rng.IntN(3) > 0 || at == len(want) { s := "xyz"[:1+rng.IntN(3)] gap.Insert(at, s) want = want[:at] + s + want[at:] } else { n := 1 + rng.IntN(min(3, len(want)-at)) expect(t, want[at:at+n], gap.Slice(at, at+n)) gap.Delete(at, n) want = want[:at] + want[at+n:] } } expect(t, want, gap.Text()) expect(t, len(want), gap.Len())}
func TestNothingToUndoOrDeleteChangesNothing(t *testing.T) { e := newWorld(1_000_000).editor expect(t, false, e.Undo()) expect(t, false, e.Redo()) e.Backspace() expect(t, false, e.Undo()) // a backspace at the start recorded nothing expect(t, "", e.Text())}
func TestSavingMarksOnlyTheVersionThatWasSaved(t *testing.T) { shared := NewSharedEditor(newWorld(1_000_000).editor) shared.Type("draft") s := shared.Snapshot() // autosave starts writing "draft" shared.Type("!") // typed while the write is in progress shared.MarkSaved(s.Version) expect(t, true, shared.Dirty()) // "!" isn't on disk yet shared.MarkSaved(shared.Snapshot().Version) expect(t, false, shared.Dirty())}
func TestSnapshotsTakenWhileTypingAreAlwaysConsistent(t *testing.T) { shared := NewSharedEditor(newWorld(1_000_000).editor) want := strings.Repeat("abcdefghijklmnopqrstuvwxyz", 200) done := make(chan struct{}) go func() { defer close(done) for _, c := range want { shared.Type(string(c)) } }() for running := true; running; { select { case <-done: running = false default: } seen := shared.Snapshot() if !strings.HasPrefix(want, seen.Text) { // never a torn or half-moved buffer t.Fatalf("snapshot %q is not a prefix of the typing", seen.Text) } expect(t, int64(len(seen.Text)), seen.Version) // the version matches the text it labels } expect(t, want, shared.Snapshot().Text)}import { test } from 'node:test';import assert from 'node:assert/strict';
function world(limit = 1_000_000) { const w = { now: 0, editor: undefined as unknown as Editor }; w.editor = new Editor(new GapBuffer(), new History(() => w.now, 1000, limit)); // Types one character at a time, 100 ms apart, as a person does. const typeSlowly = (s: string) => { for (const c of s) { w.now += 100; w.editor.type(c); } }; return { w, e: w.editor, typeSlowly };}
test('edits happen at the cursor', () => { const { e, typeSlowly } = world(); typeSlowly('helo'); e.moveCursor(3); e.type('l'); assert.equal(e.text, 'hello'); assert.equal(e.cursor, 4); e.moveCursor(99); e.backspace(); assert.equal(e.text, 'hell');});
test('undo and redo restore text and cursor', () => { const { e, typeSlowly } = world(); typeSlowly('abc'); e.moveCursor(1); e.type('X'); assert.equal(e.undo(), true); assert.deepEqual([e.text, e.cursor], ['abc', 1]); assert.equal(e.redo(), true); assert.deepEqual([e.text, e.cursor], ['aXbc', 2]); e.undo(); e.type('Y'); // a new edit assert.equal(e.redo(), false); // clears what could be redone assert.equal(e.text, 'aYbc');});
test('typing a word is one undo step', () => { const { e, typeSlowly } = world(); typeSlowly('hello world'); e.undo(); assert.equal(e.text, 'hello '); e.undo(); assert.equal(e.text, '');});
test('a pause, a cursor move or a paste starts a new step', () => { const { w, e, typeSlowly } = world(); typeSlowly('ab'); w.now += 5000; // a pause typeSlowly('c'); e.moveCursor(0); typeSlowly('d'); e.moveCursor(4); e.paste('e'); typeSlowly('f'); for (const expected of ['dabce', 'dabc', 'abc', 'ab', '']) { e.undo(); assert.equal(e.text, expected); }});
test('backspaces merge into one step', () => { const { w, e, typeSlowly } = world(); typeSlowly('hello'); e.moveCursor(5); // seal the typing for (let i = 0; i < 3; i++) { w.now += 100; e.backspace(); } assert.equal(e.text, 'he'); e.undo(); assert.deepEqual([e.text, e.cursor], ['hello', 5]);});
test('history keeps the newest edits within its limit', () => { const { e } = world(10); e.paste('aaaaa'); e.paste('bbbbb'); e.paste('ccccc'); // 15 characters of history; the limit is 10 assert.equal(e.undo(), true); assert.equal(e.undo(), true); assert.equal(e.undo(), false); // the first paste was dropped assert.equal(e.text, 'aaaaa');});
test('gap buffer agrees with a string under random edits', () => { let seed = 42; const next = (n: number) => ((seed = (seed * 1103515245 + 12345) % 2 ** 31), seed % n); const gap = new GapBuffer(); let expected = ''; for (let i = 0; i < 5000; i++) { const at = next(expected.length + 1); if (next(3) > 0 || at === expected.length) { const s = 'xyz'.slice(0, 1 + next(3)); gap.insert(at, s); expected = expected.slice(0, at) + s + expected.slice(at); } else { const n = 1 + next(Math.min(3, expected.length - at)); assert.equal(gap.slice(at, at + n), expected.slice(at, at + n)); gap.delete(at, n); expected = expected.slice(0, at) + expected.slice(at + n); } } assert.equal(gap.text(), expected); assert.equal(gap.length, expected.length);});
test('nothing to undo or delete changes nothing', () => { const { e } = world(); assert.equal(e.undo(), false); assert.equal(e.redo(), false); e.backspace(); assert.equal(e.undo(), false); // a backspace at the start recorded nothing assert.equal(e.text, '');});
test('saving marks only the version that was saved', () => { const shared = new SharedEditor(world().e); shared.type('draft'); const s = shared.snapshot(); // autosave starts writing "draft" shared.type('!'); // typed while the write is in progress shared.markSaved(s.version); assert.equal(shared.dirty, true); // "!" isn't on disk yet shared.markSaved(shared.snapshot().version); assert.equal(shared.dirty, false);});
test('typing during a slow save leaves the editor dirty', async () => { const shared = new SharedEditor(world().e); shared.type('draft'); let finishWrite!: () => void; const written: string[] = []; const saving = shared.save((text) => { written.push(text); return new Promise<void>((r) => (finishWrite = r)); }); shared.type('!'); // the user keeps typing while the disk is busy finishWrite(); await saving; assert.deepEqual(written, ['draft']); assert.equal(shared.dirty, true); await shared.save(async () => {}); assert.equal(shared.dirty, false);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Backspace at the start | Nothing, and nothing recorded | Editor.backspace |
| Undo or redo with nothing to do | Returns false | History |
| Cursor moved past the end | Kept inside the text | Editor.moveCursor |
| A new edit after undo | Redo is cleared | History.record |
| Typing after a pause, cursor move or paste | A new undo step | History.record, seal |
| A space while typing | The next character starts a new step | Insert.mergedWith |
| One paste larger than the history limit | Kept alone; everything older dropped | History.push |
| Undo the oldest dropped step | Not possible: it was forgotten | History.push |
| Autosave during typing | Writes a consistent snapshot; stays unsaved if typing continued | SharedEditor |
| Two saves finishing out of order | The saved version never goes backwards | markSaved |
| Characters made of several bytes | Positions count characters, not bytes (runes in Go) | TextBuffer |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| What undo stores | Edits holding only what changed | Undo that can’t get an inverse wrong |
| Redo | A second stack, cleared by any new edit | Branches of history (an undo tree) |
| Undo steps | Typing merged by word, pause, cursor move; a paste alone | Letter-by-letter control |
| History size | A limit on characters held; the newest step always kept | A simple step count |
| Text storage | A gap buffer behind a TextBuffer interface |
Fast jumps across huge files (a piece table) |
| Autosave | Snapshot under a lock, write outside it, save by version | One text copy per save |
Go deeperGenuinely different designs, and when to pick them2 min
Snapshots that share almost everything. If the text is a rope or piece table that never changes in place, each version shares almost everything with the last. A “snapshot” is then just a pointer. Undo is keeping old versions, and autosave reads one without a lock. The Memento pattern from stage 1 becomes cheap. Many editors written in functional languages work this way, at the cost of a more complex buffer.
An undo tree. Instead of clearing redo, keep the branch. Undoing and then typing starts a new branch, and the old one stays reachable. Vim and Emacs’s undo-tree do this. Nothing is ever lost, but the user needs a way to see and choose branches.
Operational transformation or CRDTs. When several people edit one document, an edit made against an old version must be adjusted to apply to the current one (OT, as in Google Docs). Or the text must be a structure that gives the same result whatever order edits arrive in (CRDTs, as in Figma and many local-first apps). Undo becomes “undo my last edit”, in a document others have changed since.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Plain text, one cursor, file sizes, saving in the background.
- 3 to 5. The promises. Name “an edit costs the size of the change” as the hard one.
- 5 to 23. Snapshots and their cost, edits as commands on two stacks, merging and the character
limit, then the gap buffer. Code
Delete,History.recordandmoveGap. - 23 to 25. Walk through the full diagram: nothing copies the document, every change is an edit.
- 25 to 30. Autosave: the lock, the snapshot, and the version.
- 30 to 45. Compound edits, then your own questions.
If time is short, skip the gap buffer and spend it on merging and the history limit. Those decide this question for most interviewers.
Follow-up questions
Section titled “Follow-up questions”Users want to select a word and type over it, and “replace all” in a long file. How do those fit the history?
Whether you reach for a compound command: one undo step made of several edits, applied in order and reverted in reverse order.
Most extensions fit into one place:
| Extension | What changes | Touches the history? |
|---|---|---|
| Selections | A selection range; typing over it is a compound delete-then-insert | No |
| Replace all | One compound edit of many parts | No |
| Several cursors | Each keystroke is a compound edit, one part per cursor, back to front | No |
| Undo history saved with the file | Edits saved next to the file, loaded on open | Yes |
| Collaborative editing | Edits adjusted against other people’s edits (OT), or a CRDT | Yes: undo is per user |
| Huge files | A piece table, and loading the file a part at a time | No |
Go deeperAdding compound edits, step by step2 min
Typing over a selection is a delete followed by an insert, and the user expects one undo to bring the selection back. “Replace all” is hundreds of deletes and inserts, also one step. Both are a compound edit. This is the Composite pattern applied to commands.
- Add
CompoundEdit, anEditthat holds a list of edits. To the history, it’s just one more edit. - Apply the parts in order. For typing over “cat” with “dog”: delete “cat”, then insert “dog”.
- Revert the parts in reverse order. First remove “dog”, then put “cat” back. Reverse order matters: each part’s undo must run against the text that part produced. Undoing the delete first would put “cat” back in front of “dog”.
- Its weight is the sum of its parts, so the history limit still works.
- It never merges with typing, so it’s always its own undo step.
For “replace all”, there’s one more detail: apply the replacements from the end of the file backwards. Then each replacement doesn’t shift the positions of the ones still to come.
The history doesn’t change at all. That’s the payoff of making every change an Edit.
Go deeperFour more follow-up questions2 min
Interviewer asksWhy not store the cursor position on the undo stack too?
Each edit already knows where the cursor belongs after it’s applied or reverted, from its own position and length. Storing the cursor separately would repeat that, and it would be wrong as soon as a merge changed the edit.
Interviewer asksShould moving the cursor be undoable?
Usually not: users expect undo to change the text, and stepping back through cursor moves feels
broken. Some editors put the cursor and selection back with each text undo, which this design does
through cursorAfterRevert, and a few have a separate “go back” command for moving around.
Interviewer asksHow do you show 'unsaved' correctly after undoing back to the saved text?
With versions alone, undoing back to the saved text still shows unsaved. To fix it, record the history’s position at the last save, and compare positions. If undo returns to exactly that point, the text matches the disk, which is how most editors behave.
Interviewer asksWhat if an edit fails halfway, say running out of memory while growing?
Apply the edit to the buffer first, and record it only after it succeeds, as run does. A failed
apply leaves the history untouched, so undo never tries to undo something that didn’t happen.
Further reading
Section titled “Further reading”- Command pattern, the shape this design is built on
- Design an in-memory file system, another design where the data structure decides the cost of every operation
- Text Buffer Reimplementation, VS Code blog, why VS Code moved to a piece table
- Data Structures for Text Sequences, Charles Crowley, the classic comparison of gap buffers, piece tables and more
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.