Selkie.git | lib/Selkie/Widget/ | MultiLineInput.rakumod


=begin pod

=head1 NAME

Selkie::Widget::MultiLineInput - Multi-line text input with word-wrap and 2D cursor

=head1 SYNOPSIS

=begin code :lang<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;

=end code

=head1 DESCRIPTION

A multi-line text area with word-wrapping, a 2D cursor, and dynamic
height that grows as the user types (up to C<max-lines>). By default
plain C<Enter> inserts a newline and C<Ctrl+Enter> submits; set
C<enter-submits> to swap that round for chat-style composition — see
B<Submit-key modes> below.

The height auto-adjusts via C<desired-height>: if you pass
C<sizing => Sizing.fixed(1)>, the parent layout sees the widget's
desired height grow as content is added, bounded by C<max-lines>.

C<set-text-silent> updates the buffer without emitting C<on-change> —
use this from store subscriptions to avoid feedback loops. Both
C<set-text> and C<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 C<\r> inside each line.

=head2 Submit-key modes

The C<enter-submits> attribute (default C<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.

B<Default> — C<enter-submits> is C<False>, the editor convention:

=begin table
Key             | Effect
================|===============================================
Enter           | insert a newline
Shift+Enter     | insert a newline
Alt+Enter       | insert a newline
Ctrl+Enter      | SUBMIT — emit the buffer on on-submit
=end table

B<Chat mode> — C<enter-submits> is C<True>:

=begin table
Key             | Effect
================|===============================================
Enter           | SUBMIT — emit the buffer on on-submit
Alt+Enter       | insert a newline (the portable one)
Shift+Enter     | insert a newline, where the terminal reports it
Ctrl+Enter      | insert a newline, where the terminal reports it
=end 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 C<\n>)
and does B<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.

=head3 Why Alt+Enter is the portable newline

A terminal can only report a modifier on C<Enter> if its keyboard
encoding has room to say so. Three encodings are in play:

=item The B<kitty keyboard protocol> (kitty, foot, ghostty, WezTerm, recent Alacritty) reports Shift, Ctrl and Alt on every key, C<Enter> included.
=item B<XTMODKEYS> / xterm's C<modifyOtherKeys> does the same through C<CSI 27;mod;13~>.
=item B<Legacy> encodings have no room at all: C<Enter> is the single byte C<CR>, and there is no way to decorate it. Shift+Enter and Ctrl+Enter arrive as an undecorated C<CR> — byte-identical to a plain C<Enter>.

Alt is the exception, because legacy terminals express it structurally
rather than in a modifier field: Alt+I<key> is transmitted as C<Escape>
followed by I<key>, so Alt+Enter is C<ESC> C<CR> — two bytes, plainly
distinct from a bare C<CR>. L<Selkie::Event> decodes that prefix form
back into C<Mod-Alt> alongside the two modern encodings, so
C<Alt+Enter> reaches this widget as an Alt-modified C<Enter> on all
three.

That is why C<Alt+Enter> is the newline binding worth putting in your
UI's help text. C<Shift+Enter> and C<Ctrl+Enter> are listed as aliases
for the terminals that can report them; on the terminals that cannot,
they arrive as a plain C<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 C<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 I<Use Option as Meta key>, for instance.)

=head2 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 C<on-copy> / C<on-cut> and
(for cut) delete the selection. Backspace and Delete consume an active
selection if present; typing replaces it.

=head1 EXAMPLES

=head2 Chat compose area

=begin code :lang<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;
    }
};

=end code

=head2 Chat compose area with Enter-to-send

Same widget, chat binding: C<Enter> sends and C<Alt+Enter> starts a new
paragraph. Note that the placeholder advertises C<Alt+Enter> rather
than C<Shift+Enter> — see B<Why Alt+Enter is the portable newline>.

=begin code :lang<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;
    }
};

=end code

Flipping the mode at runtime is just an assignment — the next
keystroke picks it up, and any text already in the buffer is
untouched:

=begin code :lang<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';
});

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::TextInput> — single-line variant
=item L<Selkie::Widget::TextStream> — append-only log (no editing)
=item L<Selkie::Event> — how modifiers are normalised across keyboard encodings, which is what makes C<Alt+Enter> portable

=end pod

use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;

use Selkie::Widget;
use Selkie::Widget::FocusableByDefault;
use Selkie::Style;
use Selkie::Event;
use Selkie::Sizing;
use Selkie::Widget::TextInput :words;

