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