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.