unit class Selkie::Widget::MultiLineInput does Selkie::Widget does Selkie::Widget::FocusableByDefault;

has @!lines = ('',);
has UInt $!cursor-row = 0;
has UInt $!cursor-col = 0;
has UInt $!scroll-y = 0;          # vertical scroll (visual rows)
has UInt $.max-lines = 6;

#|( Opt-in chat-style submit binding. C<False> (the default) keeps the
    editor convention: C<Enter> inserts a newline and C<Ctrl+Enter>
    emits on C<on-submit>. C<True> inverts it: a bare C<Enter> submits
    and C<Alt+Enter> (portably), C<Shift+Enter> or C<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
    B<Submit-key modes> section for the full key table and the
    portability rationale. )
has Bool $.enter-submits is rw = False;

has Str $.placeholder is rw = '';
has Bool $!focused = False;
has Supplier $!submit-supplier = Supplier.new;
has Supplier $!change-supplier = Supplier.new;
has Supplier $!copy-supplier = Supplier.new;
has Supplier $!cut-supplier = Supplier.new;

#|( Selection anchor in (logical-row, logical-col). C<-1> in $!sel-anchor-row
    means "no selection" — cursor is a bare caret. When >= 0 the
    selection covers the half-open range from C<min(anchor, cursor)>
    to C<max(anchor, cursor)>, walked across logical lines. )
has Int $!sel-anchor-row = -1;
has Int $!sel-anchor-col = 0;


submethod TWEAK() {
    # Click positions the caret. Double-click selects the word under
    # the cursor; triple-click selects the entire current logical
    # line (matches the per-row selection convention from text
    # editors). Drag extends the selection from the press anchor.
    self.on-click: -> $ev {
        my $vrow = self.local-row($ev);
        my $vcol = self.local-col($ev);
        if $vrow >= 0 && $vcol >= 0 {
            my ($lrow, $lcol) = self!visual-to-logical(($!scroll-y + $vrow).UInt, $vcol.UInt);
            given $ev.click-count {
                when 2 { self!select-word-at($lrow, $lcol) }
                when 3 { self!select-line-at($lrow) }
                default {
                    # Place the caret and clear any selection. Drag
                    # arms the anchor lazily on first motion — see
                    # the on-drag handler. Anchoring here would turn
                    # every post-click keystroke into a 1-char
                    # selection (cursor advances; anchor stays).
                    $!cursor-row = $lrow;
                    $!cursor-col = $lcol;
                    $!sel-anchor-row = -1;
                    self.mark-dirty;
                }
            }
        }
    };
    self.on-drag: -> $ev {
        my $raw-row = $ev.y - self.abs-y;
        my $raw-col = $ev.x - self.abs-x;
        # Clamp to the visible plane — drag captures keep us as the
        # target even when the cursor leaves our bounds. Beyond the
        # buffer's last visual row, !visual-to-logical pins to the
        # last line / last column for us.
        my $vrow = ($raw-row max 0) min (self.rows - 1);
        my $vcol = ($raw-col max 0) min (self.cols - 1);
        my ($lrow, $lcol) = self!visual-to-logical(($!scroll-y + $vrow).UInt, $vcol.UInt);
        unless $lrow == $!cursor-row && $lcol == $!cursor-col {
            # First motion of a drag: anchor at the press-time
            # cursor (row, col). Subsequent motions extend.
            if $!sel-anchor-row < 0 {
                $!sel-anchor-row = $!cursor-row.Int;
                $!sel-anchor-col = $!cursor-col.Int;
            }
            $!cursor-row = $lrow;
            $!cursor-col = $lcol;
            self.mark-dirty;
        }
    };
}

# --- Selection model -------------------------------------------------------

#|( True iff a selection is active (anchor differs from cursor).
    Bare caret returns False. )
method has-selection(--> Bool) {
    $!sel-anchor-row >= 0
        && ($!sel-anchor-row != $!cursor-row.Int
            || $!sel-anchor-col != $!cursor-col.Int);
}

#|( Returns the normalised selection range as a List of two pairs:
    C<(:row, :col)> for the start and C<(:row, :col)> for the end
    (half-open at end). Returns C<()> when no selection. )
method selection-range(--> List) {
    return ().List unless self.has-selection;
    my ($a-r, $a-c) = ($!sel-anchor-row, $!sel-anchor-col);
    my ($c-r, $c-c) = ($!cursor-row.Int, $!cursor-col.Int);
    if $a-r < $c-r || ($a-r == $c-r && $a-c <= $c-c) {
        return ({ :row($a-r), :col($a-c) }, { :row($c-r), :col($c-c) }).List;
    }
    ({ :row($c-r), :col($c-c) }, { :row($a-r), :col($a-c) }).List;
}

