Selkie.git | t/ | 15-multi-line-input.rakutest


use Test;
use lib 'lib';

use Selkie::Widget::MultiLineInput;
use Selkie::Event;
use Notcurses::Native::Types;

use Selkie::Test::Keys;
use Selkie::Test::Supply;

use Selkie::Sizing;

plan 66;

subtest "construction defaults" => {
    plan 5;
    my $mli = Selkie::Widget::MultiLineInput.new;
    is $mli.text, '', "empty text";
    ok $mli.focusable, "focusable by default";
    is $mli.max-lines, 6, "default max-lines";
    is $mli.line-count, 1, "starts with 1 line";
    is $mli.desired-height, 1, "desired height 1";
};

subtest "set-text" => {
    plan 3;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.set-text("hello\nworld");
    is $mli.text, "hello\nworld", "text set";
    is $mli.line-count, 2, "2 lines";
    is $mli.cursor-row, 1, "cursor at last line";
};

subtest "set-text marks dirty" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.clear-dirty;
    $mli.set-text("hi");
    ok $mli.is-dirty, "dirty after set-text";
};

subtest "sizing change propagates dirty up to root" => {
    plan 2;
    # Contract: when a MultiLineInput's desired-height changes
    # (bulk set-text, line wrap, newline), it marks its parent dirty
    # so the next render runs the parent's layout-children — which
    # re-allocates rows to every sibling and cascades dirty back
    # down through render-children. We only assert the up-propagation
    # here; the cascade-down happens at render time (via
    # Container.!render-children), not at mark-dirty time.
    use Selkie::Container;
    use Selkie::Layout::VBox;
    class TextStub does Selkie::Widget {
        method render() { self.clear-dirty }
    }

    my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
    my $mli = Selkie::Widget::MultiLineInput.new(
        sizing    => Sizing.fixed(1),
        max-lines => 6,
    );
    my $sibling = TextStub.new;
    $root.add($mli);
    $root.add($sibling);

    $root.clear-dirty;
    $mli.clear-dirty;
    $sibling.clear-dirty;

    # Enough content to push desired-height past 1 (capped at
    # max-lines via the wrap calc). set-text → update-sizing →
    # parent.mark-dirty → propagates to root.
    $mli.set-text("a" x 50 ~ "\n" ~ "b" x 50);

    ok $mli.is-dirty, "input itself dirty after bulk set-text";
    ok $root.is-dirty,
        "root dirty — parent chain reached by mark-dirty propagation";
};

subtest "clear empties text" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.set-text("stuff");
    $mli.clear;
    is $mli.text, '', "empty after clear";
    is $mli.line-count, 1, "1 line after clear";
};

subtest "on-submit and on-change are Supplies" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    isa-ok $mli.on-submit, Supply, "on-submit is Supply";
    isa-ok $mli.on-change, Supply, "on-change is Supply";
};

subtest "on-change emits on set-text" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    my $received = '';
    $mli.on-change.tap: -> $v { $received = $v };
    $mli.set-text("hi");
    sleep 0.05;
    is $received, "hi", "change emitted";
};

subtest "desired-height caps at max-lines" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(max-lines => 3);
    $mli.set-text("a\nb\nc\nd\ne");
    is $mli.desired-height, 3, "capped at max-lines";
};

subtest "desired-height matches line count when under max" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(max-lines => 6);
    $mli.set-text("a\nb\nc");
    is $mli.desired-height, 3, "matches 3 lines";
};

subtest "handle-event returns False when not focused" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    nok $mli.handle-event(key-event(id => 'a'.ord, char => 'a')), "not consumed unfocused";
};

subtest "typing characters" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    for 'h', 'i' -> $c {
        $mli.handle-event(key-event(id => $c.ord, char => $c));
    }
    is $mli.text, "hi", "characters typed";
};

