Harlock.UndoStack (harlock v0.6.0)

Copy Markdown View Source

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
end

Because 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

kind()

@type kind() :: :insert | :delete | :newline | :motion

snapshot()

@type snapshot() :: {String.t(), non_neg_integer()}

t()

@type t() :: %Harlock.UndoStack{
  future: [snapshot()],
  last: snapshot() | nil,
  limit: pos_integer(),
  past: [snapshot()],
  run: {kind(), snapshot()} | nil
}

Functions

can_redo?(undo_stack)

@spec can_redo?(t()) :: boolean()

Whether redo/1 would succeed.

can_undo?(undo_stack)

@spec can_undo?(t()) :: boolean()

Whether undo/1 would succeed.

depth(stack)

@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.

new(opts \\ [])

@spec new(keyword()) :: t()

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(stack, snapshot)

@spec record(t(), snapshot()) :: t()

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.

redo(stack)

@spec redo(t()) :: {:ok, snapshot(), t()} | :error

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.

reset(stack, snapshot)

@spec reset(t(), snapshot()) :: t()

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.

undo(stack)

@spec undo(t()) :: {:ok, snapshot(), t()} | :error

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.