#|( The text currently selected, walking line by line. C<\n> joins
    successive logical lines. Empty string when no selection. )
method selected-text(--> Str) {
    return '' unless self.has-selection;
    my ($s, $e) = self.selection-range;
    if $s<row> == $e<row> {
        return @!lines[$s<row>].substr($s<col>, $e<col> - $s<col>);
    }
    my @parts;
    @parts.push: @!lines[$s<row>].substr($s<col>);
    for ($s<row> + 1 .. $e<row> - 1) -> $r {
        @parts.push: @!lines[$r];
    }
    @parts.push: @!lines[$e<row>].substr(0, $e<col>);
    @parts.join("\n");
}

#| Clear the active selection without moving the caret.
method clear-selection() {
    return unless $!sel-anchor-row >= 0;
    $!sel-anchor-row = -1;
    self.mark-dirty;
}

#| 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-copy(--> Supply) { $!copy-supplier.Supply }

#| Supply emitting on Ctrl+X. Like C<on-copy> but the selection is
#| also deleted from the buffer.
method on-cut(--> Supply)  { $!cut-supplier.Supply }

method !select-word-at(UInt $row, UInt $col) {
    my $line = @!lines[$row];
    return unless $line.chars > 0;
    my $start = prev-word-pos($line, ($col + 1).Int);
    my $end   = next-word-pos($line, $col.Int);
    while $end > $start && !($line.substr($end - 1, 1) ~~ /\w/) {
        $end--;
    }
    return if $end == $start;
    $!sel-anchor-row = $row.Int;
    $!sel-anchor-col = $start;
    $!cursor-row = $row;
    $!cursor-col = $end.UInt;
    self.mark-dirty;
}

method !select-line-at(UInt $row) {
    return unless @!lines[$row].chars > 0;
    $!sel-anchor-row = $row.Int;
    $!sel-anchor-col = 0;
    $!cursor-row = $row;
    $!cursor-col = @!lines[$row].chars;
    self.mark-dirty;
}

method !select-all() {
    return unless @!lines.elems > 0 && self.text.chars > 0;
    $!sel-anchor-row = 0;
    $!sel-anchor-col = 0;
    $!cursor-row = @!lines.end.UInt;
    $!cursor-col = @!lines[*-1].chars.UInt;
    self.mark-dirty;
}

# Delete the active selection from the buffer, leaving the caret at
# the start of the (now-deleted) range. Returns True if deletion
# actually happened. Caller emits change / dirty.
method !delete-selection(--> Bool) {
    return False unless self.has-selection;
    my ($s, $e) = self.selection-range;
    if $s<row> == $e<row> {
        my $line = @!lines[$s<row>];
        @!lines[$s<row>] = $line.substr(0, $s<col>) ~ $line.substr($e<col>);
    } else {
        my $head = @!lines[$s<row>].substr(0, $s<col>);
        my $tail = @!lines[$e<row>].substr($e<col>);
        @!lines[$s<row>] = $head ~ $tail;
        @!lines.splice($s<row> + 1, $e<row> - $s<row>);
    }
    $!cursor-row = $s<row>.UInt;
    $!cursor-col = $s<col>.UInt;
    $!sel-anchor-row = -1;
    True;
}

# Set both cursor coords; either clear the selection (extend=False)
# or anchor at the previous cursor position (extend=True).
method !move-cursor(UInt $r, UInt $c, Bool $extend) {
    if $extend {
        if $!sel-anchor-row < 0 {
            $!sel-anchor-row = $!cursor-row.Int;
            $!sel-anchor-col = $!cursor-col.Int;
        }
    } else {
        $!sel-anchor-row = -1;
    }
    $!cursor-row = $r;
    $!cursor-col = $c;
    self.mark-dirty;
}

# Map a visual (vrow, vcol) to logical (row, col). When vrow runs
# past the last visual row, pins to the last line / last column —
# useful so a click in empty space below the buffer lands at the end.
method !visual-to-logical(UInt $vrow, UInt $vcol --> List) {
    my $w = self!wrap-width;
    my $vidx = 0;
    for @!lines.kv -> $r, $line {
        my $line-vrows = ($line.chars / $w).ceiling max 1;
        if $vidx + $line-vrows > $vrow {
            my $segment = $vrow - $vidx;
            my $col = ($segment * $w + $vcol) min $line.chars;
            return ($r.UInt, $col.UInt);
        }
        $vidx += $line-vrows;
    }
    (@!lines.end.UInt, @!lines[*-1].chars.UInt);
}

