// 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 (removed empty β€” 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_merge flag 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.dpi resource / 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 same poll() 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-1 pump logic, 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's NSTextInputClient to 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, then rename over 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 pts

Implement the op algebra and the two-stack engine with coalescing.

  • apply_op(text, op): replace the op.removed.size() bytes at op.pos (guaranteed to equal op.removed) with op.inserted.
  • inverse(op): same position, strings swapped.
  • UndoStack::record(op, can_merge): clears redo. Merges into the top undo entry iff can_merge, the top exists, both ops are pure insertions, and op.pos == top.pos + top.inserted.size(); otherwise pushes.
  • undo() / redo(): std::nullopt when 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.