subtest "ctrl+enter submits" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    my $submitted = '';
    $mli.on-submit.tap: -> $v { $submitted = $v };
    $mli.set-text("hello");
    my $consumed = $mli.handle-event(key-event(
        id => NCKEY_ENTER,
        modifiers => Set(Mod-Ctrl),
    ));
    ok $consumed, "ctrl+enter consumed";
    sleep 0.05;
    is $submitted, "hello", "submit emitted with text";
};

subtest "enter inserts newline" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(6, 40);
    $mli.set-focused(True);
    for 'a', 'b', 'c' -> $c {
        $mli.handle-event(key-event(id => $c.ord, char => $c));
    }
    $mli.handle-event(key-event(id => NCKEY_ENTER));
    for 'd', 'e' -> $c {
        $mli.handle-event(key-event(id => $c.ord, char => $c));
    }
    is $mli.text, "abc\nde", "newline inserted";
    is $mli.line-count, 2, "2 lines";
};

subtest "backspace within line" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc");
    $mli.handle-event(key-event(id => NCKEY_BACKSPACE));
    is $mli.text, "ab", "last char deleted";
};

subtest "backspace at line start joins with previous" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc\ndef");
    # Cursor is at end of "def" (row 1, col 3)
    # Move to beginning of line 2
    $mli.handle-event(key-event(id => NCKEY_HOME));
    # Backspace joins lines
    $mli.handle-event(key-event(id => NCKEY_BACKSPACE));
    is $mli.text, "abcdef", "lines joined";
    is $mli.line-count, 1, "1 line after join";
};

subtest "backspace at start of first line is no-op" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("");
    $mli.handle-event(key-event(id => NCKEY_BACKSPACE));
    is $mli.text, "", "still empty";
};

subtest "delete within line" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc");
    # Move to beginning
    $mli.handle-event(key-event(id => NCKEY_HOME));
    $mli.handle-event(key-event(id => NCKEY_DEL));
    is $mli.text, "bc", "first char deleted";
};

subtest "delete at line end joins with next" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc\ndef");
    # Move cursor to row 0 end
    $mli.handle-event(key-event(id => NCKEY_UP));
    $mli.handle-event(key-event(id => NCKEY_END));
    $mli.handle-event(key-event(id => NCKEY_DEL));
    is $mli.text, "abcdef", "lines joined by delete";
    is $mli.line-count, 1, "1 line";
};

subtest "left arrow moves cursor" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    for 'a', 'b', 'c' -> $c {
        $mli.handle-event(key-event(id => $c.ord, char => $c));
    }
    $mli.handle-event(key-event(id => NCKEY_LEFT));
    $mli.handle-event(key-event(id => 'x'.ord, char => 'x'));
    is $mli.text, "abxc", "inserted at cursor after left";
};

subtest "left arrow wraps to previous line" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc\ndef");
    # Cursor at row 1 col 3
    # Move to home (col 0)
    $mli.handle-event(key-event(id => NCKEY_HOME));
    # Left wraps to end of previous line
    $mli.handle-event(key-event(id => NCKEY_LEFT));
    is $mli.cursor-row, 0, "wrapped to previous row";
    is $mli.cursor-col, 3, "at end of previous line";
};

subtest "right arrow wraps to next line" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc\ndef");
    # Move to row 0 end
    $mli.handle-event(key-event(id => NCKEY_UP));
    $mli.handle-event(key-event(id => NCKEY_END));
    # Right wraps to start of next line
    $mli.handle-event(key-event(id => NCKEY_RIGHT));
    is $mli.cursor-row, 1, "wrapped to next row";
    is $mli.cursor-col, 0, "at start of next line";
};

subtest "up arrow" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc\ndef");
    # At row 1 col 3
    $mli.handle-event(key-event(id => NCKEY_UP));
    is $mli.cursor-row, 0, "moved up";
    is $mli.cursor-col, 3, "col preserved";
};

