Pure helpers for editing a (value, cursor) pair.
The cursor is an integer index into String.graphemes(value) — 0 is
before the first grapheme, length(graphemes) is after the last. This
matches what users expect ("the cursor is between graphemes, not codepoints").
Display column position is a separate concern, computed by cursor_column/2
using Harlock.Width.
These functions are owned by the app's model. The text_input element
is a dumb renderer that reads :value and :cursor out of the model
— no internal state. The app's update/2 calls these helpers to react
to key events.
Typical usage:
def update({:key, key, mods}, model) do
case Harlock.Focus.current() do
:search ->
case TextBuffer.apply_key({:key, key, mods}, model.search, model.search_cursor) do
{:edit, v, c} -> %{model | search: v, search_cursor: c}
:submit -> trigger_search(model)
:noop -> model
end
_ ->
model
end
endAuto-routing (v0.4)
When a text_input element is focused, the runtime routes its keys
through apply_key/3 automatically and delivers the result to
update/2 — the app's clauses get to the point:
text_input(focusable: :search, value: m.search, cursor: m.cursor)
def update({:harlock_edit, :search, {v, c}}, m),
do: %{m | search: v, cursor: c}
def update({:harlock_submit, :search}, m), do: trigger_search(m)Opt out with handle_keys: false; calling apply_key/3 directly
from update/2 continues to work.
Key bindings
Beyond the arrow / Home / End / Backspace / Delete set, apply_key/3
understands the readline-style editing keys. All of them arrive through the
legacy parser — none require the kitty keyboard protocol:
| Key | Action |
|---|---|
Alt-b / Alt-f | move one word left / right |
Ctrl-← / Ctrl-→ | move one word left / right |
Ctrl-a / Ctrl-e | move to start / end |
Ctrl-w | kill the word before the cursor |
Alt-d | kill the word after the cursor |
Ctrl-k / Ctrl-u | kill to end / to start |
Ctrl-y | yank the most recent kill |
Ctrl-d | delete forward |
A word is a run of alphanumeric graphemes; everything else separates.
Focused inputs consume these keys
When a text_input is focused and auto-routing is on, the runtime
consumes the keys above — an app that binds Ctrl-k globally will not see
it while the input has focus. Set handle_keys: false on the element to
keep them. Keys that don't change the value or cursor still fall through,
so an unmodified Ctrl-a on an empty input reaches update/2 as usual.
Kill ring
apply_key/3 threads an empty ring, so kills delete but Ctrl-y does
nothing. Apps wanting yank hold the ring on their model and call
apply_key/4. Undo is deliberately absent: the model owns :value, so it
can be rewritten without this module seeing it, and a buffer-held history
would silently desync.
Summary
Types
Most-recent-first list of killed strings. Yank inserts the head.
Result of apply_key/4 — as input_event/0 but threading the kill ring.
Functions
Map a raw {:key, key, mods} event to an edit. Returns one of
As apply_key/3, but threads a kill ring so Ctrl-Y (yank) works.
Display column corresponding to a cursor index — the sum of display widths of all graphemes before the cursor. Useful for positioning the terminal cursor in the renderer.
Delete the grapheme before the cursor. No-op at position 0.
Delete the grapheme after the cursor. No-op at end.
Move cursor to the end (one past the last grapheme).
Move cursor to the start.
Insert a string at the cursor. Returns the new value and cursor.
Delete from the start of the value to the cursor. Returns {value, cursor, killed}.
Delete from the cursor to the end of the value. Returns {value, cursor, killed}.
Delete from the start of the previous word up to the cursor.
Returns {value, cursor, killed}.
Delete from the cursor up to the end of the next word.
Returns {value, cursor, killed}.
Move cursor one grapheme left. Clamps at 0.
Move cursor one grapheme right. Clamps at the end.
Move the cursor to the start of the previous word. Clamps at 0.
Move the cursor to the end of the next word. Clamps at the end of the value.
Push killed text onto a ring.
Insert the most recent kill at the cursor. An empty ring is a no-op.
Types
@type cursor() :: non_neg_integer()
@type kill_ring() :: [String.t()]
Most-recent-first list of killed strings. Yank inserts the head.
Result of apply_key/4 — as input_event/0 but threading the kill ring.
Functions
@spec apply_key({:key, any(), [atom()]}, String.t(), cursor()) :: input_event()
Map a raw {:key, key, mods} event to an edit. Returns one of:
{:edit, value, cursor}— model should adopt the new pair:submit— user pressed Enter; app handles submission:noop— key isn't part of the text-input vocabulary, ignore
As apply_key/3, but threads a kill ring so Ctrl-Y (yank) works.
Returns {:edit, value, cursor, kill_ring} on an edit. Kill operations push
onto the ring; yank reads its head. Apps that want full emacs editing hold
the ring on their model alongside :value and :cursor:
def update({:key, _, _} = ev, m) do
case TextBuffer.apply_key(ev, m.value, m.cursor, m.ring) do
{:edit, v, c, ring} -> %{m | value: v, cursor: c, ring: ring}
:submit -> submit(m)
:noop -> m
end
endThe runtime's auto-routing (R2) calls apply_key/3, which threads an empty
ring — so kills still delete, but yank is a no-op. Set handle_keys: false
on the element and call this function directly to opt into the ring.
@spec cursor_column(String.t(), cursor()) :: non_neg_integer()
Display column corresponding to a cursor index — the sum of display widths of all graphemes before the cursor. Useful for positioning the terminal cursor in the renderer.
Delete the grapheme before the cursor. No-op at position 0.
Delete the grapheme after the cursor. No-op at end.
Move cursor to the end (one past the last grapheme).
@spec home() :: cursor()
Move cursor to the start.
Insert a string at the cursor. Returns the new value and cursor.
Delete from the start of the value to the cursor. Returns {value, cursor, killed}.
Delete from the cursor to the end of the value. Returns {value, cursor, killed}.
Delete from the start of the previous word up to the cursor.
Returns {value, cursor, killed}.
Delete from the cursor up to the end of the next word.
Returns {value, cursor, killed}.
Move cursor one grapheme left. Clamps at 0.
Move cursor one grapheme right. Clamps at the end.
Move the cursor to the start of the previous word. Clamps at 0.
Move the cursor to the end of the next word. Clamps at the end of the value.
Push killed text onto a ring.
An empty kill is dropped rather than pushed, so a stray Ctrl-k at
end-of-line doesn't shadow the previous kill, and the ring is capped so a
long-lived session doesn't accumulate one indefinitely. Exposed so
Harlock.TextArea can build line-relative kills on the same ring policy.
Insert the most recent kill at the cursor. An empty ring is a no-op.