Selkie.git | t/ | 59-mouse-dispatch.rakutest


use Test;
use lib 'lib';

use Notcurses::Native::Types;

use Selkie::App;
use Selkie::App::Internal::Dispatch;
use Selkie::Widget;
use Selkie::Container;
use Selkie::Widget::Button;
use Selkie::Widget::Modal;
use Selkie::Widget::TextInput;
use Selkie::Event;
use Selkie::Test::Keys;

# Mouse dispatch foundation. Most of the App-instance flow (capture,
# click-to-focus, modal isolation) needs notcurses_init and is covered
# by manual smoke in App-Cantina (see the v1 plan); this file pins
# down the pieces that ARE testable in-process: hit-testing, the
# event-classification helpers, the Selkie::Event annotation API, the
# default Widget.handle-event mouse fan-out, and Modal's new
# dismiss-on-click-outside default.

# Tiny test container — same pattern as t/focus-invariant.rakutest.
class TestContainer does Selkie::Container {
    method render() { self!render-children; self.clear-dirty }
}

# Helper: position a widget on screen by setting its viewport state
# directly. In production the layout pass does this — in tests we want
# fast deterministic placement without spinning notcurses.
sub place(Selkie::Widget $w, :$abs-y!, :$abs-x!, :$rows!, :$cols!) {
    $w.set-viewport(:$abs-y, :$abs-x, :$rows, :$cols);
}

plan 18;

subtest "mouse-event-button extracts the 1-indexed button" => {
    plan 6;
    is mouse-event-button(mouse-event(id => NCKEY_BUTTON1)), 1, 'BUTTON1 → 1';
    is mouse-event-button(mouse-event(id => NCKEY_BUTTON2)), 2, 'BUTTON2 → 2';
    is mouse-event-button(mouse-event(id => NCKEY_BUTTON3)), 3, 'BUTTON3 → 3';
    is mouse-event-button(mouse-event(id => NCKEY_SCROLL_UP)), 4,
        'SCROLL_UP is button 4 by encoding';
    is mouse-event-button(mouse-event(id => NCKEY_SCROLL_DOWN)), 5,
        'SCROLL_DOWN is button 5';
    is mouse-event-button(mouse-event(id => NCKEY_MOTION)), 0,
        'pure motion has no button';
};

subtest "mouse-event-kinds fans out by input type" => {
    plan 5;
    is-deeply mouse-event-kinds(
        mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS)
    ).List, <click mouse-down>.List,
        'press fans out to click + mouse-down';
    is-deeply mouse-event-kinds(
        mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_RELEASE)
    ).List, ('mouse-up',).List,
        'release fires mouse-up only';
    is-deeply mouse-event-kinds(
        mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_REPEAT)
    ).List, ('drag',).List,
        'repeat (button held + motion) fires drag';
    is-deeply mouse-event-kinds(
        mouse-event(id => NCKEY_SCROLL_UP)
    ).List, ('scroll',).List,
        'scroll wheel fires scroll regardless of input-type';
    is-deeply mouse-event-kinds(
        mouse-event(id => NCKEY_MOTION)
    ).List, ('drag',).List,
        'pure motion classifies as drag';
};

subtest "Widget.contains-point uses viewport rect" => {
    plan 6;
    my $w = Selkie::Widget::Button.new(label => 'x');
    nok $w.contains-point(0, 0),
        'unmounted widget (zero viewport) contains nothing';

    place($w, abs-y => 5, abs-x => 10, rows => 3, cols => 8);
    ok  $w.contains-point(5, 10),  'top-left corner is inside';
    ok  $w.contains-point(7, 17),  'bottom-right corner (5+3-1, 10+8-1) is inside';
    nok $w.contains-point(4, 10),  'one cell above is outside';
    nok $w.contains-point(8, 10),  'one row past bottom is outside';
    nok $w.contains-point(5, 18),  'one col past right is outside';
};

subtest "Widget.local-row / local-col" => {
    plan 4;
    my $w = Selkie::Widget::Button.new(label => 'x');
    place($w, abs-y => 5, abs-x => 10, rows => 3, cols => 8);
    is $w.local-row(mouse-event(id => NCKEY_BUTTON1, y => 5, x => 10)), 0,
        'top of widget → local row 0';
    is $w.local-col(mouse-event(id => NCKEY_BUTTON1, y => 5, x => 12)), 2,
        'col 12 against abs-x 10 → 2';
    is $w.local-row(mouse-event(id => NCKEY_BUTTON1, y => 9, x => 10)), -1,
        'event below widget → -1';
    is $w.local-col(mouse-event(id => NCKEY_BUTTON1, y => 5, x => 5)), -1,
        'event left of widget → -1';
};

