Pure helpers for editing a multi-line (value, cursor) pair.
The value is an ordinary binary whose lines are separated by \n, and the
cursor is a flat grapheme index into it — the same representation
Harlock.TextBuffer uses for a single line. Keeping the cursor flat rather
than a {row, column} pair means cross-line motion falls out for free:
moving left from the start of a line lands on the newline that ends the
previous one, and deleting there joins the two lines. It also lets the
runtime deliver textarea edits through the same
{:harlock_edit, id, {value, cursor}} message a text_input uses.
Horizontal editing — insert, delete, word motion, kills, yank — is
delegated to Harlock.TextBuffer, so both widgets share one implementation
and one set of key bindings. This module adds only what multi-line editing
needs: vertical motion, line-relative Home / End, and Enter inserting a
newline instead of submitting.
Key bindings
Everything Harlock.TextBuffer understands, plus:
| Key | Action |
|---|---|
Enter | insert a newline (never submits) |
↑ / ↓ | move one display row, keeping the column where possible |
Home / End | start / end of the current display row |
Ctrl-Home / Ctrl-End | start / end of the whole value |
Wrapping
Pass a width to apply_key/5 (or set wrap: true on the element, which
makes the runtime pass the rendered width for you) and long lines break
across display rows. Motion then follows those rows: ↓ inside a wrapped
paragraph moves to the next visual row rather than past the whole paragraph.
Without a width every "display row" is a logical line, so one code path
serves both.
Kills stay logical-line relative even when wrapping — Ctrl-k kills to the
end of the line, not to the next wrap boundary, because that boundary isn't
in the text.
Columns are counted in display cells rather than graphemes throughout, so vertical motion through CJK text lands where it looks like it should, and wrapping breaks where the text actually reaches the edge.
Vertical motion clamps the column to the target row's width. There is no goal-column memory, so moving down through a short row and on to a long one leaves the cursor at the short row's width rather than restoring the original column.
Summary
Types
Display column a run of vertical motions is aiming for, or nil when no run
is in progress.
A wrapped display row: the flat cursor index where the row begins, and the text on it. Segments concatenate back to the original value exactly, so a flat cursor maps onto them without loss.
Width to wrap at, or nil for no wrapping. nil makes every motion
logical-line based; a positive integer makes it display-row based.
Functions
Map a raw {:key, key, mods} event to an edit.
As apply_key/3, but threads a kill ring so Ctrl-y works. See
Harlock.TextBuffer.apply_key/4.
As apply_key/4, but wraps at wrap_width columns.
As apply_key/5, but carries a goal_column/0 so a run of ↑ / ↓
keeps aiming at the column it started from.
Translate a {line, column} pair back into a flat cursor. Both coordinates
clamp into range, so callers can pass an over-long column and get the end of
the line.
Replace tabs with spaces, advancing each tab to the next multiple of stop.
Insert a newline at the cursor.
Number of lines, counting a trailing empty line.
Cursor at the end of the current display row.
Cursor at the start of the current display row.
Split the value into its lines. A trailing newline yields a final empty line.
Move one display row down, preserving the display column. No-op on the last row.
Move one display row up, preserving the display column. No-op on the first row.
Translate a flat cursor into {line, column}, both zero-based and measured
in graphemes. Out-of-range cursors clamp to the end of the value.
Adjust a scroll offset by the minimum needed to keep the cursor's line
visible in a viewport height lines tall.
Translate a {display_row, display_column} pair back into a flat cursor.
Both coordinates clamp into range.
Translate a flat cursor into {display_row, display_column}.
All display rows for a value, as {flat_cursor_at_row_start, text}.
Break one logical line into display rows no wider than width columns.
Types
@type cursor() :: non_neg_integer()
@type goal_column() :: non_neg_integer() | nil
Display column a run of vertical motions is aiming for, or nil when no run
is in progress.
Vertical motion clamps the cursor to the target row, so deriving the column from the cursor on every keypress lets a short row truncate it permanently — down three rows and back up three lands left of where it started. Carrying the goal across the run fixes that: the clamp affects where the cursor sits, never what the next motion aims for.
nil means "derive from the cursor," which is what every non-vertical key
resets it to. Resetting lazily rather than recomputing the column on each
insertion matters: the column costs a full rewrap to compute, and paying
that per keystroke is the cost vertical motion alone should bear.
@type position() :: {non_neg_integer(), non_neg_integer()}
A wrapped display row: the flat cursor index where the row begins, and the text on it. Segments concatenate back to the original value exactly, so a flat cursor maps onto them without loss.
@type wrap_width() :: pos_integer() | nil
Width to wrap at, or nil for no wrapping. nil makes every motion
logical-line based; a positive integer makes it display-row based.
Functions
@spec apply_key({:key, any(), [atom()]}, String.t(), cursor()) :: {:edit, String.t(), cursor()} | :noop
Map a raw {:key, key, mods} event to an edit.
Returns {:edit, value, cursor} or :noop. Unlike
Harlock.TextBuffer.apply_key/3 there is no :submit — Enter inserts a
newline, so a textarea never submits on its own.
@spec apply_key( {:key, any(), [atom()]}, String.t(), cursor(), Harlock.TextBuffer.kill_ring() ) :: {:edit, String.t(), cursor(), Harlock.TextBuffer.kill_ring()} | :noop
As apply_key/3, but threads a kill ring so Ctrl-y works. See
Harlock.TextBuffer.apply_key/4.
@spec apply_key( {:key, any(), [atom()]}, String.t(), cursor(), Harlock.TextBuffer.kill_ring(), wrap_width() ) :: {:edit, String.t(), cursor(), Harlock.TextBuffer.kill_ring()} | :noop
As apply_key/4, but wraps at wrap_width columns.
With a width, ↑ / ↓ and Home / End act on display rows rather than
logical lines — pressing ↓ inside a wrapped paragraph moves to the next
visual row, not past the whole paragraph. Ctrl-k still kills to the end of
the logical line: killing only to a wrap boundary would be surprising,
since the boundary isn't in the text.
The runtime passes the textarea's rendered width automatically when the
element sets wrap: true.
@spec apply_key( {:key, any(), [atom()]}, String.t(), cursor(), Harlock.TextBuffer.kill_ring(), wrap_width(), goal_column() ) :: {:edit, String.t(), cursor(), Harlock.TextBuffer.kill_ring(), goal_column()} | :noop
As apply_key/5, but carries a goal_column/0 so a run of ↑ / ↓
keeps aiming at the column it started from.
Returns {:edit, value, cursor, ring, goal_column} — the extra element is the
goal to hand back on the next call. Vertical motion preserves it (deriving it
from the cursor when it arrives as nil); every other key resets it to nil,
which is what makes the next vertical run start from wherever editing left
the cursor.
The runtime threads this automatically for a focused textarea, so apps
relying on R2 auto-routing get goal-column behaviour without holding the
column themselves. apply_key/5 and below stay goal-less: a single motion is
unaffected, and drift only appears across a run that a caller would have to
hold state for anyway.
Translate a {line, column} pair back into a flat cursor. Both coordinates
clamp into range, so callers can pass an over-long column and get the end of
the line.
@spec expand_tabs(String.t(), pos_integer()) :: String.t()
Replace tabs with spaces, advancing each tab to the next multiple of stop.
A tab is zero-width as far as Harlock.Width is concerned — every codepoint
below 0x20 is — so a \t left in a value occupies a cursor index while
consuming no display cell. Wrapping does not account for it, and every index
after it maps one cell short of where it renders.
The keyboard cannot produce one: the runtime consumes Tab for focus
traversal before a widget sees it. Tabs arrive from bracketed paste, which
reaches update/2 as {:paste, text}, or from an app assigning :value
directly. Both are app-side, so normalising is app-side too:
def update({:paste, text}, model) do
clean = Harlock.TextArea.expand_tabs(text)
%{model | body: TextBuffer.insert(model.body, model.cursor, clean)}
endStops are measured in display cells, so a tab following 日 advances from
column 2, not column 1.
Columns are counted from the start of each line of the string given, not from wherever it is about to be spliced. Pasting mid-line can therefore produce stops that do not align with the destination line's columns; the tabs are still gone, which is the property that matters for rendering.
Insert a newline at the cursor.
@spec line_count(String.t()) :: pos_integer()
Number of lines, counting a trailing empty line.
@spec line_end(String.t(), cursor(), wrap_width()) :: cursor()
Cursor at the end of the current display row.
@spec line_home(String.t(), cursor(), wrap_width()) :: cursor()
Cursor at the start of the current display row.
Split the value into its lines. A trailing newline yields a final empty line.
@spec move_down(String.t(), cursor(), wrap_width()) :: cursor()
Move one display row down, preserving the display column. No-op on the last row.
@spec move_up(String.t(), cursor(), wrap_width()) :: cursor()
Move one display row up, preserving the display column. No-op on the first row.
With a width the row is a wrapped segment; without one it is a logical
line. Either way the column is preserved in display cells rather than
graphemes, so moving up a column of CJK text lands where it looks like it
should.
Translate a flat cursor into {line, column}, both zero-based and measured
in graphemes. Out-of-range cursors clamp to the end of the value.
@spec scroll_to_reveal(integer(), String.t(), cursor(), integer(), wrap_width()) :: non_neg_integer()
Adjust a scroll offset by the minimum needed to keep the cursor's line
visible in a viewport height lines tall.
The renderer applies this itself, so a textarea always draws its cursor even
when the app pins :scroll to 0 — but an app that never tracks scroll keeps
the cursor pinned to the last visible row once the value grows past one
screen. Threading the result back onto the model gives the usual behaviour
where the view only moves when the cursor would leave it:
def update({:harlock_edit, :body, {v, c}}, m) do
%{m | body: v, cursor: c, scroll: TextArea.scroll_to_reveal(m.scroll, v, c, m.body_h)}
end
@spec visual_cursor_at(String.t(), integer(), integer(), wrap_width()) :: cursor()
Translate a {display_row, display_column} pair back into a flat cursor.
Both coordinates clamp into range.
@spec visual_position(String.t(), cursor(), wrap_width()) :: position()
Translate a flat cursor into {display_row, display_column}.
The column is in display cells, not graphemes, so it can be handed straight to the renderer. A cursor sitting exactly on a wrap boundary reports the start of the following row, which is where a caret belongs after typing up to the edge.
@spec visual_rows(String.t(), wrap_width()) :: [visual_row()]
All display rows for a value, as {flat_cursor_at_row_start, text}.
With nil width this is just the logical lines, so callers can use one code
path whether or not wrapping is on.
@spec wrap_line(String.t(), pos_integer()) :: [String.t()]
Break one logical line into display rows no wider than width columns.
Breaks at the last space that fits, keeping that space on the preceding row
so the rows concatenate back to the input exactly — that property is what
lets a flat cursor index map onto wrapped rows without a separate
translation table. A word longer than width is broken mid-word rather than
overflowing.
Width is measured in display columns via Harlock.Width, so a CJK grapheme
costs two. A width too small to fit even one grapheme still consumes one
per row rather than looping forever; the renderer clips the overflow.