subtest "up arrow clamps col to shorter line" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("ab\ndefgh");
    # At row 1 col 5
    $mli.handle-event(key-event(id => NCKEY_UP));
    is $mli.cursor-row, 0, "moved up";
    is $mli.cursor-col, 2, "col clamped to line length";
};

subtest "down arrow" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abc\ndef");
    $mli.handle-event(key-event(id => NCKEY_UP));
    $mli.handle-event(key-event(id => NCKEY_DOWN));
    is $mli.cursor-row, 1, "moved down";
};

subtest "home and end" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("abcdef");
    $mli.handle-event(key-event(id => NCKEY_HOME));
    is $mli.cursor-col, 0, "home moves to start";
    $mli.handle-event(key-event(id => NCKEY_END));
    is $mli.cursor-col, 6, "end moves to end";
};

subtest "ctrl modifier bubbles up" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    nok $mli.handle-event(key-event(
        id => 'q'.ord, char => 'q',
        modifiers => Set(Mod-Ctrl),
    )), "ctrl+key not consumed";
};

subtest "OS-composed alt char (e.g. UK Mac Alt-3 → '#') is typed" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    # See TextInput's matching test for the rationale.
    my $ev = key-event(
        id => '3'.ord,
        char => '#',
        modifiers => Set(Mod-Alt),
    );
    ok $mli.handle-event($ev), "composed-alt event consumed";
    is $mli.text, '#', "composed character inserted";
};

subtest "on-change emits on character input" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.resize(3, 40);
    $mli.set-focused(True);
    my $received = '';
    $mli.on-change.tap: -> $v { $received = $v };
    $mli.handle-event(key-event(id => 'a'.ord, char => 'a'));
    sleep 0.05;
    is $received, "a", "change emitted on typing";
};

subtest "auto-expand sizing on newline" => {
    plan 3;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(1), max-lines => 6);
    $mli.resize(1, 40);
    $mli.set-focused(True);
    is $mli.sizing.value, 1, "starts at height 1";
    # Shift+Enter to add a line
    $mli.handle-event(key-event(id => NCKEY_ENTER));
    is $mli.sizing.value, 2, "grows to 2 after newline";
    $mli.handle-event(key-event(id => NCKEY_ENTER));
    is $mli.sizing.value, 3, "grows to 3 after second newline";
};

subtest "auto-shrink sizing on backspace join" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(1), max-lines => 6);
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("line1\nline2\nline3");
    is $mli.sizing.value, 3, "3 lines = height 3";
    # Cursor is at end of line3. Go to start of line3 and backspace to join.
    $mli.handle-event(key-event(id => NCKEY_HOME));
    $mli.handle-event(key-event(id => NCKEY_BACKSPACE));
    is $mli.sizing.value, 2, "shrinks to 2 after join";
};

subtest "sizing capped at max-lines" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(1), max-lines => 3);
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("a\nb\nc\nd\ne");
    is $mli.sizing.value, 3, "capped at max-lines";
};

subtest "clear shrinks back to 1" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(1), max-lines => 6);
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("a\nb\nc");
    $mli.clear;
    is $mli.sizing.value, 1, "back to 1 after clear";
};

# --- Word-jump shift-arrow behaviour ---

subtest "shift+left jumps within a line then crosses to previous" => {
    plan 4;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 6);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    $mli.set-text("hello world\nfoo bar baz");
    # cursor at end of line 2 ("baz" end). shift+left lands at start of "baz".
    $mli.handle-event(key-event('shift+left'));
    is $mli.cursor-row, 1, "still on second line";
    is $mli.cursor-col, 8, "at start of 'baz'";

    # Walk left twice more — through 'bar' and 'foo' — then once more
    # crosses the line boundary to end of "world" on line 1.
    $mli.handle-event(key-event('shift+left'));
    $mli.handle-event(key-event('shift+left'));
    $mli.handle-event(key-event('shift+left'));   # at col 0 → cross line
    is $mli.cursor-row, 0, "crossed back to line 1";
    is $mli.cursor-col, 6, "at start of 'world' on line 1";
};