subtest "App.widget-at-in: deepest hit wins" => {
    plan 4;
    my $root = TestContainer.new;
    my $a = Selkie::Widget::Button.new(label => 'A');
    my $b = Selkie::Widget::Button.new(label => 'B');
    $root.add($a);
    $root.add($b);

    place($root, abs-y => 0, abs-x => 0, rows => 10, cols => 20);
    place($a,    abs-y => 0, abs-x => 0, rows => 5,  cols => 10);
    place($b,    abs-y => 5, abs-x => 0, rows => 5,  cols => 10);

    is Selkie::App.widget-at-in($root, 1, 1).label, 'A',
        'click in upper region resolves to A';
    is Selkie::App.widget-at-in($root, 6, 1).label, 'B',
        'click in lower region resolves to B';
    is Selkie::App.widget-at-in($root, 0, 15), $root,
        'click in root-only area (no child rect) resolves to root';
    nok Selkie::App.widget-at-in($root, 100, 100).defined,
        'click outside root returns the type object';
};

subtest "App.widget-at-in: nested children resolve to leaf" => {
    plan 2;
    my $root = TestContainer.new;
    my $mid  = TestContainer.new;
    my $leaf = Selkie::Widget::Button.new(label => 'leaf');
    $root.add($mid);
    $mid.add($leaf);

    place($root, abs-y => 0, abs-x => 0, rows => 10, cols => 20);
    place($mid,  abs-y => 1, abs-x => 1, rows => 8,  cols => 18);
    place($leaf, abs-y => 2, abs-x => 2, rows => 4,  cols => 10);

    is Selkie::App.widget-at-in($root, 3, 5).label, 'leaf',
        'click inside leaf resolves through root → mid → leaf';
    is Selkie::App.widget-at-in($root, 1, 1), $mid,
        'click in mid-only band (above leaf) resolves to mid';
};

subtest "Selkie::Event.with-click-count clones into a new event" => {
    plan 4;
    my $base = mouse-event(id => NCKEY_BUTTON1, y => 7, x => 13);
    my $two  = $base.with-click-count(2);
    is $two.click-count, 2, 'cloned event reports the new count';
    is $two.y, 7, 'y preserved across clone';
    is $two.x, 13, 'x preserved across clone';
    is $base.click-count, 0, 'original event is untouched (immutability)';
};

subtest "Widget.on-click registers a click handler" => {
    plan 3;
    my class TestWidget does Selkie::Widget {
        method render() { self.clear-dirty }
    }
    my $w = TestWidget.new;
    my $fired = 0;
    $w.on-click(-> $ev { $fired++ });
    is $w.mouse-handlers.elems, 1, 'one handler registered';
    is $w.mouse-handlers[0].kind, 'click', 'kind is "click"';
    is $w.mouse-handlers[0].button, 1, 'default button is 1';
};

subtest "Widget.handle-event default routes a press through on-click" => {
    plan 3;
    # Plain Selkie::Widget composer so the default handle-event runs
    # (Button overrides handle-event with its own logic).
    my class TestWidget does Selkie::Widget {
        method render() { self.clear-dirty }
    }
    my $w = TestWidget.new;
    place($w, abs-y => 0, abs-x => 0, rows => 1, cols => 1);
    my @fired;
    $w.on-click(-> $ev { @fired.push: $ev.click-count });

    my $ev = mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, click-count => 1);
    ok $w.handle-event($ev), 'press is consumed by registered click handler';
    is @fired.elems, 1, 'handler fired exactly once';
    is @fired[0], 1, 'handler saw the click-count annotation';
};

subtest "Widget.handle-event ignores press when no matching button registered" => {
    plan 2;
    my class TestWidget does Selkie::Widget {
        method render() { self.clear-dirty }
    }
    my $w = TestWidget.new;
    place($w, abs-y => 0, abs-x => 0, rows => 1, cols => 1);
    $w.on-click(-> $ev { ; }, button => 2);
    my $primary-press = mouse-event(input-type => NCTYPE_PRESS, id => NCKEY_BUTTON1);
    nok $w.handle-event($primary-press),
        'button-1 press doesn\'t fire a button-2 handler';
    my $middle-press = mouse-event(input-type => NCTYPE_PRESS, id => NCKEY_BUTTON2);
    ok $w.handle-event($middle-press),
        'button-2 press fires the button-2 handler';
};

