Selkie.git | t/ | 09-text-input.rakutest
use Test;
use lib 'lib';
use Selkie::Widget::TextInput;
use Selkie::Event;
use Notcurses::Native::Types;
use Selkie::Test::Keys;
plan 52;
subtest "construction defaults" => {
plan 3;
my $ti = Selkie::Widget::TextInput.new;
is $ti.text, '', "empty buffer by default";
ok $ti.focusable, "focusable by default";
is $ti.placeholder, '', "no placeholder by default";
};
subtest "construction with placeholder" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new(placeholder => 'Type here...');
is $ti.placeholder, 'Type here...', "placeholder set";
};
subtest "set-text sets buffer" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.set-text('hello');
is $ti.text, 'hello', "text set";
};
subtest "set-text marks dirty" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.clear-dirty;
$ti.set-text('hello');
ok $ti.is-dirty, "dirty after set-text";
};
subtest "clear empties buffer" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.set-text('hello');
$ti.clear;
is $ti.text, '', "buffer empty after clear";
};
subtest "on-submit returns Supply" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
isa-ok $ti.on-submit, Supply, "on-submit is a Supply";
};
subtest "on-change returns Supply" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
isa-ok $ti.on-change, Supply, "on-change is a Supply";
};
subtest "on-change emits on set-text" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
my $received = '';
$ti.on-change.tap: -> $v { $received = $v };
$ti.set-text('hi');
sleep 0.05;
is $received, 'hi', "change event emitted";
};
subtest "handle-event returns False when not focused" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
my $ev = key-event(id => 'a'.ord, char => 'a');
nok $ti.handle-event($ev), "not consumed when unfocused";
};
subtest "typing a character" => {
plan 2;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
my $ev = key-event(id => 'a'.ord, char => 'a');
ok $ti.handle-event($ev), "event consumed";
is $ti.text, 'a', "character added to buffer";
};
subtest "typing multiple characters" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
for 'h', 'e', 'l', 'l', 'o' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
is $ti.text, 'hello', "multiple characters form word";
};
subtest "backspace deletes character before cursor" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
$ti.set-text('abc');
$ti.handle-event(key-event(id => NCKEY_BACKSPACE));
is $ti.text, 'ab', "last character deleted";
};
subtest "backspace at start does nothing" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
$ti.set-text('');
$ti.handle-event(key-event(id => NCKEY_BACKSPACE));
is $ti.text, '', "buffer unchanged";
};
subtest "delete key removes character at cursor" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
# Type 'abc', cursor at end (position 3)
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
# Move cursor left to position 2 (before 'c')
$ti.handle-event(key-event(id => NCKEY_LEFT));
# Delete removes 'c'
$ti.handle-event(key-event(id => NCKEY_DEL));
is $ti.text, 'ab', "character at cursor deleted";
};
subtest "left arrow moves cursor" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
# Move left twice (cursor at position 1, between 'a' and 'b')
$ti.handle-event(key-event(id => NCKEY_LEFT));
$ti.handle-event(key-event(id => NCKEY_LEFT));
# Type 'x' at position 1
$ti.handle-event(key-event(id => 'x'.ord, char => 'x'));
is $ti.text, 'axbc', "character inserted at cursor position";
};
subtest "right arrow moves cursor" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
# Move to beginning
$ti.handle-event(key-event(id => NCKEY_HOME));
# Move right once (cursor at position 1)
$ti.handle-event(key-event(id => NCKEY_RIGHT));
# Type 'x' at position 1
$ti.handle-event(key-event(id => 'x'.ord, char => 'x'));
is $ti.text, 'axbc', "right arrow advances cursor";
};
subtest "home moves cursor to start" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event(id => 'x'.ord, char => 'x'));
is $ti.text, 'xabc', "character inserted at start after home";
};
subtest "end moves cursor to end" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event(id => NCKEY_END));
$ti.handle-event(key-event(id => 'x'.ord, char => 'x'));
is $ti.text, 'abcx', "character appended after end";
};
subtest "enter emits submit" => {
plan 2;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
my $submitted = '';
$ti.on-submit.tap: -> $v { $submitted = $v };
for 'h', 'i' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
my $consumed = $ti.handle-event(key-event(id => NCKEY_ENTER));
ok $consumed, "enter consumed";
sleep 0.05;
is $submitted, 'hi', "submit emitted with buffer contents";
};
subtest "ctrl modifier bubbles up (not consumed by input)" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
my $ev = key-event(
id => 'q'.ord,
char => 'q',
modifiers => Set(Mod-Ctrl),
);
# With no keybinds registered on the widget, ctrl+q should not be consumed
nok $ti.handle-event($ev), "ctrl+key not consumed by text input";
};
subtest "OS-composed alt char (e.g. UK Mac Alt-3 → '#') is typed" => {
plan 2;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
# ncinput would arrive as: id=0x33 ('3'), eff_text=0x23 ('#'),
# modifiers=NCKEY_MOD_ALT. Selkie::Event populates char from eff_text,
# so char='#' but id=51. The modifier filter must NOT swallow this.
my $ev = key-event(
id => '3'.ord,
char => '#',
modifiers => Set(Mod-Alt),
);
ok $ti.handle-event($ev), "composed-alt event consumed";
is $ti.text, '#', "composed character inserted into buffer";
};
subtest "plain alt chord (eff_text == id) still bubbles up" => {
plan 2;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 40);
$ti.set-focused(True);
# Real Alt+N chord with no OS composition: eff_text == id, so char=='n'
# and id==110. Should still bubble so global Alt+N keybinds work.
my $ev = key-event(
id => 'n'.ord,
char => 'n',
modifiers => Set(Mod-Alt),
);
nok $ti.handle-event($ev), "alt+letter chord not consumed";
is $ti.text, '', "buffer untouched";
};
subtest "mask-char hides text" => {
plan 2;
my $ti = Selkie::Widget::TextInput.new(mask-char => '*');
$ti.resize(1, 40);
$ti.set-focused(True);
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
is $ti.text, 'abc', "actual text preserved";
is $ti.mask-char, '*', "mask-char set";
};
subtest "mask-char not set by default" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
nok $ti.mask-char.defined, "no mask by default";
};
# --- Word-jump shift-arrow behaviour ---
sub typed-input(Str:D $text, UInt :$cols = 80) {
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, $cols);
$ti.set-focused(True);
for $text.comb -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
$ti;
}
subtest "shift+left extends selection to previous word" => {
plan 3;
my $ti = typed-input('hello world foo');
# cursor is at end (15). shift+left selects back to 'foo' start (12).
$ti.handle-event(key-event('shift+left'));
is $ti.selected-text, 'foo', 'first jump selects "foo"';
# Typing replaces the selection — canonical text-editor behaviour.
$ti.handle-event(key-event(id => 'X'.ord, char => 'X'));
is $ti.text, 'hello world X', 'type replaces selected word';
# Selection cleared after replacement. Re-verify by jumping again.
$ti.handle-event(key-event('shift+left')); # selects 'X'
$ti.handle-event(key-event('shift+left')); # extends selection to 'world '
$ti.handle-event(key-event(id => 'Y'.ord, char => 'Y'));
is $ti.text, 'hello Y', 'second extension covers world+X';
};
subtest "shift+right extends selection to next word" => {
plan 2;
my $ti = typed-input('hello world foo');
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event('shift+right'));
is $ti.selected-text, 'hello ', 'extends to start of next word';
$ti.handle-event(key-event(id => 'X'.ord, char => 'X'));
is $ti.text, 'Xworld foo', 'type replaces selected leading-word run';
};
subtest "shift+left at start of buffer is a no-op" => {
plan 2;
my $ti = typed-input('hello');
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event('shift+left'));
nok $ti.has-selection, 'no selection — there was nothing to extend over';
$ti.handle-event(key-event(id => 'X'.ord, char => 'X'));
is $ti.text, 'Xhello', 'X inserts at the front';
};
subtest "shift+backspace deletes the previous word" => {
plan 2;
my $ti = typed-input('hello world foo');
$ti.handle-event(key-event('shift+backspace'));
is $ti.text, 'hello world ', "trailing word deleted, separator stays";
$ti.handle-event(key-event('shift+backspace'));
# Deletes 'world' AND the trailing space (prev-word-pos walks
# past whitespace too).
is $ti.text, 'hello ', "second backspace ate the next word";
};
subtest "shift+backspace at start does nothing destructive" => {
plan 1;
my $ti = typed-input('hi');
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event('shift+backspace'));
is $ti.text, 'hi', "buffer untouched";
};
# --- insert-text (paste batch path) ---
subtest "insert-text appends a long string at the cursor in one go" => {
plan 2;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 80);
$ti.set-focused(True);
my $blob = 'x' x 5_000;
$ti.insert-text($blob);
is $ti.text.chars, 5_000, "all 5 000 chars landed";
is $ti.text.substr(0, 5), 'xxxxx', "buffer matches";
};
subtest "insert-text inserts at the cursor, not at the end" => {
plan 1;
my $ti = typed-input('abcdef');
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event(id => NCKEY_RIGHT));
$ti.handle-event(key-event(id => NCKEY_RIGHT));
$ti.handle-event(key-event(id => NCKEY_RIGHT));
$ti.insert-text('XYZ');
is $ti.text, 'abcXYZdef', "inserted between 'c' and 'd'";
};
subtest "insert-text strips control chars and newlines (single-line)" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new;
$ti.resize(1, 80);
$ti.set-focused(True);
$ti.insert-text("hello\nworld\ttab\r\nbye");
is $ti.text, 'helloworldtabbye', "control chars and newlines dropped";
};
# --- Selection model + mouse ---
subtest "ctrl+a selects the whole buffer" => {
plan 2;
my $ti = typed-input('hello world');
$ti.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
ok $ti.has-selection, 'selection active after ctrl+a';
is $ti.selected-text, 'hello world', 'selection covers full buffer';
};
subtest "ctrl+c emits selected text without modifying buffer" => {
plan 3;
my $ti = typed-input('hello world');
$ti.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
my $copied;
$ti.on-copy.tap: -> $t { $copied = $t };
$ti.handle-event(key-event(id => 'c'.ord, modifiers => Set(Mod-Ctrl)));
is $copied, 'hello world', 'on-copy fired with selected text';
is $ti.text, 'hello world', 'buffer unchanged after copy';
ok $ti.has-selection, 'selection still active after copy';
};
subtest "ctrl+x emits selected text and deletes the selection" => {
plan 3;
my $ti = typed-input('hello world');
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event('shift+right'));
my $cut;
$ti.on-cut.tap: -> $t { $cut = $t };
$ti.handle-event(key-event(id => 'x'.ord, modifiers => Set(Mod-Ctrl)));
is $cut, 'hello ', 'on-cut fired with selected text';
is $ti.text, 'world', 'cut removed selection from buffer';
nok $ti.has-selection, 'selection cleared after cut';
};
subtest "ctrl+x bubbles when there is no selection" => {
plan 3;
my $ti = typed-input('hello world');
my $cut;
$ti.on-cut.tap: -> $t { $cut = $t };
my $handled = $ti.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 $ti.text, 'hello world', 'buffer unchanged';
};
subtest "typing replaces an active selection" => {
plan 1;
my $ti = typed-input('hello world');
$ti.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
$ti.handle-event(key-event(id => 'X'.ord, char => 'X'));
is $ti.text, 'X', 'typed char replaced full selection';
};
subtest "backspace deletes an active selection" => {
plan 1;
my $ti = typed-input('hello world');
$ti.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
$ti.handle-event(key-event(id => NCKEY_BACKSPACE));
is $ti.text, '', 'backspace deleted full selection';
};
subtest "primary click positions caret at clicked column" => {
plan 2;
my $ti = typed-input('hello world');
# Place the widget at known abs coordinates so local-col makes sense.
$ti.set-viewport(abs-y => 0, abs-x => 0, rows => 1, cols => 80);
$ti.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 0, x => 6,
input-type => NCTYPE_PRESS, click-count => 1));
nok $ti.has-selection, 'no selection from a single click';
$ti.handle-event(key-event(id => 'X'.ord, char => 'X'));
is $ti.text, 'hello Xworld', 'X inserted at clicked column';
};
subtest "double-click selects the word under the cursor" => {
plan 1;
my $ti = typed-input('hello world foo');
$ti.set-viewport(abs-y => 0, abs-x => 0, rows => 1, cols => 80);
$ti.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 0, x => 8,
input-type => NCTYPE_PRESS, click-count => 2));
is $ti.selected-text, 'world', 'double-click selected the word at column 8';
};
# --- Decorations: highlight spans + ghost suggestions ---
subtest "decoration hooks are unset by default" => {
plan 3;
my $ti = Selkie::Widget::TextInput.new;
nok $ti.highlight-provider.defined, 'no highlight-provider by default';
nok $ti.suggest-provider.defined, 'no suggest-provider by default';
nok $ti.ghost-style.defined, 'no ghost-style override by default';
};
subtest "right at end-of-buffer accepts the suggestion tail" => {
plan 2;
my $ti = typed-input('Elec');
$ti.suggest-provider = -> Str $buf, UInt $ {
$buf eq 'Elec' ?? 'tronics' !! ''
};
my $consumed = $ti.handle-event(key-event(id => NCKEY_RIGHT));
ok $consumed, 'right consumed';
is $ti.text, 'Electronics', 'tail appended to the buffer';
};
subtest "accepting a suggestion emits on-change exactly once" => {
plan 2;
my $ti = typed-input('Elec');
$ti.suggest-provider = -> Str $buf, UInt $ { 'tronics' };
my @changes;
$ti.on-change.tap: -> $v { @changes.push($v) };
$ti.handle-event(key-event(id => NCKEY_RIGHT));
sleep 0.05;
is @changes.elems, 1, 'one on-change emission';
is @changes[0], 'Electronics', 'emission carries the new buffer';
};
subtest "right mid-buffer moves the cursor, never accepts" => {
plan 1;
my $ti = typed-input('Elec');
$ti.suggest-provider = -> Str $buf, UInt $ { 'tronics' };
$ti.handle-event(key-event(id => NCKEY_HOME));
$ti.handle-event(key-event(id => NCKEY_RIGHT));
$ti.handle-event(key-event(id => 'X'.ord, char => 'X'));
is $ti.text, 'EXlec', 'right moved the cursor; no tail inserted';
};
subtest "right at end with no provider is a plain no-op" => {
plan 1;
my $ti = typed-input('Elec');
$ti.handle-event(key-event(id => NCKEY_RIGHT));
is $ti.text, 'Elec', 'buffer unchanged';
};
subtest "right at end with an empty/undefined tail does not insert" => {
plan 2;
my $ti = typed-input('Elec');
$ti.suggest-provider = -> Str $buf, UInt $ { '' };
$ti.handle-event(key-event(id => NCKEY_RIGHT));
is $ti.text, 'Elec', 'empty tail: buffer unchanged';
$ti.suggest-provider = -> Str $buf, UInt $ { Str };
$ti.handle-event(key-event(id => NCKEY_RIGHT));
is $ti.text, 'Elec', 'undefined tail: buffer unchanged';
};
subtest "masked inputs never accept suggestions" => {
plan 1;
my $ti = Selkie::Widget::TextInput.new(mask-char => '*');
$ti.resize(1, 40);
$ti.set-focused(True);
for 'a', 'b', 'c' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
$ti.suggest-provider = -> Str $buf, UInt $ { 'def' };
$ti.handle-event(key-event(id => NCKEY_RIGHT));
is $ti.text, 'abc', 'mask-char set: right at end did not insert';
};
subtest "right with an active selection clears it instead of accepting" => {
plan 2;
my $ti = typed-input('Elec');
$ti.suggest-provider = -> Str $buf, UInt $ { 'tronics' };
$ti.handle-event(key-event(id => 'a'.ord, modifiers => Set(Mod-Ctrl)));
$ti.handle-event(key-event(id => NCKEY_RIGHT));
is $ti.text, 'Elec', 'selection path: no tail inserted';
nok $ti.has-selection, 'selection cleared';
};
# --- Show-password reveal ------------------------------------------------
#
# A masked field is unreadable by design, which is exactly what made a
# login incident invisible: characters the user never typed sat in the
# field as indistinguishable bullets. `set-revealed` is the escape
# hatch a show-password affordance is built on. It is a DISPLAY switch
# only — the buffer, the caret and every Supply behave identically
# either way, so a form submits the same string revealed or hidden.
subtest "a masked input starts masked and reveals on request" => {
plan 6;
my $ti = Selkie::Widget::TextInput.new(mask-char => '*');
ok $ti.masked, 'masked out of the box';
nok $ti.revealed, 'and not revealed';
$ti.set-revealed(True);
ok $ti.revealed, 'revealed after set-revealed(True)';
nok $ti.masked, 'and no longer masked';
$ti.set-revealed(False);
ok $ti.masked, 're-hidden by set-revealed(False)';
nok $ti.revealed, 'and no longer revealed';
};
subtest "revealing changes nothing about the content" => {
plan 5;
my $ti = Selkie::Widget::TextInput.new(mask-char => '*');
$ti.resize(1, 40);
$ti.set-focused(True);
my @changes;
$ti.on-change.tap: -> $t { @changes.push($t) };
for 'p', 'w', '1' -> $c {
$ti.handle-event(key-event(id => $c.ord, char => $c));
}
is $ti.text, 'pw1', 'typed into a masked field';
$ti.set-revealed(True);
is $ti.text, 'pw1', 'the buffer is untouched by revealing';
is @changes.elems, 3, 'and revealing emits nothing on on-change';
$ti.handle-event(key-event(id => '2'.ord, char => '2'));
is $ti.text, 'pw12', 'typing still works while revealed';
$ti.set-revealed(False);
is $ti.text, 'pw12', 'and re-hiding keeps it';
};
subtest "toggle-revealed flips and reports the new state" => {
plan 3;
my $ti = Selkie::Widget::TextInput.new(mask-char => '*');
is $ti.toggle-revealed, True, 'first toggle reveals';
is $ti.toggle-revealed, False, 'second toggle re-hides';
ok $ti.masked, 'and the field is masked again';
};
subtest "an input with no mask-char cannot be revealed" => {
plan 4;
# Nothing to lift, so both calls are no-ops rather than a way to
# turn an ordinary field into a password field by accident.
my $ti = Selkie::Widget::TextInput.new;
nok $ti.masked, 'never masked';
nok $ti.revealed, 'never revealed';
$ti.set-revealed(True);
nok $ti.revealed, 'set-revealed(True) does nothing';
is $ti.toggle-revealed, False, 'and toggle-revealed reports no reveal';
};