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';
};