Harlock.TextBuffer (harlock v0.6.0)

Copy Markdown View Source

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
end

Auto-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:

KeyAction
Alt-b / Alt-fmove one word left / right
Ctrl-← / Ctrl-→move one word left / right
Ctrl-a / Ctrl-emove to start / end
Ctrl-wkill the word before the cursor
Alt-dkill the word after the cursor
Ctrl-k / Ctrl-ukill to end / to start
Ctrl-yyank the most recent kill
Ctrl-ddelete 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

cursor()

@type cursor() :: non_neg_integer()

input_event()

@type input_event() :: {:edit, String.t(), cursor()} | :submit | :noop

kill_ring()

@type kill_ring() :: [String.t()]

Most-recent-first list of killed strings. Yank inserts the head.

ring_event()

@type ring_event() :: {:edit, String.t(), cursor(), kill_ring()} | :submit | :noop

Result of apply_key/4 — as input_event/0 but threading the kill ring.

Functions

apply_key(event, value, cursor)

@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

apply_key(arg1, value, cursor, ring)

@spec apply_key({:key, any(), [atom()]}, String.t(), cursor(), kill_ring()) ::
  ring_event()

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
end

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

cursor_column(value, cursor)

@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_backward(value, cursor)

@spec delete_backward(String.t(), cursor()) :: {String.t(), cursor()}

Delete the grapheme before the cursor. No-op at position 0.

delete_forward(value, cursor)

@spec delete_forward(String.t(), cursor()) :: {String.t(), cursor()}

Delete the grapheme after the cursor. No-op at end.

end_(value)

@spec end_(String.t()) :: cursor()

Move cursor to the end (one past the last grapheme).

home()

@spec home() :: cursor()

Move cursor to the start.

insert(value, cursor, str)

@spec insert(String.t(), cursor(), String.t()) :: {String.t(), cursor()}

Insert a string at the cursor. Returns the new value and cursor.

kill_to_bol(value, cursor)

@spec kill_to_bol(String.t(), cursor()) :: {String.t(), cursor(), String.t()}

Delete from the start of the value to the cursor. Returns {value, cursor, killed}.

kill_to_end(value, cursor)

@spec kill_to_end(String.t(), cursor()) :: {String.t(), cursor(), String.t()}

Delete from the cursor to the end of the value. Returns {value, cursor, killed}.

kill_word_backward(value, cursor)

@spec kill_word_backward(String.t(), cursor()) :: {String.t(), cursor(), String.t()}

Delete from the start of the previous word up to the cursor. Returns {value, cursor, killed}.

kill_word_forward(value, cursor)

@spec kill_word_forward(String.t(), cursor()) :: {String.t(), cursor(), String.t()}

Delete from the cursor up to the end of the next word. Returns {value, cursor, killed}.

move_left(cursor)

@spec move_left(cursor()) :: cursor()

Move cursor one grapheme left. Clamps at 0.

move_right(value, cursor)

@spec move_right(String.t(), cursor()) :: cursor()

Move cursor one grapheme right. Clamps at the end.

move_word_left(value, cursor)

@spec move_word_left(String.t(), cursor()) :: cursor()

Move the cursor to the start of the previous word. Clamps at 0.

move_word_right(value, cursor)

@spec move_word_right(String.t(), cursor()) :: cursor()

Move the cursor to the end of the next word. Clamps at the end of the value.

push_kill(ring, text)

@spec push_kill(kill_ring(), String.t()) :: kill_ring()

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.

yank(value, cursor, list)

@spec yank(String.t(), cursor(), kill_ring()) :: {String.t(), cursor()}

Insert the most recent kill at the cursor. An empty ring is a no-op.