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