Selkie.git | t/ | 17-button.rakutest
use Test;
use lib 'lib';
use Selkie::Widget::Button;
use Selkie::Event;
use Selkie::Layout::VBox;
use Selkie::Sizing;
use Selkie::Test::Keys;
use Notcurses::Native::Types;
plan 17;
subtest "construction" => {
plan 3;
my $b = Selkie::Widget::Button.new(label => 'OK');
is $b.label, 'OK', "label set";
ok $b.focusable, "focusable by default";
nok $b.is-focused, "not focused initially";
};
subtest "on-press returns Supply" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
isa-ok $b.on-press, Supply, "on-press is Supply";
};
subtest "enter triggers press when focused" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
$b.set-focused(True);
my $pressed = False;
$b.on-press.tap: -> $ { $pressed = True };
$b.handle-event(key-event(id => NCKEY_ENTER));
ok $pressed, "enter triggers press";
};
subtest "space triggers press when focused" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
$b.set-focused(True);
my $pressed = False;
$b.on-press.tap: -> $ { $pressed = True };
$b.handle-event(key-event(id => NCKEY_SPACE));
ok $pressed, "space triggers press";
};
subtest "no press when not focused" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
my $pressed = False;
$b.on-press.tap: -> $ { $pressed = True };
$b.handle-event(key-event(id => NCKEY_ENTER));
nok $pressed, "no press when unfocused";
};
subtest "set-focused marks dirty" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
$b.clear-dirty;
$b.set-focused(True);
ok $b.is-dirty, "dirty after focus change";
};
subtest "unrelated key not consumed" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
$b.set-focused(True);
nok $b.handle-event(key-event(id => 'x'.ord)), "x not consumed";
};
subtest "keybind on button works" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
$b.set-focused(True);
my $called = False;
$b.on-key: 'ctrl+q', -> $ { $called = True };
$b.handle-event(key-event(id => 'q'.ord, modifiers => Set(Mod-Ctrl)));
ok $called, "keybind fires";
};
# --- Arrow-key focus cycling -----------------------------------------------
#
# Modal action rows lay buttons out horizontally. Tab/Shift+Tab work
# globally, but most users reach for arrow keys first. Left/Right
# dispatch the same focus events App's global Tab keybinds use, so
# the focus-cycle semantics stay identical (same focusable order,
# modal traps, etc.). These subtests verify the dispatch happens
# only when focused and only with no modifiers held.
use Selkie::Store;
subtest "right arrow dispatches ui/focus-next when focused" => {
plan 2;
my $b = Selkie::Widget::Button.new(label => 'OK');
my $store = Selkie::Store.new;
$b.set-store($store);
$b.set-focused(True);
my $fired = False;
$store.register-handler('ui/focus-next', -> $st, %ev {
$fired = True;
();
});
my $consumed = $b.handle-event(key-event(id => NCKEY_RIGHT));
$store.tick;
ok $consumed, 'event consumed';
ok $fired, 'ui/focus-next dispatched';
};
subtest "left arrow dispatches ui/focus-prev when focused" => {
plan 2;
my $b = Selkie::Widget::Button.new(label => 'OK');
my $store = Selkie::Store.new;
$b.set-store($store);
$b.set-focused(True);
my $fired = False;
$store.register-handler('ui/focus-prev', -> $st, %ev {
$fired = True;
();
});
my $consumed = $b.handle-event(key-event(id => NCKEY_LEFT));
$store.tick;
ok $consumed, 'event consumed';
ok $fired, 'ui/focus-prev dispatched';
};
subtest "modifier-held arrow keys are not consumed" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK');
my $store = Selkie::Store.new;
$b.set-store($store);
$b.set-focused(True);
# Shift+Right should NOT trigger focus-next — that's some other
# widget's binding (text-input word-jump etc.) and Button must
# not eat it. The bare-arrow case is a Button affordance; with
# any modifier held the event bubbles up.
nok $b.handle-event(key-event(id => NCKEY_RIGHT, modifiers => Set(Mod-Shift))),
'Shift+Right not consumed by Button';
};
subtest "primary click fires on-press supply" => {
plan 2;
my $b = Selkie::Widget::Button.new(label => 'OK');
# No set-focused — App's click-to-focus runs upstream of
# handle-event, but TWEAK's on-click registration doesn't depend
# on focus state, so a hit-tested press still activates.
my $pressed = False;
$b.on-press.tap: -> $ { $pressed = True };
my $consumed = $b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
ok $consumed, 'click event consumed';
ok $pressed, 'on-press fired from primary click';
};
subtest "debounce-ms default collapses rapid clicks" => {
plan 2;
my $b = Selkie::Widget::Button.new(label => 'OK');
my $count = 0;
$b.on-press.tap: -> $ { $count++ };
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
is $count, 1, 'second rapid click suppressed by default';
sleep 0.15;
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
is $count, 2, 'click after default window emits';
};
subtest "debounce-ms 0 lets every click through" => {
plan 1;
my $b = Selkie::Widget::Button.new(label => 'OK', debounce-ms => 0);
my $count = 0;
$b.on-press.tap: -> $ { $count++ };
for ^3 {
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
}
is $count, 3, 'three clicks -> three emits with debounce-ms=0';
};
subtest "debounce-ms collapses rapid emits" => {
plan 2;
my $b = Selkie::Widget::Button.new(label => 'OK', debounce-ms => 120);
my $count = 0;
$b.on-press.tap: -> $ { $count++ };
# Two clicks in immediate succession — the second falls inside
# the debounce window and is dropped.
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
is $count, 1, 'second click within window suppressed';
# Wait past the window, then a third click is allowed through.
sleep 0.15;
$b.handle-event(
mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS,
click-count => 1)
);
is $count, 2, 'click after window emits';
};
subtest "debounce-ms also gates keyboard activation" => {
plan 1;
# Mouse and keyboard share the same emit chokepoint, so a
# repeat-key burst on a focused button should also collapse.
my $b = Selkie::Widget::Button.new(label => 'OK', debounce-ms => 120);
$b.set-focused(True);
my $count = 0;
$b.on-press.tap: -> $ { $count++ };
$b.handle-event(key-event(id => NCKEY_ENTER));
$b.handle-event(key-event(id => NCKEY_ENTER));
$b.handle-event(key-event(id => NCKEY_SPACE));
is $count, 1, 'three rapid activations → one emit';
};
subtest "a disabled button leaves the focus cycle but stays focusable" => {
plan 5;
# focusable and disabled are separate axes on purpose: disabling a
# button must not clobber the thing that says it is a button. If it
# did, re-enabling would have to guess.
my $vbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
my $b = Selkie::Widget::Button.new(label => 'OK', sizing => Sizing.fixed(1));
$vbox.add($b);
nok $b.disabled, 'not disabled by default';
is $vbox.focusable-descendants.List.elems, 1, 'reachable by Tab';
$b.set-disabled(True);
is $vbox.focusable-descendants.List.elems, 0, 'disabled drops out of the cycle';
ok $b.focusable, 'but focusable is untouched';
$b.set-disabled(False);
is $vbox.focusable-descendants.List.elems, 1, 're-enabling puts it back';
};