#| The full buffer contents joined with C<\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 text(--> Str) { @!lines.join("\n") }

#| Replace the buffer contents and place the caret at the end. Emits
#| on C<on-change>. Use this for user-driven updates; for programmatic
#| syncs from a store path use C<set-text-silent> instead to avoid
#| feedback loops.
method set-text(Str:D $t) {
    self!load-text($t);
    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

#| Silent variant of C<set-text> — updates the buffer without emitting
#| on C<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 set-text-silent(Str:D $t) {
    self!load-text($t);
    self!update-sizing;
    self.mark-dirty;
}

method !load-text(Str:D $t) {
    # Normalise CRLF/CR → LF before splitting. A stray \r left in the
    # buffer makes ncplane_putstr_yx jump the cursor back to col 0
    # mid-row, throws off .chars-based wrap counts, and breaks scroll.
    # Common when text comes from imported JSON authored on Windows
    # (e.g. CCv3 character cards).
    my $norm = $t.subst("\r\n", "\n", :g).subst("\r", "\n", :g);
    @!lines = $norm.split("\n").List;
    @!lines = ('',) unless @!lines;
    $!cursor-row = @!lines.end;
    $!cursor-col = @!lines[*-1].chars;
    $!sel-anchor-row = -1;
}

#| Empty the buffer. Equivalent to C<set-text('')>.
method clear() { self.set-text('') }

#|( Supply that emits the current buffer when the user presses the
    submit key — C<Ctrl+Enter> by default, or a bare C<Enter> when
    C<enter-submits> is set. The emitted value is C<text>, the logical
    lines joined with C<\n>; the buffer is left untouched, so the tap
    decides whether to C<clear> it. )
method on-submit(--> Supply) { $!submit-supplier.Supply }

#| Supply that emits the new buffer contents on every user-driven edit
#| (typing, paste, delete, cut, C<set-text>). Does not fire for
#| C<set-text-silent>.
method on-change(--> Supply) { $!change-supplier.Supply }

#| Set the input's focus state. Called by C<Selkie::App>'s focus
#| dispatcher. The caret is only painted while focused.
method set-focused(Bool $f) {
    $!focused = $f;
    self.mark-dirty;
}

#| Whether the widget currently has focus.
method is-focused(--> Bool) { $!focused }

#| The natural visual height for the buffer in cells, accounting for
#| soft-wrap at the current width. Clamped to C<max-lines>. Used by
#| autosize containers (e.g. a chat compose area) to grow the input
#| with its content.
method desired-height(--> UInt) {
    my $visual = self!total-visual-rows;
    my UInt $h = $visual max 1;
    $h = $h min $!max-lines;
    $h;
}

#| 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 line-count(--> UInt) { @!lines.elems }

#| Caret row in logical-line coordinates (0-based; counts hard newlines,
#| not soft-wraps).
method cursor-row(--> UInt) { $!cursor-row }

#| Caret column on the current logical line (0-based; counts characters,
#| not visual cells).
method cursor-col(--> UInt) { $!cursor-col }

# --- Visual line wrapping ---

method !wrap-width(--> UInt) {
    self.cols max 1;
}

method !total-visual-rows(--> UInt) {
    my $w = self!wrap-width;
    my $total = 0;
    for @!lines -> $line {
        $total += ($line.chars / $w).ceiling max 1;
    }
    $total.UInt;
}

# Map logical (row, col) to visual row
method !cursor-visual-row(--> UInt) {
    my $w = self!wrap-width;
    my $vrow = 0;
    for ^$!cursor-row -> $i {
        $vrow += (@!lines[$i].chars / $w).ceiling max 1;
    }
    $vrow += ($!cursor-col / $w).floor;
    $vrow.UInt;
}

# Map logical (row, col) to visual col
method !cursor-visual-col(--> UInt) {
    my $w = self!wrap-width;
    ($!cursor-col % $w).UInt;
}

# Build array of visual lines (each is a substr of a logical line)
method !visual-lines(--> Array) {
    my $w = self!wrap-width;
    my @vlines;
    for @!lines -> $line {
        if $line.chars <= $w {
            @vlines.push($line);
        } else {
            my $pos = 0;
            while $pos < $line.chars {
                @vlines.push($line.substr($pos, $w));
                $pos += $w;
            }
        }
        # Empty lines still take a row
        @vlines.push('') if $line.chars == 0 && @vlines[*-1] ne '';
    }
    @vlines.Array;
}

