// lesson: undo-redo-last-mile
Undo, Redo, and the Last Mile
Undo is a contract with the user's trust: nothing you do here is irreversible. Editors without dependable undo don't get used twice. The mechanics are a solved problem with well-known shape β two stacks and an inverse β but the feel of good undo lives in the details this lesson pins down.
Edits as values
The foundation is representing every text change as a value that carries enough information to run both directions:
struct EditOp {
size_t pos; // where
std::string removed; // what the edit deleted (empty for pure inserts)
std::string inserted; // what it added (empty for pure deletions)
};
Typing X at 5 is {5, "", "X"}. Backspacing an Γ© is {4, "\xc3\xa9", ""}. Pasting over a selection is {pos, old_text, new_text} β one op,
both facts. Applying an op replaces removed with inserted at pos;
the inverse just swaps the two strings, and applying the inverse
lands you byte-for-byte where you started. (Note the piece-table synergy
from lesson 7: the "removed" text is still sitting immutably in a buffer,
so real implementations can store spans instead of copies. We store
strings for clarity; the algebra is identical.)
This is the command pattern without ceremony β no class ICommand, no
virtual Execute(). A struct with three fields is the command, and
inverse() is three swaps. When a pattern reduces to a value type, let
it.
Two stacks and one rule
The engine: an undo stack and a redo stack.
- A fresh edit is recorded: pushed onto undo. And here is the rule users depend on without knowing it: recording clears the redo stack. After undoβundoβtype, the two undone futures are gone; Ctrl+Y must not resurrect them into your new timeline. (Editors that keep those branches β Vim's undo tree β are deliberately exotic.)
- Undo pops an op, pushes it onto redo, and hands back its inverse for the document to apply.
- Redo pops from redo, pushes back onto undo, and hands back the op itself. Undo/redo never record β they shuffle.
Where does the caret go? To the site of the change β after undo, to the
end of the restored text (pos + removed.size()); after redo, to the end
of the re-applied text. Restoring the document but leaving the caret
where it was strands the user staring at an unchanged screen wondering if
anything happened; every mainstream editor warps the caret (and scrolls
to reveal it β lesson 12's function, third customer).
Grouping: undo at the speed of intention
Record one op per keystroke and Ctrl+Z peels off single letters β typing a sentence takes thirty undos to remove. Users think in runs: "undo what I just typed". So consecutive plain insertions coalesce into one op, under conditions that all make sense once stated:
- both ops are pure insertions (
removedempty β typing over a selection starts a fresh group, since that op also carries a deletion); - the new insertion lands exactly at the end of the previous one
(
pos == top.pos + top.inserted.size()) β type, click elsewhere, type, and the position check alone splits the groups; - and nothing else happened in between. The caller passes a
can_mergeflag for this: arrow keys, clicks, undo itself β anything that isn't uninterrupted typing β sets it false for the next record. Real editors also split on pauses and at word boundaries; those are policy tweaks on the same flag.
Merging means appending to the top op's inserted β the undo stack
doesn't grow. One Ctrl+Z, one burst of typing gone. That's the feel.
The last mile
Four topics belong in your real build-out of the editor; none can be graded headless, all deserve a map before you go:
- High DPI. A "pixel" in window coordinates may be 2Γ2 physical
pixels. Each platform tells you the scale factor (
Xft.dpiresource /WM_DPICHANGED/backingScaleFactor); render your framebuffer at physical size and multiply all font metrics by the scale β with a bitmap font, integer-scale the glyphs (8Γ16 β 16Γ32 looks crisp and period-correct; fractional scales are where you graduate to FreeType). The trap: mixing logical mouse coordinates with physical framebuffer coordinates β pick one space for the editor core (logical) and convert at the seam, in exactly one place. - File watching. When the file changes on disk, offer to reload. On
Linux that's
inotifyβ a file descriptor you add to the samepoll()as the X connection: the event loop gains a second input, not a thread. Watch the directory, not the file: most programs (and your own safe-save below) replace files by rename, which silently orphans a file-handle watch. Coalesce the burst of events a single save produces (your lesson-1pumplogic, fourth customer). - IME. For Chinese, Japanese, Korean β and dead-key composition
everywhere β text arrives through a composition dialogue
(preedit-draw, commit), not as keystrokes: one more reason the core's
text-entry API is
insert_text(string)and never "handle key". Wire X11's XIM (or ibus), Windows'WM_IME_*, Cocoa'sNSTextInputClientto the same two calls: draw preedit, commit text. - Saving safely. Never truncate-and-write the user's file β a crash
mid-write destroys both versions. Write to a temp file in the same
directory,
fsync, thenrenameover the target: POSIX rename is atomic, so the file is always either old or new, never half. (The fine print β preserving permissions/ownership, symlink targets, hardlink identity β is why "safe save" options exist in every serious editor's manual.)
βΊ Grouped Undo
15 ptsImplement the op algebra and the two-stack engine with coalescing.
apply_op(text, op): replace theop.removed.size()bytes atop.pos(guaranteed to equalop.removed) withop.inserted.inverse(op): same position, strings swapped.UndoStack::record(op, can_merge): clears redo. Merges into the top undo entry iffcan_merge, the top exists, both ops are pure insertions, andop.pos == top.pos + top.inserted.size(); otherwise pushes.undo()/redo():std::nulloptwhen their stack is empty; otherwise move the op across and return the op to apply (the inverse for undo, the original for redo).undo_depth()/redo_depth()for the tests.
Log in to submit a solution and earn points.