subtest "shift+right jumps across line boundary" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 6);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    $mli.set-text("hello\nworld");
    # cursor starts at end of line 2. Move to end of line 1 first.
    $mli.handle-event(key-event(id => NCKEY_HOME));
    $mli.handle-event(key-event(id => NCKEY_UP));
    $mli.handle-event(key-event(id => NCKEY_END));
    # Now at line 0, col 5 (end of "hello"). shift+right crosses to (1, 0).
    $mli.handle-event(key-event('shift+right'));
    is $mli.cursor-row, 1, "moved to next line";
    is $mli.cursor-col, 0, "at column 0 of next line";
};

subtest "shift+backspace deletes the previous word" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 6);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    $mli.set-text("hello world foo");
    # cursor at end. shift+backspace eats 'foo'.
    $mli.handle-event(key-event('shift+backspace'));
    is $mli.text, 'hello world ', "trailing word deleted";
};

subtest "shift+backspace at column 0 joins with previous line" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 6);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    $mli.set-text("hello\nworld");
    # cursor at end of line 2. Move to start of line 2.
    $mli.handle-event(key-event(id => NCKEY_HOME));
    $mli.handle-event(key-event('shift+backspace'));
    is $mli.text, 'helloworld', "lines joined as if plain backspace";
};

# --- insert-text (paste batch path) ---

subtest "insert-text drops a multi-line blob across lines" => {
    plan 3;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 12);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    $mli.insert-text("alpha\nbeta\ngamma");
    is $mli.text, "alpha\nbeta\ngamma", "three lines after a single insert";
    is $mli.line-count, 3, "line count matches";
    is $mli.cursor-row, 2, "cursor lands on the last pasted line";
};

subtest "insert-text in the middle of an existing line preserves the tail" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 12);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    $mli.set-text('helloworld');
    # Cursor at end. Move to between 'hello' and 'world'.
    for ^5 {
        $mli.handle-event(key-event(id => NCKEY_LEFT));
    }
    $mli.insert-text("XX\nYY");
    is $mli.text, "helloXX\nYYworld", "tail flows onto the new last line";
    is $mli.line-count, 2, "now spans two lines";
};

subtest "insert-text scales to thousands of chars" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(2), max-lines => 64);
    $mli.resize(2, 40);
    $mli.set-focused(True);
    my $body = ('lorem ipsum ' x 1_000).chomp;
    $mli.insert-text($body);
    is $mli.text.chars, $body.chars, "all chars retained";
    is $mli.line-count, 1, "no newlines, single line";
};

# --- Selection model + mouse ----------------------------------------------

subtest "ctrl+a selects the entire buffer (multi-line)" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3), max-lines => 8);
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("alpha\nbeta\ngamma");
    $mli.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
    ok $mli.has-selection, 'selection active';
    is $mli.selected-text, "alpha\nbeta\ngamma",
        'selected text spans all lines with newline joins';
};

subtest "ctrl+c emits selected text without changing buffer" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3));
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("foo\nbar");
    $mli.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
    my $copied;
    $mli.on-copy.tap: -> $t { $copied = $t };
    $mli.handle-event(key-event(id => 'c'.ord, modifiers => Set(Mod-Ctrl)));
    is $copied, "foo\nbar", 'on-copy fires with full selection';
    is $mli.text, "foo\nbar", 'buffer unchanged';
};

subtest "ctrl+x cuts selection (deletes from buffer)" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3));
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("foo\nbar");
    $mli.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
    $mli.handle-event(key-event(id => 'x'.ord, modifiers => Set(Mod-Ctrl)));
    is $mli.text, '', 'buffer drained after cut';
    nok $mli.has-selection, 'selection cleared after cut';
};

