Selkie.git | t/ | 04-event.rakutest


use Test;
use lib 'lib';

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

plan 16;

subtest "Keybind.parse - simple key" => {
    plan 2;
    my $kb = Keybind.parse('q', -> $ { });
    is $kb.id, 'q'.ord, "id is ord of q";
    is $kb.modifiers.elems, 0, "no modifiers";
};

subtest "Keybind.parse - ctrl+key" => {
    plan 2;
    my $kb = Keybind.parse('ctrl+q', -> $ { });
    is $kb.id, 'q'.ord, "id is ord of q";
    ok Mod-Ctrl ∈ $kb.modifiers, "ctrl modifier present";
};

subtest "Keybind.parse - alt+key" => {
    plan 2;
    my $kb = Keybind.parse('alt+s', -> $ { });
    is $kb.id, 's'.ord, "id is ord of s";
    ok Mod-Alt ∈ $kb.modifiers, "alt modifier present";
};

subtest "Keybind.parse - multiple modifiers" => {
    plan 3;
    my $kb = Keybind.parse('ctrl+shift+x', -> $ { });
    is $kb.id, 'x'.ord, "id is ord of x";
    ok Mod-Ctrl ∈ $kb.modifiers, "ctrl present";
    ok Mod-Shift ∈ $kb.modifiers, "shift present";
};

subtest "Keybind.parse - special keys" => {
    plan 6;
    is Keybind.parse('enter', -> $ { }).id, NCKEY_ENTER, "enter";
    is Keybind.parse('tab', -> $ { }).id, NCKEY_TAB, "tab";
    is Keybind.parse('esc', -> $ { }).id, NCKEY_ESC, "esc";
    is Keybind.parse('space', -> $ { }).id, NCKEY_SPACE, "space";
    is Keybind.parse('backspace', -> $ { }).id, NCKEY_BACKSPACE, "backspace";
    is Keybind.parse('delete', -> $ { }).id, NCKEY_DEL, "delete";
};

subtest "Keybind.parse - function keys" => {
    plan 3;
    is Keybind.parse('f1', -> $ { }).id, NCKEY_F00 + 1, "f1";
    is Keybind.parse('f12', -> $ { }).id, NCKEY_F00 + 12, "f12";
    is Keybind.parse('ctrl+f5', -> $ { }).id, NCKEY_F00 + 5, "ctrl+f5";
};

subtest "Keybind.parse - navigation keys" => {
    plan 4;
    is Keybind.parse('up', -> $ { }).id, NCKEY_UP, "up";
    is Keybind.parse('down', -> $ { }).id, NCKEY_DOWN, "down";
    is Keybind.parse('left', -> $ { }).id, NCKEY_LEFT, "left";
    is Keybind.parse('right', -> $ { }).id, NCKEY_RIGHT, "right";
};

subtest "Keybind.parse - ctrl+digit, all ten digits" => {
    plan 22;
    # Consumers bind perspective/tab hotkeys as ctrl+1..ctrl+9 and wrap
    # the tenth onto ctrl+0 (Mindmoor's perspective row). Digits go
    # through the generic single-char arm of the parser, so all ten —
    # including 0 — must parse and match with an exact Ctrl modifier
    # set. Pinned here because at least one consumer comment claimed
    # ctrl+0 was unsupported; it never was.
    for '0' .. '9' -> $d {
        my $kb = Keybind.parse("ctrl+$d", -> $ { });
        is $kb.id, $d.ord, "ctrl+$d parses to codepoint of '$d'";
        my $ev = Selkie::Event.new(
            id => $d.ord,
            modifiers => Set(Mod-Ctrl),
            input-type => NCTYPE_PRESS,
            event-type => KeyEvent,
        );
        ok $kb.matches($ev), "ctrl+$d keybind matches ctrl+$d event";
    }
    my $kb0 = Keybind.parse('ctrl+0', -> $ { });
    my $plain = Selkie::Event.new(
        id => '0'.ord,
        modifiers => Set(),
        input-type => NCTYPE_PRESS,
        event-type => KeyEvent,
    );
    nok $kb0.matches($plain), "ctrl+0 does not match a plain 0 keystroke";
    dies-ok { Keybind.parse('ctrl+10', -> $ { }) },
        "two-digit ctrl+10 is rejected (no such key name)";
};

subtest "Keybind.parse - invalid modifier dies" => {
    plan 1;
    dies-ok { Keybind.parse('foo+q', -> $ { }) }, "unknown modifier dies";
};

subtest "Keybind.parse - invalid key dies" => {
    plan 1;
    dies-ok { Keybind.parse('f99', -> $ { }) }, "invalid function key dies";
};

subtest "Keybind.matches" => {
    plan 3;
    my $kb = Keybind.parse('ctrl+q', -> $ { });

    my $ev-match = Selkie::Event.new(
        id => 'q'.ord,
        modifiers => Set(Mod-Ctrl),
        input-type => NCTYPE_PRESS,
        event-type => KeyEvent,
    );
    ok $kb.matches($ev-match), "matches ctrl+q event";

    my $ev-no-mod = Selkie::Event.new(
        id => 'q'.ord,
        modifiers => Set(),
        input-type => NCTYPE_PRESS,
        event-type => KeyEvent,
    );
    nok $kb.matches($ev-no-mod), "does not match plain q";

    my $ev-wrong-mod = Selkie::Event.new(
        id => 'q'.ord,
        modifiers => Set(Mod-Alt),
        input-type => NCTYPE_PRESS,
        event-type => KeyEvent,
    );
    nok $kb.matches($ev-wrong-mod), "does not match alt+q";
};

