Selkie.git | t/ | 30-select.rakutest


use Test;
use lib 'lib';

use Selkie::Widget::Select;
use Selkie::Widget;
use Selkie::Event;
use Selkie::EffectiveBounds;
use Notcurses::Native::Types;

use Selkie::Test::Keys;

LEAVE set-terminal-viewport-provider(-> { (1_000, 1_000) });

class ParentStub does Selkie::Widget {
    method render() { self.clear-dirty }
}

plan 31;

subtest "construction with defaults" => {
    plan 5;
    my $s = Selkie::Widget::Select.new;
    ok $s.focusable, "focusable by default";
    is $s.selected, 0, "selected starts at 0";
    nok $s.is-open, "closed by default";
    nok $s.is-focused, "not focused initially";
    is $s.placeholder, '', "empty placeholder by default";
};

subtest "construction with placeholder" => {
    plan 1;
    my $s = Selkie::Widget::Select.new(placeholder => 'Choose...');
    is $s.placeholder, 'Choose...', "placeholder set";
};

subtest "on-change returns Supply" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    isa-ok $s.on-change, Supply, "on-change is Supply";
};

subtest "set-items populates list" => {
    plan 3;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    is $s.items.elems, 3, "3 items";
    is $s.selected-value, 'Alpha', "first item selected";
    ok $s.is-dirty, "dirty after set-items";
};

subtest "set-items preserves selection by value" => {
    plan 4;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    $s.select-index(2);   # Gamma
    $s.open;
    $s.set-items(<Zed Gamma Alpha>);
    is $s.selected, 1, "selection follows Gamma to its new index";
    nok $s.is-open, "dropdown closes on set-items";
    $s.set-items(<X Y>);  # Gamma gone, clamp
    is $s.selected, 1, "clamps when previous value is missing";
    $s.set-items(());
    is $s.selected, 0, "resets on empty";
};

subtest "select-index changes selection" => {
    plan 2;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    $s.select-index(2);
    is $s.selected, 2, "selected is 2";
    is $s.selected-value, 'Gamma', "label is Gamma";
};

subtest "select-index clamps to bounds" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.select-index(10);
    is $s.selected, 1, "clamped to last index";
};

subtest "select-index emits on-change" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    my $emitted = -1;
    $s.on-change.tap: -> $v { $emitted = $v };
    $s.select-index(1);
    is $emitted, 1, "emitted index 1";
};

subtest "select-index with same value does not emit" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    my $count = 0;
    $s.on-change.tap: -> $ { $count++ };
    $s.select-index(0);
    is $count, 0, "no emit when selecting current";
};

subtest "enter opens dropdown when focused and closed" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);
    $s.handle-event(key-event(id => NCKEY_ENTER));
    ok $s.is-open, "open after enter";
};

subtest "space opens dropdown when focused and closed" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);
    $s.handle-event(key-event(id => NCKEY_SPACE));
    ok $s.is-open, "open after space";
};

subtest "open does nothing with no items" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-focused(True);
    $s.open;
    nok $s.is-open, "stays closed with no items";
};

subtest "no open when not focused" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.handle-event(key-event(id => NCKEY_ENTER));
    nok $s.is-open, "stays closed when unfocused";
};

subtest "down arrow moves cursor when open" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    $s.set-focused(True);
    $s.open;
    $s.handle-event(key-event(id => NCKEY_DOWN));
    # Cursor starts at selected (0), now should be 1
    # We confirm by selecting — enter should change selected to 1
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $s.selected, 1, "cursor moved down, selected on enter";
};

subtest "up arrow moves cursor when open" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    $s.select-index(2);
    $s.set-focused(True);
    $s.open;
    $s.handle-event(key-event(id => NCKEY_UP));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $s.selected, 1, "cursor moved up, selected on enter";
};

subtest "esc closes without selecting" => {
    plan 2;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    $s.set-focused(True);
    $s.open;
    $s.handle-event(key-event(id => NCKEY_DOWN));
    $s.handle-event(key-event(id => NCKEY_ESC));
    nok $s.is-open, "closed after esc";
    is $s.selected, 0, "selection unchanged";
};

