Harlock.TextArea (harlock v0.6.0)

Copy Markdown View Source

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:

KeyAction
Enterinsert a newline (never submits)
/ move one display row, keeping the column where possible
Home / Endstart / end of the current display row
Ctrl-Home / Ctrl-Endstart / 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

cursor()

@type cursor() :: non_neg_integer()

goal_column()

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

position()

@type position() :: {non_neg_integer(), non_neg_integer()}

visual_row()

@type visual_row() :: {cursor(), String.t()}

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.

wrap_width()

@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

apply_key(event, value, cursor)

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

apply_key(event, value, cursor, ring)

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

apply_key(event, value, cursor, ring, width)

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

apply_key(event, value, cursor, ring, width, goal)

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

cursor_at(value, line, column)

@spec cursor_at(String.t(), integer(), integer()) :: cursor()

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.

expand_tabs(value, stop \\ 4)

@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)}
end

Stops 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_newline(value, cursor)

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

Insert a newline at the cursor.

line_count(value)

@spec line_count(String.t()) :: pos_integer()

Number of lines, counting a trailing empty line.

line_end(value, cursor, width \\ nil)

@spec line_end(String.t(), cursor(), wrap_width()) :: cursor()

Cursor at the end of the current display row.

line_home(value, cursor, width \\ nil)

@spec line_home(String.t(), cursor(), wrap_width()) :: cursor()

Cursor at the start of the current display row.

lines(value)

@spec lines(String.t()) :: [String.t()]

Split the value into its lines. A trailing newline yields a final empty line.

move_down(value, cursor, width \\ nil)

@spec move_down(String.t(), cursor(), wrap_width()) :: cursor()

Move one display row down, preserving the display column. No-op on the last row.

move_up(value, cursor, width \\ nil)

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

position(value, cursor)

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

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.

scroll_to_reveal(scroll, value, cursor, height, width \\ nil)

@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

visual_cursor_at(value, row, column, width)

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

visual_position(value, cursor, width)

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

visual_rows(value, width)

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

wrap_line(line, width)

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