subtest "Modal default dismiss-on-click-outside is False" => {
    plan 2;
    my $m = Selkie::Widget::Modal.new;
    nok $m.dismiss-on-click-outside,
        'default is the focus-trap behavior — clicks outside are dropped';
    my $opt-in = Selkie::Widget::Modal.new(dismiss-on-click-outside => True);
    ok $opt-in.dismiss-on-click-outside,
        'opt-in flag flips on construction';
};

subtest "widget-at-in routes through overlay claims" => {
    # Regression for the dropdown-in-Select bug: widgets that paint
    # over the layout flow (open Selects, popovers) must capture clicks
    # on their overlay area even when the parent's contains-point
    # would otherwise hand the click to a sibling sitting underneath.
    plan 3;
    my class OverlayWidget does Selkie::Widget {
        has Bool $.open is rw = False;
        method render() { self.clear-dirty }
        method claims-overlay-at(Int $y, Int $x --> Bool) {
            return False unless $!open;
            $y >= self.abs-y && $y < self.abs-y + 4
                && $x >= self.abs-x && $x < self.abs-x + 8;
        }
    }
    my $root = TestContainer.new;
    place($root, abs-y => 0, abs-x => 0, rows => 5, cols => 20);

    my $top = OverlayWidget.new;
    my $bot = Selkie::Widget::Button.new(label => 'underneath');
    $root.add($top);
    $root.add($bot);
    place($top, abs-y => 0, abs-x => 0, rows => 1, cols => 8);
    place($bot, abs-y => 1, abs-x => 0, rows => 1, cols => 8);

    # Closed: click on row 1 lands on the bottom button (normal
    # containment walk).
    is Selkie::App.widget-at-in($root, 1, 2).label, 'underneath',
        'closed overlay defers to layout flow';

    # Opened: same click is captured by the overlay widget — its
    # painted region extends down to row 3.
    $top.open = True;
    is Selkie::App.widget-at-in($root, 1, 2), $top,
        'open overlay captures clicks past its own viewport';

    # Click outside the overlay's painted region (still inside the
    # root container) falls back to containment walk and lands on
    # the root — no descendant covers row 4.
    is Selkie::App.widget-at-in($root, 4, 2), $root,
        'click below the overlay extent falls back to containment walk';
};

subtest "internal mouse dispatch uses hit-test helper directly" => {
    plan 2;
    my class DispatchHarness does Selkie::App::Internal::Dispatch {
        has $.root is rw;
        has $.focused is rw;

        method !active-modal() { Nil }
        method focus($widget --> Nil) { $!focused = $widget }
        method widget-attached($widget, $root --> Bool) { True }
        method close-modal(--> Nil) { }
        method !check-terminal-resize(--> Nil) { }
        method dispatch-event-for-test(Selkie::Event $ev --> Nil) {
            self!dispatch-event($ev);
        }
    }

    my class HitWidget does Selkie::Widget {
        has Int $.hits is rw = 0;
        method render() { self.clear-dirty }
        method handle-event(Selkie::Event $ev --> Bool) {
            $!hits++ if $ev.event-type ~~ MouseEvent;
            True;
        }
    }

    my $root = TestContainer.new;
    my $hit = HitWidget.new;
    $root.add($hit);
    place($root, abs-y => 0, abs-x => 0, rows => 3, cols => 10);
    place($hit, abs-y => 0, abs-x => 0, rows => 1, cols => 6);

    my $h = DispatchHarness.new(root => $root);
    lives-ok {
        $h.dispatch-event-for-test(mouse-event(
            id => NCKEY_BUTTON1,
            input-type => NCTYPE_PRESS,
            y => 0,
            x => 1,
        ));
    }, 'mouse dispatch survives hit-test through internal helper';
    is $hit.hits, 1, 'click delivered to hit widget';
};