subtest "ctrl+x bubbles when there is no selection" => {
    plan 3;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3));
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("foo\nbar");
    my $cut;
    $mli.on-cut.tap: -> $t { $cut = $t };
    my $handled = $mli.handle-event(key-event(id => 'x'.ord, modifiers => Set(Mod-Ctrl)));
    nok $handled, 'unselected ctrl+x is not handled by the input';
    nok $cut.defined, 'no cut event emitted';
    is $mli.text, "foo\nbar", 'buffer unchanged';
};

subtest "typing replaces an active selection" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3));
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("hello");
    $mli.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
    $mli.handle-event(key-event(id => 'X'.ord, char => 'X'));
    is $mli.text, 'X', 'X replaced selection';
};

subtest "primary click positions caret at clicked cell" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3));
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("hello\nworld");
    $mli.set-viewport(abs-y => 0, abs-x => 0, rows => 3, cols => 40);
    $mli.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 1, x => 3,
                                  input-type => NCTYPE_PRESS, click-count => 1));
    is $mli.cursor-row, 1, 'cursor moved to row 1';
    is $mli.cursor-col, 3, 'cursor moved to col 3';
};

subtest "double-click selects the word under the cursor (multi-line)" => {
    plan 1;
    my $mli = Selkie::Widget::MultiLineInput.new(sizing => Sizing.fixed(3));
    $mli.resize(3, 40);
    $mli.set-focused(True);
    $mli.set-text("hello world\nfoo bar");
    $mli.set-viewport(abs-y => 0, abs-x => 0, rows => 3, cols => 40);
    # Click in the middle of "world" on row 0 (cols 6-10).
    $mli.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 0, x => 8,
                                  input-type => NCTYPE_PRESS, click-count => 2));
    is $mli.selected-text, 'world', 'double-click selected word "world"';
};

subtest "set-text normalises CRLF to LF" => {
    plan 4;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.set-text("a\r\nb\r\nc");
    is $mli.text, "a\nb\nc", "CRLF collapsed to LF";
    is $mli.line-count, 3, "3 logical lines";
    is $mli.cursor-row, 2, "cursor at last line";
    is $mli.cursor-col, 1, "cursor at end of 'c' (no trailing \\r)";
};

subtest "set-text normalises lone CR to LF" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.set-text("a\rb");
    is $mli.text, "a\nb", "lone CR collapsed to LF";
    is $mli.line-count, 2, "2 logical lines";
};

subtest "set-text leaves LF-only text untouched" => {
    plan 2;
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.set-text("a\nb\nc");
    is $mli.text, "a\nb\nc", "unchanged";
    is $mli.line-count, 3, "3 logical lines";
};

subtest "set-text-silent also normalises CRLF" => {
    plan 1;
    # Same normalisation path (!load-text), but exercised through
    # the silent setter that store subscriptions use.
    my $mli = Selkie::Widget::MultiLineInput.new;
    $mli.set-text-silent("foo\r\nbar");
    is $mli.text, "foo\nbar", "CRLF collapsed via set-text-silent";
};

# --- Submit-key modes ------------------------------------------------------
#
# Two conventions, selected by the `enter-submits` attribute. The
# default (False) is the editor convention and is pinned here
# exhaustively: it predates the flag, App::Cantina depends on it, and
# adding the flag must not have moved it by a single keystroke.
#
# The chat convention (True) inverts the Enter branch. Alt+Enter is
# the newline binding that survives every keyboard encoding — legacy
# terminals transmit it as ESC CR, which is distinguishable from a
# bare CR, whereas Shift+Enter and Ctrl+Enter are not. Those two are
# tested with their modifier bits set, which is what a terminal
# speaking the kitty protocol or XTMODKEYS delivers; on terminals that
# cannot report them the event arrives as a plain Enter and submits,
# by design.

# Build a focused widget with room for several lines.
sub compose(Bool :$enter-submits = False, Str :$text) {
    my $mli = Selkie::Widget::MultiLineInput.new(
        sizing => Sizing.fixed(6),
        :$enter-submits,
    );
    $mli.resize(6, 40);
    $mli.set-focused(True);
    $mli.set-text($text) with $text;
    $mli;
}