#|( Same shape as C<!visual-lines>, but each entry is a hash with
    C<logical-row>, C<logical-col-start>, C<text>. Used by the
    selection overlay to map visual rows back to logical (row, col)
    spans for highlighting. )
method !visual-rows(--> Array) {
    my $w = self!wrap-width;
    my @rows;
    for @!lines.kv -> $r, $line {
        if $line.chars == 0 {
            @rows.push({ :logical-row($r), :logical-col-start(0), :text('') });
        } elsif $line.chars <= $w {
            @rows.push({ :logical-row($r), :logical-col-start(0), :text($line) });
        } else {
            my $pos = 0;
            while $pos < $line.chars {
                @rows.push({
                    :logical-row($r),
                    :logical-col-start($pos),
                    :text($line.substr($pos, $w)),
                });
                $pos += $w;
            }
        }
    }
    @rows.Array;
}

method render() {
    return without self.plane;
    ncplane_erase(self.plane);

    my $style = $!focused ?? self.theme.input-focused !! self.theme.input;
    self.apply-style($style);

    my UInt $visible-rows = self.rows;
    my UInt $w = self.cols;

    # Fill background
    for ^$visible-rows -> $row {
        ncplane_putstr_yx(self.plane, $row, 0, ' ' x $w);
    }

    if @!lines.elems == 1 && @!lines[0].chars == 0 && !$!focused && $!placeholder.chars > 0 {
        my $ps = self.theme.input-placeholder;
        self.apply-style($ps);
        ncplane_putstr_yx(self.plane, 0, 0, $!placeholder.substr(0, $w));
    } else {
        self.apply-style($style);
        self!adjust-scroll;

        my @vrows = self!visual-rows;
        for ^$visible-rows -> $row {
            my UInt $vline-idx = $!scroll-y + $row;
            last if $vline-idx >= @vrows.elems;
            ncplane_putstr_yx(self.plane, $row, 0, @vrows[$vline-idx]<text>);
        }

        # Selection overlay: redraw cells in the selection range with
        # reverse-video. Walks the visible visual rows and computes
        # overlap per-row against the normalised selection bounds.
        if self.has-selection {
            my ($s, $e) = self.selection-range;
            my $selection = self.theme.selection;
            ncplane_set_fg_rgb(self.plane, $selection.fg // $style.bg) if ($selection.fg // $style.bg).defined;
            ncplane_set_bg_rgb(self.plane, $selection.bg // $style.fg) if ($selection.bg // $style.fg).defined;
            for ^$visible-rows -> $row {
                my UInt $vline-idx = $!scroll-y + $row;
                last if $vline-idx >= @vrows.elems;
                my %vr = @vrows[$vline-idx];
                next if %vr<logical-row> < $s<row> || %vr<logical-row> > $e<row>;
                my $seg-start = %vr<logical-col-start>;
                my $seg-end   = $seg-start + %vr<text>.chars;
                my $hi-lo = %vr<logical-row> == $s<row> ?? max($s<col>, $seg-start) !! $seg-start;
                my $hi-hi = %vr<logical-row> == $e<row> ?? min($e<col>, $seg-end)   !! $seg-end;
                next unless $hi-hi > $hi-lo;
                my $start-col = $hi-lo - $seg-start;
                my $sel-text = %vr<text>.substr($start-col, $hi-hi - $hi-lo);
                ncplane_putstr_yx(self.plane, $row, $start-col, $sel-text);
            }
        }

        # Draw cursor (skipped while selection is active — the
        # reverse-video span already marks the active end).
        if $!focused && !self.has-selection {
            my Int $cursor-vrow = self!cursor-visual-row - $!scroll-y;
            my UInt $cursor-vcol = self!cursor-visual-col;
            if $cursor-vrow >= 0 && $cursor-vrow < $visible-rows {
                my $under = $!cursor-col < @!lines[$!cursor-row].chars
                    ?? @!lines[$!cursor-row].substr($!cursor-col, 1) !! ' ';
                my $selection = self.theme.selection;
                ncplane_set_fg_rgb(self.plane, $selection.fg // $style.bg) if ($selection.fg // $style.bg).defined;
                ncplane_set_bg_rgb(self.plane, $selection.bg // $style.fg) if ($selection.bg // $style.fg).defined;
                ncplane_putstr_yx(self.plane, $cursor-vrow, $cursor-vcol, $under);
            }
        }
    }

    self.clear-dirty;
}

method !adjust-scroll() {
    my UInt $visible = self.rows max 1;
    my UInt $total-vrows = self!total-visual-rows;
    my UInt $cursor-vrow = self!cursor-visual-row;

    # Clamp scroll to valid range
    my $max-scroll = ($total-vrows - $visible) max 0;
    $!scroll-y = $!scroll-y min $max-scroll;

    # Ensure cursor is visible
    if $cursor-vrow < $!scroll-y {
        $!scroll-y = $cursor-vrow;
    } elsif $cursor-vrow >= $!scroll-y + $visible {
        $!scroll-y = $cursor-vrow - $visible + 1;
    }
}

method handle-event(Selkie::Event $ev --> Bool) {
    # Mouse routes through the registration API regardless of focus.
    if $ev.event-type ~~ MouseEvent {
        # Scroll-wheel keeps its existing cursor-driven semantics.
        given $ev.id {
            when NCKEY_SCROLL_UP   { self!move-up; return True }
            when NCKEY_SCROLL_DOWN { self!move-down; return True }
        }
        return True if self!dispatch-mouse-handlers($ev);
        return False;
    }

    return False unless $!focused;

    return False unless $ev.input-type == NCTYPE_PRESS || $ev.input-type == NCTYPE_REPEAT
                     || $ev.input-type == NCTYPE_UNKNOWN;

    my $shift = $ev.has-modifier(Mod-Shift);
    my $ctrl  = $ev.has-modifier(Mod-Ctrl);

    # Enter. Default mode: Ctrl+Enter submits, everything else
    # inserts a newline. With $!enter-submits the sense inverts to the
    # chat convention: a bare Enter sends, and a modified Enter —
    # Alt, Shift or Ctrl — inserts. Either way the newline path
    # replaces the selection first, and either way the keystroke is
    # consumed rather than bubbled.
    if $ev.id == NCKEY_ENTER {
        my Bool $newline = $!enter-submits
            ?? ($ctrl || $shift || $ev.has-modifier(Mod-Alt))
            !! !$ctrl;
        if $newline {
            self!delete-selection;
            self!insert-newline;
        } else {
            $!submit-supplier.emit(self.text);
        }
        return True;
    }

    # Ctrl-chord shortcuts that own selection / copy / cut. Handled
    # before the generic Ctrl-bubble-out so they don't fall through
    # to global keybinds. Match on id (case-insensitive) — char is
    # typically unset for ctrl chords outside the kitty-keyboard path.
    if $ctrl && !$ev.has-modifier(Mod-Alt) && !$ev.has-modifier(Mod-Super) {
        my $lower-id = $ev.id;
        $lower-id = $lower-id + 32 if $lower-id >= 'A'.ord && $lower-id <= 'Z'.ord;
        given $lower-id {
            when 'a'.ord { self!select-all; return True }
            when 'c'.ord {
                $!copy-supplier.emit(self.selected-text) if self.has-selection;
                return True;
            }
            when 'x'.ord {
                if self.has-selection {
                    $!cut-supplier.emit(self.selected-text);
                    self!delete-selection;
                    $!change-supplier.emit(self.text);
                    self!update-sizing;
                    self.mark-dirty;
                    return True;
                }
                return False;
            }
        }
    }

    # Let other Ctrl/Alt/Super bubble for global keybinds — *unless*
    # the OS keyboard layout already composed the modifier into a
    # different printable character (e.g. UK Mac Alt-3 → '#'). See
    # TextInput.handle-event for the full rationale.
    my $composed = $ev.char.defined && $ev.char.chars == 1
                && $ev.char.ord >= 32 && $ev.char.ord != $ev.id;
    if !$composed && ($ev.has-modifier(Mod-Ctrl) || $ev.has-modifier(Mod-Alt) || $ev.has-modifier(Mod-Super)) {
        return self!check-keybinds($ev);
    }

    given $ev.id {
        when NCKEY_BACKSPACE {
            if self.has-selection {
                self!delete-selection;
                $!change-supplier.emit(self.text);
                self!update-sizing;
                self.mark-dirty;
            } elsif $shift {
                self!do-word-backspace;
            } else {
                self!do-backspace;
            }
            return True;
        }
        when NCKEY_DEL {
            if self.has-selection {
                self!delete-selection;
                $!change-supplier.emit(self.text);
                self!update-sizing;
                self.mark-dirty;
            } else {
                self!do-delete;
            }
            return True;
        }
        when NCKEY_LEFT {
            self!handle-left($shift);
            return True;
        }
        when NCKEY_RIGHT {
            self!handle-right($shift);
            return True;
        }
        when NCKEY_UP {
            self!handle-up($shift);
            return True;
        }
        when NCKEY_DOWN {
            self!handle-down($shift);
            return True;
        }
        when NCKEY_HOME {
            self!set-cursor-extending($!cursor-row, 0, $shift);
            return True;
        }
        when NCKEY_END {
            self!set-cursor-extending($!cursor-row, @!lines[$!cursor-row].chars, $shift);
            return True;
        }
        default {
            if $ev.char.defined && $ev.char.chars == 1 {
                if $ev.char.ord == 10 || $ev.char.ord == 13 {
                    self!delete-selection;
                    self!insert-newline;
                    return True;
                } elsif $ev.char.ord >= 32 {
                    self!delete-selection;   # type-replaces-selection
                    self!insert-char($ev.char);
                    return True;
                }
            }
        }
    }
    False;
}

# --- Selection-aware cursor moves --------------------------------------

# Helper: position cursor and clear/extend selection in one call.
method !set-cursor-extending(UInt $r, UInt $c, Bool $extend) {
    self!move-cursor($r, $c, $extend);
}

method !handle-left(Bool $extend) {
    my $start-row = $!cursor-row;
    my $start-col = $!cursor-col;
    if $extend {
        # Shift+Left: word-jump (matches existing convention) AND
        # extend selection. Falls back to plain prev-cell at column 0.
        if $!cursor-col == 0 && $!cursor-row > 0 {
            my $r = $!cursor-row - 1;
            my $line = @!lines[$r];
            my $c = $line.chars > 0 ?? prev-word-pos($line, $line.chars).UInt !! 0;
            self!move-cursor($r, $c, True);
        } elsif $!cursor-col > 0 {
            my $line = @!lines[$!cursor-row];
            my $c = prev-word-pos($line, $!cursor-col.Int).UInt;
            self!move-cursor($!cursor-row, $c, True);
        }
        return;
    }
    # Plain Left: clear selection, step one cell back across line
    # boundaries.
    if $!cursor-col > 0 {
        self!move-cursor($!cursor-row, $!cursor-col - 1, False);
    } elsif $!cursor-row > 0 {
        my $r = $!cursor-row - 1;
        self!move-cursor($r, @!lines[$r].chars.UInt, False);
    } else {
        self.clear-selection;
    }
}

method !handle-right(Bool $extend) {
    if $extend {
        my $line = @!lines[$!cursor-row];
        if $!cursor-col >= $line.chars && $!cursor-row < @!lines.end {
            self!move-cursor(($!cursor-row + 1).UInt, 0, True);
        } elsif $!cursor-col < $line.chars {
            my $c = next-word-pos($line, $!cursor-col.Int).UInt;
            self!move-cursor($!cursor-row, $c, True);
        }
        return;
    }
    my $line = @!lines[$!cursor-row];
    if $!cursor-col < $line.chars {
        self!move-cursor($!cursor-row, $!cursor-col + 1, False);
    } elsif $!cursor-row < @!lines.end {
        self!move-cursor(($!cursor-row + 1).UInt, 0, False);
    } else {
        self.clear-selection;
    }
}

method !handle-up(Bool $extend) {
    if $!cursor-row > 0 {
        my $r = $!cursor-row - 1;
        my $c = $!cursor-col min @!lines[$r].chars;
        self!move-cursor($r, $c.UInt, $extend);
    } elsif !$extend {
        self.clear-selection;
    }
}

method !handle-down(Bool $extend) {
    if $!cursor-row < @!lines.end {
        my $r = $!cursor-row + 1;
        my $c = $!cursor-col min @!lines[$r].chars;
        self!move-cursor($r.UInt, $c.UInt, $extend);
    } elsif !$extend {
        self.clear-selection;
    }
}

method !insert-char(Str $ch) {
    my $line = @!lines[$!cursor-row];
    @!lines[$!cursor-row] = $line.substr(0, $!cursor-col) ~ $ch ~ $line.substr($!cursor-col);
    $!cursor-col++;
    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

#|( Insert C<$text> at the current cursor position in one operation,
    splitting on C<\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 insert-text(Str:D $text --> Nil) {
    return if $text.chars == 0;
    self!delete-selection;
    # Strip control chars except \n (\r is normalised to \n) and \t.
    my $norm = $text.subst(/\r\n|\r/, "\n", :g);
    $norm = $norm.subst(/<[\x[00]..\x[08]\x[0B]..\x[0C]\x[0E]..\x[1F]\x[7F]]>/, '', :g);

    my @parts = $norm.split("\n");
    my $first = @parts.shift;

    # Insert the first line's worth of text at the cursor.
    my $line = @!lines[$!cursor-row];
    @!lines[$!cursor-row] = $line.substr(0, $!cursor-col) ~ $first ~ $line.substr($!cursor-col);
    $!cursor-col += $first.chars;

    # For each subsequent newline-separated chunk: split the current
    # line at the cursor, drop the chunk in as the next line, and
    # carry the tail along. After the loop the cursor lands at the
    # end of whatever the LAST chunk was.
    if @parts {
        my $tail = @!lines[$!cursor-row].substr($!cursor-col);
        @!lines[$!cursor-row] = @!lines[$!cursor-row].substr(0, $!cursor-col);
        for @parts.kv -> $i, $part {
            my $is-last = $i == @parts.end;
            my $row-text = $is-last ?? ($part ~ $tail) !! $part;
            @!lines.splice($!cursor-row + 1, 0, $row-text);
            $!cursor-row++;
            $!cursor-col = $part.chars;
        }
    }

    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

method !insert-newline() {
    my $line = @!lines[$!cursor-row];
    my $before = $line.substr(0, $!cursor-col);
    my $after = $line.substr($!cursor-col);
    @!lines[$!cursor-row] = $before;
    @!lines.splice($!cursor-row + 1, 0, $after);
    $!cursor-row++;
    $!cursor-col = 0;
    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

method !do-backspace() {
    if $!cursor-col > 0 {
        my $line = @!lines[$!cursor-row];
        @!lines[$!cursor-row] = $line.substr(0, $!cursor-col - 1) ~ $line.substr($!cursor-col);
        $!cursor-col--;
    } elsif $!cursor-row > 0 {
        my $prev = @!lines[$!cursor-row - 1];
        my $curr = @!lines[$!cursor-row];
        $!cursor-col = $prev.chars;
        @!lines[$!cursor-row - 1] = $prev ~ $curr;
        @!lines.splice($!cursor-row, 1);
        $!cursor-row--;
    } else {
        return;
    }
    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

method !do-delete() {
    my $line = @!lines[$!cursor-row];
    if $!cursor-col < $line.chars {
        @!lines[$!cursor-row] = $line.substr(0, $!cursor-col) ~ $line.substr($!cursor-col + 1);
    } elsif $!cursor-row < @!lines.end {
        @!lines[$!cursor-row] = $line ~ @!lines[$!cursor-row + 1];
        @!lines.splice($!cursor-row + 1, 1);
    } else {
        return;
    }
    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

method !move-up() {
    if $!cursor-row > 0 {
        $!cursor-row--;
        $!cursor-col = $!cursor-col min @!lines[$!cursor-row].chars;
    }
    self.mark-dirty;
}

method !move-down() {
    if $!cursor-row < @!lines.end {
        $!cursor-row++;
        $!cursor-col = $!cursor-col min @!lines[$!cursor-row].chars;
    }
    self.mark-dirty;
}

#|( 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. )
method !do-word-backspace() {
    if $!cursor-col == 0 {
        self!do-backspace;
        return;
    }
    my $line = @!lines[$!cursor-row];
    my $start = prev-word-pos($line, $!cursor-col.Int);
    return unless $start < $!cursor-col;
    @!lines[$!cursor-row] = $line.substr(0, $start) ~ $line.substr($!cursor-col);
    $!cursor-col = $start.UInt;
    $!change-supplier.emit(self.text);
    self!update-sizing;
    self.mark-dirty;
}

method !update-sizing() {
    my $h = self.desired-height;
    if self.sizing.mode ~~ SizeFixed && self.sizing.value != $h {
        self.set-sizing(Sizing.fixed($h));
        # Mark parent dirty so the next render re-runs its
        # layout-children with our new sizing. mark-dirty propagates
        # up to the root; render-children cascades back down so
        # every sibling gets a fresh layout pass. One top-down
        # traversal per frame is all we need — Selkie's handle-resize
        # doesn't recurse on its own (as of the layout-cascade
        # simplification), so there's no longer any "stale sizes
        # between handle-resize and render" window to worry about.
        self.parent.mark-dirty if self.parent.defined;
    }
}