ke's undo system
Working on ke; I am trying to build out an undo system. It might be the last major thing before I focus on performance improvements.
The grand idea
undo_node_freedestroys an entire timeline, including children and next.undo_node_free_branchonly discards next.undo_discard_redo_brancheskills child and next.
Basic invariants of the undo system:
- root->parent == NULL
- root->current is reachable from root via repeated child walk
- saved is NULL or reachable the same way
- pending is either NULL or a brand-new node not yet linked
- when we commit, pending becomes current->child and current moves forward
- when we undo, current = current->parent
- when we type after undo, we free current->child (redo branch) first
Or, visually,
root ──> N1 ──> N2 ──> N3 ──> N4* ──> N5 ──> N6 (main timeline)
^ ^ ^
| | |
saved pending current
- root : first edit ever, never has a parent
- current : where we are right now in history
- saved : points to the node that matches the on-disk file
- pending : temporary node being built (committed → becomes current->child)
If I do a double undo then type something:
root ──> N1 ──> N2 ──> N3* ──> N4 ──> N5 (old N4→N5→N6 discarded)
^ ^
| |
current pending (new edit)
|
saved
All four pointers point into the same tree → and should only be memory managed via the root node.
The lifecycle looks something like:
undo_begin(UNDO_INSERT)- creates a brand-new
undo_node - stores row/col at that moment
- puts it in editor.tree->pending
- tree->current is still pointing at the previous node
- nothing is linked yet
- creates a brand-new
- Start typing…
undo_append_char('h')undo_append_char('e')undo_append_char('l')undo_append_char('l')undo_append_char('o')- all five bytes go into pending->text abuf
- still completely detached from the tree
- cursor moves \(->\)
undo_commit()is calledundo_commit()does the real linking:- discards any existing redo branches (current->child = NULL)
- sets current->child = pending
- moves tree->current = pending
- clears tree->pending = NULL
- now the node is officially part of the tree
Before undo_begin():
root ──> A ──> B ──> C* ← tree->current = C
pending = NULL
After undo_begin(UNDO_INSERT):
root ──> A ──> B ──> C* ← tree->current still C
pending ──> [new node, type=INSERT, text=""]
After typing "hello" + undo_commit():
root ──> A ──> B ──> C ──> D* ← tree->current now D
← pending = NULL
D->text = "hello"
Some function definitions
/* Discard all redo branches starting at 'from' */ void undo_discard_redo_branches(struct undo_node *from)
- Called right before you attach a new node after an undo.
- Frees
from->childand everything reachable via->childand->next. - Leaves
fromitself untouched. - Typical call:
undo_discard_redo_branches(editor.tree->current);
/* Return the parent of a node (O(depth) walk – fine, depth is tiny) */ undo_node_t *undo_parent_of(undo_node_t *n)
- Walks backward from
rootfollowing->childlinks until it finds the node whose->child==n. - Returns NULL if
nis the root. - Used only by
editor_redo()(to movecurrentforward) and optionally byeditor_undo()if you don't store->parent.
/* User-facing undo – ^_ or ^X u */ void editor_undo(void)
- If nothing to undo (
current == root), do nothing + status "No further undo". - Otherwise:
- Reverse-apply the operation stored in
current. - Move
current = parent_of(current)(or stored->parent). display_refresh();
- Reverse-apply the operation stored in
- Must work whether the last action was insert, delete, newline, delete-row, paste, etc.
/* User-facing redo – ^X r or ^_ again */ void editor_redo(void)
- If
current->child == NULL, nothing to redo → status "No further redo". - Otherwise:
- Move
current = current->child. - Forward-apply the operation stored in the new
current. display_refresh();
- Move
/* Called immediately after successful save_file() */ void undo_mark_saved(void)
- Simply does:
editor.tree->saved = editor.tree->current; - Used so that:
editor.dirty = 0can be restored correctly- status bar can show "clean" when
current == saved
/* How many undo steps back from current? */ int undo_depth(const undo_tree_t *t)
- Returns number of steps you can still undo.
- Walk from
currenttorootcounting links. - Mainly for status line:
U3means three undos available.
/* Can we undo right now? */ int undo_can_undo(const undo_tree_t *t)
- Returns
(t->current !t->root && t->current != NULL)= - Used to dim/grey the undo menu item if you ever add one.
/* Can we redo right now? */ int undo_can_redo(const undo_tree_t *t)
- Returns
(t->current !NULL && t->current->child != NULL)= - Symmetric to
undo_can_undo().
/* Debug helper – prints the whole tree */ void undo_tree_debug_dump(const undo_tree_t *t)
Recursively walks the tree and prints something like:
[root] ──> [I "hello "] ──> [I "world"]* ──> [D 1] current └─> [I "there"] redo branch saved @ "hello world"- Incredibly useful when you enable
-ddebugging.
Call-site cheat sheet
| Situation | Functions called (in order) |
|---|---|
| User types a character | undo_begin(UNDO_INSERT) → undo_append_char() → undo_commit() on cursor move |
| User presses Backspace/Delete | same, but UNDO_DELETE |
| User undoes (^_) | editor_undo() |
| User redoes (X r) | editor_redo() |
| User saves file successfully | undo_mark_saved(); editor.dirty = 0; |
| User types after undo | undo_discard_redo_branches(current); then normal commit |
| Editor starts / new file | editor.tree = undo_tree_new(); |
| Editor exits / file closed | undo_tree_free(editor.tree); |
That's literally everything you need. Once these nine functions are implemented, your undo system is **complete, bulletproof, and better than 99 % of editors out there.
Happy undoing!