subtest "enter in dropdown selects and closes" => {
    plan 3;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma>);
    $s.set-focused(True);
    my $emitted = -1;
    $s.on-change.tap: -> $v { $emitted = $v };
    $s.open;
    $s.handle-event(key-event(id => NCKEY_DOWN));
    $s.handle-event(key-event(id => NCKEY_DOWN));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    nok $s.is-open, "closed after selection";
    is $s.selected, 2, "selected is 2";
    is $emitted, 2, "on-change emitted";
};

subtest "selecting same item does not emit" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);
    my $count = 0;
    $s.on-change.tap: -> $ { $count++ };
    $s.open;
    # Cursor starts at selected (0), enter without moving
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $count, 0, "no emit when selecting current";
};

subtest "losing focus closes dropdown" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);
    $s.open;
    $s.set-focused(False);
    nok $s.is-open, "closed when focus lost";
};

subtest "open dropdown consumes all keys" => {
    plan 1;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);
    $s.open;
    ok $s.handle-event(key-event(id => 'x'.ord)), "random key consumed when open";
};

subtest "home and end keys when open" => {
    plan 2;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta Gamma Delta>);
    $s.set-focused(True);
    $s.open;
    $s.handle-event(key-event(id => NCKEY_END));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $s.selected, 3, "end goes to last";
    $s.open;
    $s.handle-event(key-event(id => NCKEY_HOME));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $s.selected, 0, "home goes to first";
};

subtest "max-visible can be configured" => {
    plan 1;
    my $s = Selkie::Widget::Select.new(max-visible => 4);
    is $s.max-visible, 4, "max-visible set to 4";
};

subtest "default debounce prevents rapid mouse retoggle" => {
    plan 3;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-viewport(abs-y => 0, abs-x => 0, rows => 1, cols => 10);

    $s.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 0, x => 0));
    ok $s.is-open, "first click opens dropdown";
    $s.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 0, x => 0));
    ok $s.is-open, "rapid duplicate click does not close dropdown";

    sleep 0.15;
    $s.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 0, x => 0));
    nok $s.is-open, "click after debounce window toggles closed";
};

subtest "default debounce prevents rapid keyboard open-and-commit" => {
    plan 2;
    my $s = Selkie::Widget::Select.new;
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);

    $s.handle-event(key-event(id => NCKEY_ENTER));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    ok $s.is-open, "rapid duplicate Enter leaves dropdown open";

    sleep 0.15;
    $s.handle-event(key-event(id => NCKEY_ENTER));
    nok $s.is-open, "Enter after debounce window commits/closes";
};

subtest "debounce-ms 0 lets rapid activation through" => {
    plan 1;
    my $s = Selkie::Widget::Select.new(debounce-ms => 0);
    $s.set-items(<Alpha Beta>);
    $s.set-focused(True);

    $s.handle-event(key-event(id => NCKEY_ENTER));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    nok $s.is-open, "rapid Enter opens then closes when debounce disabled";
};

subtest "dropdown overlay opens below when it fits" => {
    plan 4;
    set-terminal-viewport-provider(-> { (10, 80) });
    my $s = Selkie::Widget::Select.new(max-visible => 4);
    $s.set-items(<Alpha Beta Gamma Delta>);
    $s.set-viewport(abs-y => 2, abs-x => 5, rows => 1, cols => 12);
    $s.open;
    ok $s.claims-overlay-at(3, 5), "first dropdown row is below control";
    ok $s.claims-overlay-at(6, 5), "last visible row is below control";
    nok $s.claims-overlay-at(1, 5), "does not claim above when below fits";
    nok $s.claims-overlay-at(7, 5), "does not claim past visible rows";
};

subtest "dropdown overlay opens upward when bottom space is tighter" => {
    plan 5;
    set-terminal-viewport-provider(-> { (10, 80) });
    my $s = Selkie::Widget::Select.new(max-visible => 4);
    $s.set-items(<Alpha Beta Gamma Delta>);
    $s.set-viewport(abs-y => 8, abs-x => 5, rows => 1, cols => 12);
    $s.open;
    ok $s.claims-overlay-at(4, 5), "first dropdown row is above control";
    ok $s.claims-overlay-at(7, 5), "last visible row is above control";
    nok $s.claims-overlay-at(9, 5), "does not claim rows below the control";
    ok $s.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 6, x => 6)),
        "mouse click in upward dropdown is consumed";
    is $s.selected, 2, "upward dropdown click maps to the correct option";
};

