Bounded undo/redo history for a (value, cursor) pair, held by the app.
Why the app holds it
It cannot live inside Harlock.TextBuffer or Harlock.TextArea. The app model
owns :value and is free to rewrite it without the widget seeing — loading a
file, clearing a form, applying a Cmd result. A history kept inside the
widget would drift out of sync with the truth and start restoring text the user
never typed. So the stack is a plain value the app keeps in its model and feeds
explicitly:
def update({:harlock_edit, :body, {value, cursor}}, m) do
%{m | body: value, cursor: cursor, undo: UndoStack.record(m.undo, {value, cursor})}
end
def update({:key, {:char, ?z}, [:ctrl]}, m) do
case UndoStack.undo(m.undo) do
{:ok, {value, cursor}, undo} -> %{m | body: value, cursor: cursor, undo: undo}
:error -> m
end
endBecause the app decides when to record/2, a programmatic rewrite can simply
skip it — or call reset/2 to make the new content the new baseline.
Snapshots, not commands
Each entry is a whole {value, cursor} pair. For buffers the size a terminal
edits, copying the string is cheaper than the bookkeeping an inverse-command
stack needs, and it cannot get out of step with the text.
The ring is capped (default 100 committed entries); the oldest are dropped. An
edit still in progress is held separately and adds at most one more step, so
depth/1 can read one above the limit while you are mid-word.
Coalescing is contract, not implementation
A run of insertions collapses into one undo step, so typing a word and pressing undo removes the word rather than the last letter. That run breaks on:
- a newline — paragraphs are natural units, and one undo should not swallow several of them
- a cursor jump — moving somewhere else ends the edit you were making
- a delete after an insert — changing direction starts a new step
These are guarantees, not tuning. Coalescing too eagerly is worse than having no undo at all: a user who types a paragraph, presses undo expecting to lose a word, and loses the paragraph has had work destroyed rather than merely not restored.
Summary
Functions
Whether redo/1 would succeed.
Whether undo/1 would succeed.
Number of undo steps available, counting an in-progress run as one.
A new stack.
Record the state after an edit.
Step forward one entry, undoing an undo.
Make snapshot the new baseline, discarding all history.
Step back one entry.
Types
@type kind() :: :insert | :delete | :newline | :motion
@type snapshot() :: {String.t(), non_neg_integer()}
Functions
Whether redo/1 would succeed.
Whether undo/1 would succeed.
@spec depth(t()) :: non_neg_integer()
Number of undo steps available, counting an in-progress run as one.
Intended for status lines and tests rather than control flow.
A new stack.
Options: :limit (default 100) caps committed entries, and
:from seeds the baseline so the first edit has something to undo to.
Record the state after an edit.
Classifies the transition since the last recorded state and either extends the current run or closes it and opens a new one. Recording an unchanged state is a no-op, so it is safe to call on every message.
Step forward one entry, undoing an undo.
Returns :error when there is nothing to redo. Any fresh edit clears the redo
history — the branch it belonged to no longer exists.
Make snapshot the new baseline, discarding all history.
For when the app replaces the content outright — opening a different file — where undoing back into the previous document would be wrong.
Step back one entry.
Returns {:ok, snapshot, stack} with the state to restore, or :error when
there is nothing left. An in-progress run is its own nearest target, so undo
works mid-typing without waiting for the run to close.