subtest "duplicate press on exposed debounced control is suppressed" => {
    plan 3;
    my class DispatchHarness does Selkie::App::Internal::Dispatch {
        has $.root is rw;
        has $.focused is rw;

        method !active-modal() { Nil }
        method focus($widget --> Nil) { $!focused = $widget }
        method widget-attached($widget, $root --> Bool) { True }
        method close-modal(--> Nil) { }
        method !check-terminal-resize(--> Nil) { }
        method dispatch-event-for-test(Selkie::Event $ev --> Nil) {
            self!dispatch-event($ev);
        }
    }

    my class DebouncedHit does Selkie::Widget {
        has UInt $.debounce-ms = 120;
        has Int $.hits is rw = 0;
        has Bool $.park-on-hit = False;
        method render() { self.clear-dirty }
        method handle-event(Selkie::Event $ev --> Bool) {
            if $ev.event-type ~~ MouseEvent && $ev.input-type == NCTYPE_PRESS {
                $!hits++;
                self.set-viewport(abs-y => 99, abs-x => 0, rows => 1, cols => 6)
                    if $!park-on-hit;
                return True;
            }
            False;
        }
    }

    my $root = TestContainer.new;
    my $under = DebouncedHit.new;
    my $top = DebouncedHit.new(park-on-hit => True);
    $root.add($under);
    $root.add($top);
    place($root, abs-y => 0, abs-x => 0, rows => 3, cols => 10);
    place($under, abs-y => 0, abs-x => 0, rows => 1, cols => 6);
    place($top, abs-y => 0, abs-x => 0, rows => 1, cols => 6);

    my $h = DispatchHarness.new(root => $root);
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 1));
    is $top.hits, 1, 'first click delivered to top debounced control';

    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 1));
    is $under.hits, 0, 'duplicate press does not fall through to exposed control';

    sleep 0.15;
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 1));
    is $under.hits, 1, 'press after debounce window reaches exposed control';
};

subtest "driver duplicate press is suppressed for plain widgets, real double-click survives" => {
    plan 4;
    my class DispatchHarness does Selkie::App::Internal::Dispatch {
        has $.root is rw;
        has $.focused is rw;

        method !active-modal() { Nil }
        method focus($widget --> Nil) { $!focused = $widget }
        method widget-attached($widget, $root --> Bool) { True }
        method close-modal(--> Nil) { }
        method !check-terminal-resize(--> Nil) { }
        method dispatch-event-for-test(Selkie::Event $ev --> Nil) {
            self!dispatch-event($ev);
        }
    }

    # No debounce-ms method — a ListView/Table stand-in that records
    # each delivered press and its click-count annotation.
    my class PlainHit does Selkie::Widget {
        has Int $.hits is rw = 0;
        has @.counts;
        method render() { self.clear-dirty }
        method handle-event(Selkie::Event $ev --> Bool) {
            if $ev.event-type ~~ MouseEvent && $ev.input-type == NCTYPE_PRESS {
                $!hits++;
                @!counts.push($ev.click-count);
                return True;
            }
            False;
        }
    }

    my $root = TestContainer.new;
    my $list = PlainHit.new;
    $root.add($list);
    place($root, abs-y => 0, abs-x => 0, rows => 3, cols => 10);
    place($list, abs-y => 0, abs-x => 0, rows => 1, cols => 6);

    my $h = DispatchHarness.new(root => $root);
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 2));
    is $list.hits, 1, 'first press delivered to plain widget';

    # Same cell, same drain — a terminal-driver duplicate, not a user
    # double-click. Must not deliver, and must not become click-count 2.
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 2));
    is $list.hits, 1, 'driver duplicate press suppressed for plain widget';

    # A human double-click's second press arrives well past the
    # duplicate window but inside the double-click window.
    sleep 0.08;
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 2));
    is $list.hits, 2, 'second click after duplicate window delivered';
    is $list.counts.tail, 2, 'and still counts as a double-click';
};