subtest "long dropdown caps to available space and remains scrollable" => {
    plan 4;
    set-terminal-viewport-provider(-> { (6, 80) });
    my $s = Selkie::Widget::Select.new(max-visible => 10);
    $s.set-items(<A B C D E F G H>);
    $s.set-viewport(abs-y => 2, abs-x => 0, rows => 1, cols => 10);
    $s.set-focused(True);
    $s.open;
    ok $s.claims-overlay-at(3, 0), "capped dropdown starts below control";
    ok $s.claims-overlay-at(5, 0), "uses available rows to terminal bottom";
    nok $s.claims-overlay-at(6, 0), "does not claim past terminal bottom";
    $s.handle-event(key-event(id => NCKEY_END));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $s.selected, 7, "keyboard can scroll to and select hidden options";
};

subtest "ordinary fixed-height parent does not clip dropdown overlay" => {
    plan 5;
    set-terminal-viewport-provider(-> { (30, 80) });
    my $parent = ParentStub.new;
    $parent.resize(2, 80);
    $parent.set-viewport(abs-y => 0, abs-x => 0, rows => 2, cols => 80);

    my $s = Selkie::Widget::Select.new(max-visible => 5);
    $s.parent = $parent;
    $s.set-items(<A B C D E>);
    $s.resize(1, 10);
    $s.set-viewport(abs-y => 1, abs-x => 2, rows => 1, cols => 10);
    $s.open;

    ok $s.claims-overlay-at(2, 2), "first dropdown row escapes below parent row";
    ok $s.claims-overlay-at(6, 2), "last visible row is based on terminal space";
    nok $s.claims-overlay-at(7, 2), "does not claim past max-visible rows";
    ok $s.handle-event(mouse-event(id => NCKEY_BUTTON1, y => 4, x => 3)),
        "mouse click in escaped dropdown is consumed";
    is $s.selected, 2, "escaped dropdown click maps to the correct option";
};

subtest "dropdown ignores unsized parent rows and uses terminal viewport" => {
    plan 4;
    set-terminal-viewport-provider(-> { (30, 80) });
    my $parent = ParentStub.new;
    $parent.set-viewport(abs-y => 0, abs-x => 0, rows => 12, cols => 80);

    my $s = Selkie::Widget::Select.new(max-visible => 3);
    $s.parent = $parent;
    $s.set-items(<A B C>);
    $s.resize(1, 10);
    $s.set-viewport(abs-y => 5, abs-x => 2, rows => 1, cols => 10);
    $s.open;

    ok $s.claims-overlay-at(6, 2), "first dropdown row is below control";
    ok $s.claims-overlay-at(8, 2), "last row uses terminal viewport height";
    nok $s.claims-overlay-at(4, 2), "does not incorrectly flip upward";
    nok $s.claims-overlay-at(9, 2), "does not claim past visible rows";
};

subtest "long dropdown caps to terminal and remains scrollable in a short parent" => {
    plan 4;
    set-terminal-viewport-provider(-> { (6, 80) });
    my $parent = ParentStub.new;
    $parent.resize(4, 80);
    $parent.set-viewport(abs-y => 0, abs-x => 0, rows => 4, cols => 80);

    my $s = Selkie::Widget::Select.new(max-visible => 10);
    $s.parent = $parent;
    $s.set-items(<A B C D E F G H>);
    $s.resize(1, 10);
    $s.set-viewport(abs-y => 1, abs-x => 2, rows => 1, cols => 10);
    $s.set-focused(True);
    $s.open;

    ok $s.claims-overlay-at(2, 2), "capped dropdown starts below control";
    ok $s.claims-overlay-at(5, 2), "uses available terminal rows";
    nok $s.claims-overlay-at(6, 2), "does not claim past terminal bottom";
    $s.handle-event(key-event(id => NCKEY_END));
    $s.handle-event(key-event(id => NCKEY_ENTER));
    is $s.selected, 7, "keyboard can scroll to hidden options in terminal-capped menu";
};