Selkie.git | t/ | 113-mouse-capture-lifecycle.rakutest


use Test;
use lib 'lib';

use Notcurses::Native::Types;

use Selkie::App;
use Selkie::App::Internal::Dispatch;
use Selkie::Container;
use Selkie::Event;
use Selkie::Test::Keys;
use Selkie::Tree;
use Selkie::Widget;

# Mouse-capture lifecycle.
#
# A button press captures the widget under the pointer so the drag and
# the release that follow keep reaching it. The release is what clears
# the entry — and a release is not guaranteed to arrive: terminals lose
# them across focus changes and screen swaps, and a press handler is
# free to tear its own widget down. A leaked entry is not merely
# untidy, because every later NCKEY_MOTION is routed to whoever holds a
# capture: a stale entry silently redirects motion at a widget that may
# already have been destroyed, i.e. into a freed plane.
#
# Two guards, both exercised here: the widget-destroyed observer marks
# the table stale so it is pruned on the next mouse event, and every
# read of a capture re-checks that the widget is still routable.

class TestContainer does Selkie::Container {
    method render() { self!render-children; self.clear-dirty }
}

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

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 focus-eligible($w) { Selkie::App.focus-eligible($w) }
    method widget-attached($widget, $root --> Bool) {
        Selkie::App.widget-attached($widget, $root);
    }
    method close-modal(--> Nil) { }
    method !check-terminal-resize(--> Nil) { }

    method dispatch-event-for-test(Selkie::Event $ev --> Nil) {
        self!dispatch-event($ev);
    }
    method capture-count(--> Int) { self!mouse-capture-count }
    method note-destroyed($w --> Nil) { self!note-widget-destroyed($w) }
}

sub place(Selkie::Widget $w, :$abs-y!, :$abs-x!, :$rows!, :$cols!) {
    $w.set-viewport(:$abs-y, :$abs-x, :$rows, :$cols);
}

sub press(:$y = 0, :$x = 1) {
    mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_PRESS, :$y, :$x);
}
sub release(:$y = 0, :$x = 1) {
    mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_RELEASE, :$y, :$x);
}
sub motion(:$y = 0, :$x = 2) {
    mouse-event(id => NCKEY_MOTION, input-type => NCTYPE_PRESS, :$y, :$x);
}
sub drag(:$y = 0, :$x = 2) {
    mouse-event(id => NCKEY_BUTTON1, input-type => NCTYPE_REPEAT, :$y, :$x);
}

# The presses in this file all land on the same cell, so the
# duplicate-press guard (40ms floor) would swallow the later ones.
sub past-duplicate-window() { sleep 0.05 }

plan 6;

subtest "press captures, release clears" => {
    plan 4;

    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);
    is $h.capture-count, 0, 'nothing captured before the first press';

    $h.dispatch-event-for-test(press());
    is $h.capture-count, 1, 'the press captured the widget under the pointer';
    is $hit.hits, 1, 'and the press itself was delivered';

    $h.dispatch-event-for-test(release());
    is $h.capture-count, 0, 'the release gave the capture back';
};

subtest "motion routes to the captured widget while it is live" => {
    plan 3;

    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);
    $h.dispatch-event-for-test(motion());
    is $hit.hits, 0, 'motion with nothing captured is dropped';

    $h.dispatch-event-for-test(press());
    $h.dispatch-event-for-test(motion());
    is $hit.hits, 2, 'motion under a live capture reaches the captured widget';
    is $h.capture-count, 1, 'and the capture survives the motion';
};

subtest "a destroyed widget's capture is dropped, not delivered to" => {
    plan 6;

    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);
    $h.dispatch-event-for-test(press());
    is $h.capture-count, 1, 'preconditions: the widget holds the capture';

    # Removal destroys the child but leaves its `parent` pointing at the
    # container, so it still walks up to the live root — the reason the
    # routability test cannot be attachment alone.
    $root.remove($hit);
    ok $hit.is-destroyed, 'preconditions: the captured widget is destroyed';
    ok Selkie::App.widget-attached($hit, $root),
        'preconditions: and it still walks up to the root';

    my $hits-before = $hit.hits;
    lives-ok { $h.dispatch-event-for-test(motion()) },
        'motion after the teardown does not blow up';
    is $h.capture-count, 0,
        'and the stale entry is gone rather than routing to a dead widget';
    is $hit.hits, $hits-before,
        'nothing was delivered to the destroyed widget';
};

subtest "a drag after teardown falls back to hit-testing" => {
    plan 3;

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

    my $h = DispatchHarness.new(root => $root);
    $h.dispatch-event-for-test(press(y => 0, x => 1));
    is $h.capture-count, 1, 'preconditions: the upper widget holds the capture';

    $root.remove($upper);
    $h.dispatch-event-for-test(drag(y => 2, x => 1));
    is $lower.hits, 1,
        'the drag is hit-tested afresh and lands on what is actually there';
    is $h.capture-count, 0, 'the dead capture was dropped on the way';
};

subtest "the widget-destroyed observer prunes without over-deleting" => {
    plan 4;

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

    my $h = DispatchHarness.new(root => $root);
    # Selkie::App wires this at init; here we do it by hand so the
    # observer path itself is under test.
    set-widget-destroyed-observer(-> $w { $h.note-destroyed($w) });
    LEAVE set-widget-destroyed-observer(-> $ { });

    $h.dispatch-event-for-test(press(y => 0, x => 1));
    is $h.capture-count, 1, 'preconditions: a capture is held';

    # An unrelated widget goes away: the table is marked stale, but the
    # live capture must survive the prune.
    $root.remove($spare);
    past-duplicate-window();
    $h.dispatch-event-for-test(motion(y => 0, x => 2));
    is $h.capture-count, 1, 'an unrelated teardown does not drop a live capture';
    is $hit.hits, 2, 'and the motion still reached the captured widget';

    $root.remove($hit);
    past-duplicate-window();
    $h.dispatch-event-for-test(motion(y => 0, x => 2));
    is $h.capture-count, 0, 'the captured widget going away does drop it';
};

subtest "a capture whose surface was swapped out is dropped" => {
    plan 3;

    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);
    $h.dispatch-event-for-test(press());
    is $h.capture-count, 1, 'preconditions: a capture is held on the old screen';

    # Screen swap: the surface that owns input is a different tree now,
    # and the widget holding the capture is not in it.
    my $other = TestContainer.new;
    place($other, abs-y => 0, abs-x => 0, rows => 3, cols => 10);
    $h.root = $other;

    my $hits-before = $hit.hits;
    $h.dispatch-event-for-test(motion());
    is $h.capture-count, 0, 'the off-surface capture is dropped';
    is $hit.hits, $hits-before,
        'and no motion was delivered to a widget on the old screen';
};