subtest "wheel events are not clicks: no suppression, no capture" => {
    plan 3;
    my class DispatchHarness does Selkie::App::Internal::Dispatch {
        has $.root is rw;
        has $.focused is rw;

        method !active-modal() { Nil }
        method focus($widget --> Nil) { $!focused = $widget }
        method widget-attached($widget, $root --> Bool) { True }
        method close-modal(--> Nil) { }
        method !check-terminal-resize(--> Nil) { }
        method dispatch-event-for-test(Selkie::Event $ev --> Nil) {
            self!dispatch-event($ev);
        }
    }

    # A debounced control (Button stand-in) that also counts scroll
    # deliveries: flick-scrolling a form must not lose wheel events
    # to the control's press-debounce window.
    my class DebouncedScrollable does Selkie::Widget {
        has UInt $.debounce-ms = 120;
        has Int $.scrolls is rw = 0;
        has Int $.motions is rw = 0;
        method render() { self.clear-dirty }
        method handle-event(Selkie::Event $ev --> Bool) {
            if $ev.event-type ~~ MouseEvent {
                $!scrolls++ if $ev.id == NCKEY_SCROLL_UP
                            || $ev.id == NCKEY_SCROLL_DOWN;
                $!motions++ if $ev.id == NCKEY_MOTION;
                return True;
            }
            False;
        }
    }

    my $root = TestContainer.new;
    my $ctl = DebouncedScrollable.new;
    $root.add($ctl);
    place($root, abs-y => 0, abs-x => 0, rows => 3, cols => 10);
    place($ctl, abs-y => 0, abs-x => 0, rows => 1, cols => 6);

    my $h = DispatchHarness.new(root => $root);
    for ^3 {
        $h.dispatch-event-for-test(mouse-event(
            id => NCKEY_SCROLL_UP, input-type => NCTYPE_PRESS, y => 0, x => 3));
    }
    is $ctl.scrolls, 3,
        'rapid same-cell wheel events all delivered over a debounced control';

    # A wheel press must not seed drag capture: motion events with no
    # real button held should keep returning early, not route to the
    # last-scrolled widget.
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_MOTION, input-type => NCTYPE_PRESS, y => 0, x => 3));
    is $ctl.motions, 0, 'motion after wheel press not captured';

    # And a real press right after a wheel event still lands (wheel
    # must not have seeded the duplicate-press bookkeeping either).
    my $before = $ctl.scrolls;
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, y => 0, x => 3));
    $h.dispatch-event-for-test(mouse-event(
        id => NCKEY_SCROLL_DOWN, input-type => NCTYPE_PRESS, y => 0, x => 3));
    is $ctl.scrolls, $before + 1, 'wheel event after a click still delivered';
};

subtest "paste focus helper returns Bool, not method list" => {
    plan 5;
    my class PasteHarness does Selkie::App::Internal::Dispatch {
        has $.focused is rw;
        method focused-can-insert-text-for-test(--> Bool) {
            self!focused-can-insert-text;
        }
        method flush-paste-for-test(Str:D $text --> Nil) {
            self!flush-paste-batch($text);
        }
    }

    my $input = Selkie::Widget::TextInput.new;
    my $h = PasteHarness.new(focused => $input);
    my Bool $can = False;
    lives-ok { $can = $h.focused-can-insert-text-for-test },
        'insert-text capability check returns without typecheck crash';
    ok $can, 'focused TextInput can receive paste batches';
    lives-ok { $h.flush-paste-for-test('secret') },
        'paste batch flush inserts into focused input';
    is $input.text, 'secret', 'pasted text reaches the input';

    my class PlainFocus { }
    nok PasteHarness.new(focused => PlainFocus.new).focused-can-insert-text-for-test,
        'focused object without insert-text returns False';
};

subtest "documented mouse-dispatch contract for handle-event overrides" => {
    plan 4;
    # Pin the contract documented under Widget.handle-event: subclasses
    # that override handle-event for KeyEvent should branch on
    # event-type and call self!dispatch-mouse-handlers explicitly for
    # mouse — `nextsame` does NOT work here because Selkie::Widget is
    # a role, and Raku flattens role methods into the consuming class.
    # An override shadows the role's handle-event rather than
    # inheriting it as a separate dispatch candidate, leaving nextsame
    # with no candidate to fall through to.
    my class DocPatternWidget does Selkie::Widget {
        method render() { self.clear-dirty }
        method handle-event(Selkie::Event $ev --> Bool) {
            if $ev.event-type ~~ MouseEvent {
                return True if self!dispatch-mouse-handlers($ev);
                return False;
            }
            False;
        }
    }

    my Int $click-count = 0;
    my $w = DocPatternWidget.new;
    $w.on-click(-> $ { $click-count++ });

    my $click = mouse-event(
        id => NCKEY_BUTTON1, y => 0, x => 0,
        input-type => NCTYPE_PRESS, click-count => 1
    );
    my $consumed = $w.handle-event($click);

    is $click-count, 1,
        "explicit dispatch-mouse-handlers fires registered on-click";
    ok $consumed, "handle-event returns True for consumed click";

    # And a non-mouse event takes the keyboard branch (returns False
    # for this minimal stub) — confirming the branch decision works
    # both ways.
    my $kev = key-event(id => 'a'.ord);
    my $consumed-key = $w.handle-event($kev);
    nok $consumed-key, "non-mouse path returns False (no keybinds bound)";
    is $click-count, 1, "no-op key-event does not fire on-click again";
};