Selkie--Widget--MultiLineInput
NAME
Selkie::Widget::MultiLineInput - Multi-line text input with word-wrap and 2D cursor
SYNOPSIS
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:
Chat mode ā enter-submits is True:
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,
Enterincluded.XTMODKEYS / xterm's
modifyOtherKeysdoes the same throughCSI 27;mod;13~.Legacy encodings have no room at all:
Enteris the single byteCR, and there is no way to decorate it. Shift+Enter and Ctrl+Enter arrive as an undecoratedCRā byte-identical to a plainEnter.
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 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
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.
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:
$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 ā single-line variant
Selkie::Widget::TextStream ā append-only log (no editing)
Selkie::Event ā how modifiers are normalised across keyboard encodings, which is what makes
Alt+Enterportable
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
method has-selection() returns BoolTrue iff a selection is active (anchor differs from cursor). Bare caret returns False.
method selection-range
method selection-range() returns ListReturns 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
method selected-text() returns StrThe text currently selected, walking line by line. \n joins successive logical lines. Empty string when no selection.
method clear-selection
method clear-selection() returns MuClear the active selection without moving the caret.
method on-copy
method on-copy() returns SupplySupply 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
method on-cut() returns SupplySupply emitting on Ctrl+X. Like on-copy but the selection is also deleted from the buffer.
method text
method text() returns StrThe 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
method set-text(
Str:D $t
) returns MuReplace 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
method set-text-silent(
Str:D $t
) returns MuSilent 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
method clear() returns MuEmpty the buffer. Equivalent to set-text('').
method on-submit
method on-submit() returns SupplySupply 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
method on-change() returns SupplySupply 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
method set-focused(
Bool $f
) returns MuSet the input's focus state. Called by Selkie::App's focus dispatcher. The caret is only painted while focused.
method is-focused
method is-focused() returns BoolWhether the widget currently has focus.
method desired-height
method desired-height() returns UIntThe 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
method line-count() returns UIntNumber 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
method cursor-row() returns UIntCaret row in logical-line coordinates (0-based; counts hard newlines, not soft-wraps).
method cursor-col
method cursor-col() returns UIntCaret column on the current logical line (0-based; counts characters, not visual cells).
method visual-rows
method visual-rows() returns ArraySame 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
method insert-text(
Str:D $text
) returns NilInsert $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
method do-word-backspace() returns MuShift-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.