Selkie.git | docs/api/ | Selkie--Widget--MultiLineInput.md
NAME
====
Selkie::Widget::MultiLineInput - Multi-line text input with word-wrap and 2D cursor
SYNOPSIS
========
```raku
use Selkie::Widget::MultiLineInput;
use Selkie::Sizing;
my $area = Selkie::Widget::MultiLineInput.new(
sizing => Sizing.fixed(1), # grows up to max-lines as user types
max-lines => 6,
placeholder => 'Type a message... (Ctrl+Enter to send)',
);
$area.on-submit.tap: -> $text { send-message($text); $area.clear };
$area.on-change.tap: -> $text { save-draft($text) };
# ...or the chat binding: Enter sends, Alt+Enter breaks the line
$area.enter-submits = True;
```
DESCRIPTION
===========
A multi-line text area with word-wrapping, a 2D cursor, and dynamic height that grows as the user types (up to `max-lines`). By default plain `Enter` inserts a newline and `Ctrl+Enter` submits; set `enter-submits` to swap that round for chat-style composition — see **Submit-key modes** below.
The height auto-adjusts via `desired-height`: if you pass `sizing =` Sizing.fixed(1)>, the parent layout sees the widget's desired height grow as content is added, bounded by `max-lines`.
`set-text-silent` updates the buffer without emitting `on-change` — use this from store subscriptions to avoid feedback loops. Both `set-text` and `set-text-silent` normalise CRLF and lone CR to LF before splitting, so text loaded from Windows-authored files (e.g. CCv3 cards) does not strand a `\r` inside each line.
Submit-key modes
----------------
The `enter-submits` attribute (default `False`) picks which of the two conventions the widget follows. It is a plain writable attribute, so an app can bind it to a user preference and flip it at runtime.
**Default** — `enter-submits` is `False`, the editor convention:
<table class="pod-table">
<thead><tr>
<th>Key</th> <th>Effect</th>
</tr></thead>
<tbody>
<tr> <td>Enter</td> <td>insert a newline</td> </tr> <tr> <td>Shift+Enter</td> <td>insert a newline</td> </tr> <tr> <td>Alt+Enter</td> <td>insert a newline</td> </tr> <tr> <td>Ctrl+Enter</td> <td>SUBMIT — emit the buffer on on-submit</td> </tr>
</tbody>
</table>
**Chat mode** — `enter-submits` is `True`:
<table class="pod-table">
<thead><tr>
<th>Key</th> <th>Effect</th>
</tr></thead>
<tbody>
<tr> <td>Enter</td> <td>SUBMIT — emit the buffer on on-submit</td> </tr> <tr> <td>Alt+Enter</td> <td>insert a newline (the portable one)</td> </tr> <tr> <td>Shift+Enter</td> <td>insert a newline, where the terminal reports it</td> </tr> <tr> <td>Ctrl+Enter</td> <td>insert a newline, where the terminal reports it</td> </tr>
</tbody>
</table>
In both modes the newline path replaces the active selection first, exactly as typing a character does, and the keystroke is always consumed — it never bubbles to a parent or to a global keybind. Submitting emits the buffer verbatim (logical lines joined with `\n`) and does **not** clear the buffer; do that yourself in the tap, as the example below does, so a failed send can keep the user's text.
### Why Alt+Enter is the portable newline
A terminal can only report a modifier on `Enter` if its keyboard encoding has room to say so. Three encodings are in play:
* The **kitty keyboard protocol** (kitty, foot, ghostty, WezTerm, recent Alacritty) reports Shift, Ctrl and Alt on every key, `Enter` included.
* **XTMODKEYS** / xterm's `modifyOtherKeys` does the same through `CSI 27;mod;13~`.
* **Legacy** encodings have no room at all: `Enter` is the single byte `CR`, and there is no way to decorate it. Shift+Enter and Ctrl+Enter arrive as an undecorated `CR` — byte-identical to a plain `Enter`.
Alt is the exception, because legacy terminals express it structurally rather than in a modifier field: Alt+*key* is transmitted as `Escape` followed by *key*, so Alt+Enter is `ESC` `CR` — two bytes, plainly distinct from a bare `CR`. [Selkie::Event](Selkie--Event.md) decodes that prefix form back into `Mod-Alt` alongside the two modern encodings, so `Alt+Enter` reaches this widget as an Alt-modified `Enter` on all three.
That is why `Alt+Enter` is the newline binding worth putting in your UI's help text. `Shift+Enter` and `Ctrl+Enter` are listed as aliases for the terminals that can report them; on the terminals that cannot, they arrive as a plain `Enter` and therefore submit. That degradation is intentional and unavoidable — it is a property of the wire format, not of this widget — and it is precisely why Alt+Enter exists as the guaranteed escape hatch.
(One legacy caveat worth knowing: because Alt is an `Escape` prefix there, the terminal only distinguishes Alt+Enter from "pressed Escape, then pressed Enter" by whether the two bytes arrive together. Some terminals also need Alt-as-Meta turning on before they send the prefix at all — macOS Terminal.app's *Use Option as Meta key*, for instance.)
Mouse and selection
-------------------
Click positions the caret. Drag selects across rows; the selection range is rendered with reverse-video and respects word-wrap (the highlight follows the wrapped layout, not raw offsets). Double-click selects the word under the cursor; triple-click selects the entire current logical line. Scroll-wheel moves the cursor up/down. Ctrl+A selects everything; Ctrl+C / Ctrl+X emit on `on-copy` / `on-cut` and (for cut) delete the selection. Backspace and Delete consume an active selection if present; typing replaces it.
EXAMPLES
========
Chat compose area
-----------------
```raku
my $compose = Selkie::Widget::MultiLineInput.new(
sizing => Sizing.fixed(1),
max-lines => 5,
placeholder => 'Type a message — Ctrl+Enter to send',
);
$compose.on-submit.tap: -> $text {
if $text.chars > 0 {
$app.store.dispatch('chat/send', :$text);
$compose.clear;
}
};
```
Chat compose area with Enter-to-send
------------------------------------
Same widget, chat binding: `Enter` sends and `Alt+Enter` starts a new paragraph. Note that the placeholder advertises `Alt+Enter` rather than `Shift+Enter` — see **Why Alt+Enter is the portable newline**.
```raku
my $compose = Selkie::Widget::MultiLineInput.new(
sizing => Sizing.fixed(1),
max-lines => 5,
enter-submits => True,
placeholder => 'Type a message — Enter to send, Alt+Enter for a new line',
);
$compose.on-submit.tap: -> $text {
if $text.trim.chars > 0 {
$app.store.dispatch('chat/send', :$text);
$compose.clear;
}
};
```
Flipping the mode at runtime is just an assignment — the next keystroke picks it up, and any text already in the buffer is untouched:
```raku
$app.store.subscribe(-> %s { %s<settings><enter-sends> }, -> $on {
$compose.enter-submits = $on;
$compose.placeholder = $on
?? 'Enter to send, Alt+Enter for a new line'
!! 'Ctrl+Enter to send';
});
```
SEE ALSO
========
* [Selkie::Widget::TextInput](Selkie--Widget--TextInput.md) — single-line variant
* [Selkie::Widget::TextStream](Selkie--Widget--TextStream.md) — append-only log (no editing)
* [Selkie::Event](Selkie--Event.md) — how modifiers are normalised across keyboard encodings, which is what makes `Alt+Enter` portable
### has Bool $.enter-submits
Opt-in chat-style submit binding. `False` (the default) keeps the editor convention: `Enter` inserts a newline and `Ctrl+Enter` emits on `on-submit`. `True` inverts it: a bare `Enter` submits and `Alt+Enter` (portably), `Shift+Enter` or `Ctrl+Enter` (where the terminal distinguishes them) insert a newline. Writable, so an app can wire it to a user preference at runtime — see the pod's **Submit-key modes** section for the full key table and the portability rationale.
### has Int $!sel-anchor-row
Selection anchor in (logical-row, logical-col). `-1` in $!sel-anchor-row means "no selection" — cursor is a bare caret. When >= 0 the selection covers the half-open range from `min(anchor, cursor)` to `max(anchor, cursor)`, walked across logical lines.
### method has-selection
```raku
method has-selection() returns Bool
```
True iff a selection is active (anchor differs from cursor). Bare caret returns False.
### method selection-range
```raku
method selection-range() returns List
```
Returns the normalised selection range as a List of two pairs: `(:row, :col)` for the start and `(:row, :col)` for the end (half-open at end). Returns `()` when no selection.
### method selected-text
```raku
method selected-text() returns Str
```
The text currently selected, walking line by line. `\n` joins successive logical lines. Empty string when no selection.
### method clear-selection
```raku
method clear-selection() returns Mu
```
Clear the active selection without moving the caret.
### method on-copy
```raku
method on-copy() returns Supply
```
Supply emitting the currently-selected text on Ctrl+C. Selkie does not own the system clipboard — apps wire this up themselves via OSC 52 or notcurses paste-buffer. Fires only when there's an active selection.
### method on-cut
```raku
method on-cut() returns Supply
```
Supply emitting on Ctrl+X. Like `on-copy` but the selection is also deleted from the buffer.
### method text
```raku
method text() returns Str
```
The full buffer contents joined with `\n`. (The buffer is stored as an array of logical lines; this assembly is O(N) in total character count — cache the result if calling per frame.)
### method set-text
```raku
method set-text(
Str:D $t
) returns Mu
```
Replace the buffer contents and place the caret at the end. Emits on `on-change`. Use this for user-driven updates; for programmatic syncs from a store path use `set-text-silent` instead to avoid feedback loops.
### method set-text-silent
```raku
method set-text-silent(
Str:D $t
) returns Mu
```
Silent variant of `set-text` — updates the buffer without emitting on `on-change`. Wire this into store subscriptions that mirror external state into the input, so the input update doesn't dispatch an event that loops back through the store and re-fires the subscription.
### method clear
```raku
method clear() returns Mu
```
Empty the buffer. Equivalent to `set-text('')`.
### method on-submit
```raku
method on-submit() returns Supply
```
Supply that emits the current buffer when the user presses the submit key — `Ctrl+Enter` by default, or a bare `Enter` when `enter-submits` is set. The emitted value is `text`, the logical lines joined with `\n`; the buffer is left untouched, so the tap decides whether to `clear` it.
### method on-change
```raku
method on-change() returns Supply
```
Supply that emits the new buffer contents on every user-driven edit (typing, paste, delete, cut, `set-text`). Does not fire for `set-text-silent`.
### method set-focused
```raku
method set-focused(
Bool $f
) returns Mu
```
Set the input's focus state. Called by `Selkie::App`'s focus dispatcher. The caret is only painted while focused.
### method is-focused
```raku
method is-focused() returns Bool
```
Whether the widget currently has focus.
### method desired-height
```raku
method desired-height() returns UInt
```
The natural visual height for the buffer in cells, accounting for soft-wrap at the current width. Clamped to `max-lines`. Used by autosize containers (e.g. a chat compose area) to grow the input with its content.
### method line-count
```raku
method line-count() returns UInt
```
Number of logical lines in the buffer (counts hard newlines, not soft-wraps). Always at least 1 — an empty buffer counts as one empty line.
### method cursor-row
```raku
method cursor-row() returns UInt
```
Caret row in logical-line coordinates (0-based; counts hard newlines, not soft-wraps).
### method cursor-col
```raku
method cursor-col() returns UInt
```
Caret column on the current logical line (0-based; counts characters, not visual cells).
### method visual-rows
```raku
method visual-rows() returns Array
```
Same shape as `!visual-lines`, but each entry is a hash with `logical-row`, `logical-col-start`, `text`. Used by the selection overlay to map visual rows back to logical (row, col) spans for highlighting.
### method insert-text
```raku
method insert-text(
Str:D $text
) returns Nil
```
Insert `$text` at the current cursor position in one operation, splitting on `\n` so multi-line pasted content lays across multiple buffer lines. Equivalent to typing each character in turn but with one buffer rebuild instead of one per char — O(n) total instead of O(n²). Used by the App's paste-batching drain loop.
### method do-word-backspace
```raku
method do-word-backspace() returns Mu
```
Shift-Backspace: delete from the cursor back to the previous word boundary. At column 0, falls through to the regular backspace semantics so the line above is joined — matches what users expect from "delete previous word" in editors that also support multi-line.