Harlock.TextArea (harlock v0.4.2)

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

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.

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.

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()

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.

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.

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.