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