subtest "enter-submits defaults to False" => {
    plan 2;
    is Selkie::Widget::MultiLineInput.new.enter-submits, False,
        'default is the editor convention';
    is Selkie::Widget::MultiLineInput.new(enter-submits => True).enter-submits,
        True, 'constructor argument honoured';
};

subtest "default mode: plain enter inserts a newline and does not submit" => {
    plan 4;
    my $mli = compose(text => "hello");
    emitted-count-is $mli.on-submit, 0, 'plain enter (default mode)', {
        ok $mli.handle-event(key-event(id => NCKEY_ENTER)), 'enter consumed';
    };
    is $mli.text, "hello\n", 'newline inserted at the caret';
    is $mli.line-count, 2, '2 logical lines';
};

subtest "default mode: ctrl+enter submits and leaves the buffer alone" => {
    plan 4;
    my $mli = compose(text => "hello\nworld");
    emitted-once-ok $mli.on-submit, "hello\nworld",
                    'ctrl+enter (default mode)', {
        ok press-key($mli, 'ctrl+enter'), 'ctrl+enter consumed';
    };
    is $mli.text, "hello\nworld", 'buffer untouched by submit';
};

subtest "default mode: alt+enter and shift+enter insert newlines" => {
    plan 4;
    # Regression pin: modified Enter other than Ctrl has always been a
    # newline in the default mode, and the enter-submits branch must
    # not have taken those over.
    my $alt = compose(text => "a");
    emitted-count-is $alt.on-submit, 0, 'alt+enter (default mode)', {
        press-key($alt, 'alt+enter');
    };
    is $alt.text, "a\n", 'alt+enter inserted a newline';

    my $shift = compose(text => "a");
    emitted-count-is $shift.on-submit, 0, 'shift+enter (default mode)', {
        press-key($shift, 'shift+enter');
    };
    is $shift.text, "a\n", 'shift+enter inserted a newline';
};

subtest "default mode: enter replaces the active selection" => {
    plan 2;
    my $mli = compose(text => "hello world");
    $mli.handle-event(key-event(id => NCKEY_HOME));
    # Shift+Right is a word-jump extend, so it takes the trailing
    # space along with the word.
    press-key($mli, 'shift+right');
    is $mli.selected-text, 'hello ', 'selection armed';
    $mli.handle-event(key-event(id => NCKEY_ENTER));
    is $mli.text, "\nworld", 'selection replaced by the newline';
};

subtest "enter-submits: plain enter submits the whole buffer" => {
    plan 4;
    my $mli = compose(:enter-submits, text => "line one\nline two\nline three");
    emitted-once-ok $mli.on-submit, "line one\nline two\nline three",
                    'enter (chat mode)', {
        ok $mli.handle-event(key-event(id => NCKEY_ENTER)), 'enter consumed';
    };
    is $mli.line-count, 3, 'buffer not modified by the submit';
};

subtest "enter-submits: submit emits the buffer verbatim" => {
    plan 2;
    # No trimming, no normalisation, no clearing — whatever is in the
    # buffer is what the tap sees, including leading/trailing space,
    # blank lines and non-ASCII.
    my $raw = "  padded \n\nünïcödé ✓  ";
    my $mli = compose(:enter-submits, text => $raw);
    emitted-once-ok $mli.on-submit, $raw, 'verbatim submit', {
        $mli.handle-event(key-event(id => NCKEY_ENTER));
    };
};

subtest "enter-submits: submit does not clear the buffer" => {
    plan 2;
    my $mli = compose(:enter-submits, text => "keep me");
    $mli.handle-event(key-event(id => NCKEY_ENTER));
    is $mli.text, "keep me", 'text still there after submit';
    $mli.clear;
    is $mli.text, '', 'clearing is the app’s call';
};

