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