subtest "Keybind.parse - literal '+' key" => {
    plan 9;

    # Plain '+' — historically this died because '+'.split('+') yields
    # ('','') and the parser saw an empty modifier. Now special-cased.
    my $plus = Keybind.parse('+', -> $ { });
    is $plus.id, '+'.ord, "plain '+' parses as the '+' codepoint";
    is $plus.modifiers.elems, 0, "no modifiers";

    # Modifier + '+': trailing '++' peels off as the key.
    my $shift-plus = Keybind.parse('shift++', -> $ { });
    is $shift-plus.id, '+'.ord, "shift++ id is '+'";
    ok Mod-Shift ∈ $shift-plus.modifiers, "shift++ has Mod-Shift";

    my $ctrl-plus = Keybind.parse('ctrl++', -> $ { });
    is $ctrl-plus.id, '+'.ord, "ctrl++ id is '+'";
    ok Mod-Ctrl ∈ $ctrl-plus.modifiers, "ctrl++ has Mod-Ctrl";

    my $ctrl-shift-plus = Keybind.parse('ctrl+shift++', -> $ { });
    is $ctrl-shift-plus.id, '+'.ord, "ctrl+shift++ id is '+'";
    ok Mod-Ctrl  ∈ $ctrl-shift-plus.modifiers, "ctrl+shift++ has Mod-Ctrl";
    ok Mod-Shift ∈ $ctrl-shift-plus.modifiers, "ctrl+shift++ has Mod-Shift";
};

# --- from-ncinput: modifier normalisation ----------------------------------
#
# Selkie::Event.from-ncinput is the single place raw notcurses input
# becomes a Selkie event, so it is where the three keyboard encodings
# are flattened into one modifier Set. The tests below pin the two
# legacy decodings, because they are invisible in the ncinput
# `modifiers` bitmask and easy to lose in a refactor.

subtest "from-ncinput - modern modifier bits" => {
    plan 4;
    my $ev = Selkie::Event.from-ncinput(
        Ncinput.new(id => NCKEY_ENTER, modifiers => NCKEY_MOD_ALT));
    ok Mod-Alt ∈ $ev.modifiers, 'NCKEY_MOD_ALT decoded to Mod-Alt';
    is $ev.modifiers.elems, 1, 'and nothing else';

    my $both = Selkie::Event.from-ncinput(Ncinput.new(
        id        => NCKEY_ENTER,
        modifiers => NCKEY_MOD_ALT +| NCKEY_MOD_SHIFT,
    ));
    ok Mod-Alt   ∈ $both.modifiers, 'Alt in a combo';
    ok Mod-Shift ∈ $both.modifiers, 'Shift in a combo';
};

subtest "from-ncinput - legacy ESC-prefix Alt" => {
    plan 4;
    # Terminals without the kitty protocol / XTMODKEYS send Alt+key as
    # ESC then key. notcurses folds the pair into one ncinput but
    # records it only in the deprecated `alt` boolean, leaving the
    # `modifiers` bitmask at zero (src/lib/automaton.c, escape-root
    # fallback). Without this normalisation Alt+Enter would be
    # indistinguishable from a bare Enter on every legacy terminal.
    my $ev = Selkie::Event.from-ncinput(
        Ncinput.new(id => NCKEY_ENTER, alt => 1));
    ok Mod-Alt ∈ $ev.modifiers, 'legacy alt boolean decoded to Mod-Alt';
    is $ev.modifiers.elems, 1, 'no other modifier fabricated';
    is $ev.id, NCKEY_ENTER, 'id preserved';

    # Same for a printable key, which is the far more common case.
    my $alt-x = Selkie::Event.from-ncinput(
        Ncinput.new(id => 'x'.ord, alt => 1));
    ok Mod-Alt ∈ $alt-x.modifiers, 'legacy Alt+x decoded';
};

subtest "from-ncinput - no Alt means no Mod-Alt" => {
    plan 2;
    my $ev = Selkie::Event.from-ncinput(Ncinput.new(id => NCKEY_ENTER));
    nok Mod-Alt ∈ $ev.modifiers, 'bare Enter carries no Alt';
    is $ev.modifiers.elems, 0, 'no modifiers at all';
};

subtest "from-ncinput - both encodings match the same keybind" => {
    plan 3;
    # The point of normalising: one 'alt+enter' bind fires whatever
    # the terminal speaks.
    my $kb = Keybind.parse('alt+enter', -> $ { });
    my $modern = Selkie::Event.from-ncinput(
        Ncinput.new(id => NCKEY_ENTER, modifiers => NCKEY_MOD_ALT));
    my $legacy = Selkie::Event.from-ncinput(
        Ncinput.new(id => NCKEY_ENTER, alt => 1));
    ok $kb.matches($modern), 'kitty / XTMODKEYS encoding matches';
    ok $kb.matches($legacy), 'legacy ESC-prefix encoding matches';
    nok $kb.matches(Selkie::Event.from-ncinput(Ncinput.new(id => NCKEY_ENTER))),
        'a bare Enter still does not';
};