subtest "enter-submits: alt+enter inserts a newline" => {
    plan 4;
    my $mli = compose(:enter-submits, text => "first");
    emitted-count-is $mli.on-submit, 0, 'alt+enter (chat mode)', {
        ok press-key($mli, 'alt+enter'), 'alt+enter consumed';
    };
    is $mli.text, "first\n", 'newline inserted';
    is $mli.line-count, 2, '2 logical lines';
};

subtest "enter-submits: shift+enter inserts a newline" => {
    plan 3;
    # Delivered with the Shift bit, i.e. what a kitty-protocol or
    # XTMODKEYS terminal sends.
    my $mli = compose(:enter-submits, text => "first");
    emitted-count-is $mli.on-submit, 0, 'shift+enter (chat mode)', {
        ok press-key($mli, 'shift+enter'), 'shift+enter consumed';
    };
    is $mli.text, "first\n", 'newline inserted';
};

subtest "enter-submits: ctrl+enter inserts a newline" => {
    plan 3;
    my $mli = compose(:enter-submits, text => "first");
    emitted-count-is $mli.on-submit, 0, 'ctrl+enter (chat mode)', {
        ok press-key($mli, 'ctrl+enter'), 'ctrl+enter consumed';
    };
    is $mli.text, "first\n", 'newline inserted';
};

subtest "enter-submits: newline chords all reach the same caret path" => {
    plan 3;
    # Each chord splits at the caret rather than appending, and the
    # caret lands at column 0 of the new line — same !insert-newline
    # the default mode uses.
    for <alt+enter shift+enter ctrl+enter> -> $chord {
        my $mli = compose(:enter-submits, text => "abcdef");
        $mli.handle-event(key-event(id => NCKEY_HOME));
        press-key($mli, $chord);
        is "{$mli.text}|{$mli.cursor-row}:{$mli.cursor-col}", "\nabcdef|1:0",
            "$chord split at the caret";
    }
};

subtest "enter-submits: alt+enter replaces the active selection" => {
    plan 3;
    my $mli = compose(:enter-submits, text => "hello world");
    $mli.handle-event(key-event(id => NCKEY_HOME));
    press-key($mli, 'shift+right');    # word-jump extend: "hello "
    is $mli.selected-text, 'hello ', 'selection armed';
    emitted-count-is $mli.on-submit, 0, 'alt+enter over a selection', {
        press-key($mli, 'alt+enter');
    };
    is $mli.text, "\nworld", 'selection replaced by the newline';
};

subtest "enter-submits: multi-line selection replaced by a newline chord" => {
    plan 2;
    my $mli = compose(:enter-submits, text => "one\ntwo\nthree");
    press-key($mli, 'ctrl+a');
    is $mli.selected-text, "one\ntwo\nthree", 'select-all armed';
    press-key($mli, 'shift+enter');
    is $mli.text, "\n", 'whole buffer replaced by a single newline';
};

subtest "enter-submits is writable at runtime" => {
    plan 4;
    my $mli = compose(text => "draft");
    $mli.handle-event(key-event(id => NCKEY_ENTER));
    is $mli.text, "draft\n", 'editor convention while False';

    $mli.enter-submits = True;
    emitted-once-ok $mli.on-submit, "draft\n", 'after flipping the flag', {
        $mli.handle-event(key-event(id => NCKEY_ENTER));
    };
    is $mli.text, "draft\n", 'flip did not disturb the buffer';
};

subtest "enter-submits: unfocused input ignores enter entirely" => {
    plan 3;
    my $mli = compose(:enter-submits, text => "hello");
    $mli.set-focused(False);
    emitted-count-is $mli.on-submit, 0, 'enter while unfocused', {
        nok $mli.handle-event(key-event(id => NCKEY_ENTER)),
            'not consumed — bubbles to the parent chain';
    };
    is $mli.text, "hello", 'buffer untouched';
};