A generic undo history, then using it as the state itself
- YT :: https://www.youtube.com/watch?v=YTe-cpDgyKs
- Original title :: Reusable Undo System - Tsoding
A live session on Sowon2, Tsoding's Jai music player. It starts from a tiny annoyance - skipping past a song you wanted and having no Ctrl-Z - and ends with a generic undo data structure reused verbatim for both the playlist and the text edit field, plus the realization that the undo stack can be the editor state rather than a copy of it.
The design question: what happens to an abandoned branch
Drawn out on a tablet: history is a log with a cursor. Undo moves the cursor back, redo forward. The interesting case is adding a new entry while the cursor sits mid-history - you have an alternative timeline. Emacs traverses that tree, some editors expose it, but he decides the branch should just be discarded. That collapses the whole problem to a dynamic array plus an index: truncate to the cursor, push, done.
The add operation goes through a few iterations before landing on the form that handles every edge case at once - set the array count to the cursor, push, then point the cursor at the last element. The empty case initially broke it (a single element got destroyed by its own truncation), fixed after a tea break with a guard that skips the increment when the array is empty. He notes an invariant he only discovered by building it: the history is never empty, because the first song is pushed at startup.
Where to record, and where not to
Instead of recording inside switch_to_song - which undo itself calls - every caller makes a conscious decision:
- Shuffle and in-order playback: record.
- Repeat mode: do not record, or the history fills with the same song.
- Restarting the current song: do not record.
- Invalid/missing songs: record anyway. The player deliberately keeps broken songs in the playlist as an Easter egg, and its error handling returns a playable "missing song" object (10 seconds long, pausable, scrubbable) instead of reporting an error - a Zig-ish approach he says makes everything downstream simpler because no caller has to check.
Ctrl-Z is bound to undo, Ctrl-Shift-Z to redo (Ctrl-R was already restart).
Reusing the same structure for the edit field
The payoff: parameterize the history over the entry type and it works for anything. The edit field's entire state is a 64-byte buffer, a count and a cursor - small enough to snapshot wholesale on every modification rather than storing deltas.
Two Jai features carry the refactor:
- Generic structs, done with a
$parameter on the entry type. usingon a field, which exports a nested struct's members into the enclosing scope. Factoring three fields out into anedit_statestruct would normally mean prefixing every reference across the file;usingmade it a zero-touch change. It also collides amusingly with an existing parameter name, whichusing exceptresolves - he finds the language "self-aware of its own silliness" here.
This also deletes the old previous_buffer hack that served as a bootleg undo (though a remnant stays, since it doubled as a "was this actually modified" signal for resetting the timer).
The quirk, and the idea it leads to
Snapshotting the whole state means the cursor position is part of the snapshot, so undoing jumps the cursor to wherever it was at the very first snapshot - unlike a text editor, which stores deltas. In the footnotes he fixes it by writing the current cursor into the top history entry just before pushing a new one.
That fix suggests something bigger: if you can mutate the top entry, the top entry can simply be the live state. He replaces the editor's state field with history_current() (returning a pointer, exported via using) and introduces history_dupe() - duplicate the top entry and return a pointer to the copy - as the "take a snapshot now" operation. Navigation uses current, mutation uses dupe. The editor no longer holds state separately; the undo stack holds it.
A second-order win: =undo=/=redo= can now return a boolean for "did we actually move" instead of a value, which fixes the playlist restarting the song every time you hit the end of the history.
Operation grouping falls out for free
Finally, the Emacs behaviour of undoing a whole run of typed characters at once. With snapshots under explicit control, grouping is just tracking the last operation kind in an enum - if the last op was insert_character and this one is too, skip the dupe and mutate in place. Same for deletes and backspaces. Word deletion stays ungroupable.
His own caveat: this snapshot-everything approach works because the state is 64-odd bytes. For a real text editor it would not, "but we don't, so it's fine" - better not to build what you don't need.
Side notes
- Renamed the obsolete "hidden/non-hidden song" terminology, now superseded by real playlists.
- His issue tracker stores no dates - it's under version control, so
gitalready knows when and by whom.