Selkie.git | t/ | 110-viewported-card-list-render.rakutest
use Test;
use lib 'lib';
use NativeCall;
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Notcurses::Native::Cell;
use Selkie::Test::SnapshotPlatform;
use Selkie::Sizing;
use Selkie::Widget;
use Selkie::Layout::VBox;
use Selkie::Widget::Border;
use Selkie::Widget::Image;
use Selkie::Widget::Text;
use Selkie::Widget::ViewportedCardList;
=begin pod
The parts of C<ViewportedCardList.render> that only exist once there is
a real notcurses plane under the widget. Everything scroll-arithmetic
shaped lives in F<t/65-viewported-card-list.rakutest>, which needs no
terminal; this file drives actual renders and reads the cells back.
What is being guarded:
=item B<The backing plane is a 1×1 coordinate anchor, not a content-sized framebuffer.> Card planes are its children at content coordinates, and notcurses does not clip a child to its parent's rectangle — only the anchor's origin is ever read (C<!merge-widget-plane> subtracts it to recover content-space positions). Sizing it to the content allocated an nccell framebuffer proportional to the whole transcript and memset it on every layout-dirty frame — that is, on every scroll keystroke. Per-frame cost has to stay O(viewport).
=item B<Cards far outside the anchor still merge.> The direct consequence of the above, and the thing that would break if some notcurses release started clipping bound planes: a card 400 rows down inside a 1×1 parent must still paint.
=item B<The C<set-selected> duck-typed hook.> The only selection affordance a card registered without a C<:border> has. It must fire on change, must not fire per frame, and must fire again when a card comes back into the viewport (selection can have moved any number of times while it was parked).
=item B<C<min-display-height> is honoured.> A card clipped by a viewport edge to fewer rows than it declared usable is parked, leaving its band empty, rather than drawn as a sliver. Capped by the card's own height and by the viewport height so neither a short card nor a pane-filling card can be parked out of existence.
=item B<One-row image slices reach C<render-viewport-crop>.> With the default minimum, cards remain live until their viewport overlap reaches zero, and the crop hook receives the exact source and destination slice for both viewport edges.
=item B<Nested crop widgets receive their zero-overlap edge.> An avatar can leave the viewport before its outer card's final border/body row. The hook receives a zero extent at that transition so its crop-owned plane cannot stick to the viewport until the card itself parks.
Renders read cells straight off the widget's own plane — that is where
the merge writes — so no C<notcurses_render> is involved. Assertions are
about blank-versus-painted geometry rather than exact glyphs; exact
glyph output is pinned by the C<xt/> snapshots.
=end pod
constant PLAN = 10;
plan PLAN;
# Same headless setup Selkie::Test::Snapshot uses: detach from the
# controlling terminal first so notcurses cannot interrogate it and
# block on the replies, then init against the null device. Note the
# absence of NCOPTION_INHIBIT_SETLOCALE — without a UTF-8 locale
# notcurses stores each byte of a multi-byte glyph in its own cell,
# which is invisible on screen (the bytes go out consecutively) but
# makes every cell-level readback garbage.
snapshot-setsid() unless IS-WINDOWS;
my Pointer $devnull = snapshot-fopen(NULL-PATH, 'w');
my $nc-flags = NCOPTION_SUPPRESS_BANNERS
+| NCOPTION_NO_ALTERNATE_SCREEN
+| NCOPTION_NO_WINCH_SIGHANDLER;
my $nc = notcurses_init(
NotcursesOptions.new(flags => $nc-flags, loglevel => NCLOGLEVEL_SILENT),
$devnull,
);
unless $nc.defined {
skip 'notcurses_init failed (no usable terminal)', PLAN;
exit 0;
}
END {
notcurses_stop($nc) if $nc.defined;
snapshot-fclose($devnull) if $devnull.defined;
}
# A card widget that records the selection hook instead of drawing.
# It is the widget handed to add-item (the hook receiver); the card's
# visible body is the Border + Text used as its root.
class SelectableStub does Selkie::Widget {
has @.selected-calls;
method render() { self.clear-dirty }
method set-selected(Bool $on) { @!selected-calls.push($on) }
}
# A card widget with no hooks at all — the "plain widget" control.
class PlainStub does Selkie::Widget {
method render() { self.clear-dirty }
}
# Records the viewport slice routed through the image-widget hook. Returning
# True tells ViewportedCardList that the crop was handled, so the empty spy
# plane does not need the ordinary cell-copy fallback.
class CropSpy does Selkie::Widget {
has @!crop-calls;
has Bool $.crop-active = False;
method render() { self.clear-dirty }
method render-viewport-crop(
:$parent-plane!,
Int :$dest-y!,
Int :$dest-x!,
Int :$source-row!,
Int :$source-col!,
UInt :$rows!,
UInt :$cols!,
--> Bool
) {
@!crop-calls.push: %(
source-row => $source-row,
rows => $rows.Int,
dest-y => $dest-y,
);
$!crop-active = $rows > 0 && $cols > 0;
True;
}
method crop-calls(--> Array) { @!crop-calls.Array }
method clear-crop-calls(--> Nil) { @!crop-calls = () }
method park() {
$!crop-active = False;
self.reposition(self.park-y, 0);
}
}
sub build-list(
:@heights!,
UInt :$rows!,
UInt :$cols!,
UInt :$min-display-height = 1,
Bool :$hooks = True,
--> List
) {
my $std = notcurses_stdplane($nc);
ncplane_resize_simple($std, $rows, $cols);
ncplane_erase($std);
my $list = Selkie::Widget::ViewportedCardList.new(sizing => Sizing.flex);
my @widgets;
my @roots;
for @heights.kv -> $i, $h {
my $text = Selkie::Widget::Text.new(text => "card$i", sizing => Sizing.flex);
my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
$border.set-content($text);
my $widget = $hooks ?? SelectableStub.new !! PlainStub.new;
@widgets.push($widget);
@roots.push($border);
$list.add-item($widget, root => $border, height => $h, :$border,
:$min-display-height);
}
$list.init-plane($std, y => 0, x => 0, :$rows, :$cols);
$list.set-viewport(abs-y => 0, abs-x => 0, :$rows, :$cols);
($list, @widgets, @roots);
}
# The backing plane is not exposed — it is reached the way any
# consumer could: a card root's plane is bound to it.
sub backing-plane-of($list --> NcplaneHandle) {
my $root = $list.children.first({ $_.defined && $_.plane.defined });
return NcplaneHandle without $root;
ncplane_parent($root.plane);
}
sub plane-dims(NcplaneHandle $p --> List) {
my uint32 $h = 0;
my uint32 $w = 0;
ncplane_dim_yx($p, $h, $w);
($h.Int, $w.Int);
}
# Row-by-row readback of a plane's own cells. A cell whose EGC can't be
# decoded comes back as 'X' rather than throwing: still non-blank, so
# blank-band assertions stay meaningful, and no platform can turn a
# rendering regression into a crash-shaped failure.
sub readback($plane, Int $rows, Int $cols --> Array) {
my $cell = Nccell.new;
my @lines;
for ^$rows -> $y {
my $line = '';
for ^$cols -> $x {
my $bytes = ncplane_at_yx_cell($plane, $y, $x, $cell);
if $bytes < 0 {
$line ~= ' ';
next;
}
my $egc = try nccell_extended_gcluster($plane, $cell);
$line ~= $egc.defined
?? ($egc.chars ?? $egc !! ' ')
!! 'X';
}
@lines.push($line);
}
@lines;
}
# Content columns only — the last column belongs to the scrollbar,
# which paints on every frame regardless of card layout.
sub content-rows($list, Int $rows, Int $cols --> Array) {
readback($list.plane, $rows, $cols).map({ .substr(0, $cols - 1) }).Array;
}
sub nested-crop-card($crop --> Selkie::Layout::VBox) {
my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$root.add: Selkie::Widget::Text.new(text => '', sizing => Sizing.fixed(2));
$root.add: $crop;
$root.add: Selkie::Widget::Text.new(text => '', sizing => Sizing.flex);
$root;
}
sub init-two-card-list($first, $second --> Selkie::Widget::ViewportedCardList) {
my $std = notcurses_stdplane($nc);
ncplane_resize_simple($std, 8, 20);
ncplane_erase($std);
my $list = Selkie::Widget::ViewportedCardList.new(sizing => Sizing.flex);
$list.add-item($first, root => $first, height => 8, min-display-height => 1);
$list.add-item($second, root => $second, height => 8, min-display-height => 1);
$list.init-plane($std, y => 0, x => 0, rows => 8, cols => 20);
$list.set-viewport(abs-y => 0, abs-x => 0, rows => 8, cols => 20);
$list;
}
subtest "nested crop widgets receive top and bottom zero-overlap slices" => {
plan 14;
# Top edge: the five-row child occupies card rows 2..6. At offset
# 6 its final row is visible; at 7 the card's last row remains but
# the child itself has no overlap.
my $top-spy = CropSpy.new(sizing => Sizing.fixed(5));
my $top-root = nested-crop-card($top-spy);
my $top-tail = PlainStub.new;
my $top-list = init-two-card-list($top-root, $top-tail);
$top-list.render;
$top-spy.clear-crop-calls;
$top-list.scroll-to(6);
$top-list.render;
is-deeply $top-spy.crop-calls,
[%(source-row => 4, rows => 1, dest-y => 0).item],
"top exit routes the child's final row";
ok $top-spy.crop-active, 'the final positive top slice is active';
$top-spy.clear-crop-calls;
$top-list.scroll-to(7);
$top-list.render;
is-deeply $top-spy.crop-calls,
[%(source-row => 5, rows => 0, dest-y => 0).item],
'top exit delivers an explicit zero-row slice';
nok $top-spy.crop-active, 'the zero-row slice clears crop state';
nok $top-root.is-parked, 'outer card remains admitted at top zero-overlap';
$top-spy.clear-crop-calls;
$top-root.mark-dirty;
$top-list.render;
is-deeply $top-spy.crop-calls,
[%(source-row => 5, rows => 0, dest-y => 0).item],
'a repeated top zero-overlap notification stays explicit';
nok $top-spy.crop-active, 'repeated top zero remains inactive';
$top-list.scroll-to(6);
$top-list.render;
ok $top-spy.crop-active, 'top-edge re-entry restores a positive crop';
$top-list.destroy;
# Bottom edge, in reverse: the second card begins at row 8 and its
# child at row 10. Offset 3 exposes one child row at viewport row 7;
# offset 2 still exposes the card but ends immediately above child.
my $bottom-head = PlainStub.new;
my $bottom-spy = CropSpy.new(sizing => Sizing.fixed(5));
my $bottom-root = nested-crop-card($bottom-spy);
my $bottom-list = init-two-card-list($bottom-head, $bottom-root);
$bottom-list.scroll-to(8);
$bottom-list.render;
$bottom-spy.clear-crop-calls;
$bottom-list.scroll-to(3);
$bottom-list.render;
is-deeply $bottom-spy.crop-calls,
[%(source-row => 0, rows => 1, dest-y => 7).item],
"bottom exit routes the child's first row";
ok $bottom-spy.crop-active, 'the final positive bottom slice is active';
$bottom-spy.clear-crop-calls;
$bottom-list.scroll-to(2);
$bottom-list.render;
is-deeply $bottom-spy.crop-calls,
[%(source-row => 0, rows => 0, dest-y => 8).item],
'bottom exit delivers an explicit zero-row slice';
nok $bottom-spy.crop-active, 'the bottom zero-row slice clears crop state';
nok $bottom-root.is-parked,
'outer card remains admitted at bottom zero-overlap';
$bottom-list.scroll-to(3);
$bottom-list.render;
ok $bottom-spy.crop-active, 'bottom-edge re-entry restores a positive crop';
$bottom-list.destroy;
};
subtest "nested Image tears its crop plane down at child zero-overlap" => {
plan 6;
my $image = Selkie::Widget::Image.new(
sizing => Sizing.fixed(5),
clip-only => True,
);
# Tall source guarantees that SCALE fills the five-row image slot
# under both pixel and cell blitters.
$image.set-rgba(
Buf.new(255 xx (16 * 64 * 4)),
width => 16, height => 64, id => 'nested-zero-overlap',
);
my $root = nested-crop-card($image);
my $tail = PlainStub.new;
my $list = init-two-card-list($root, $tail);
$list.scroll-to(6);
$list.render;
ok $image.has-blit-plane, 'one-row nested crop owns a blit-plane';
$list.scroll-to(7);
$list.render;
nok $image.has-blit-plane,
'child zero-overlap destroys the crop plane before card parking';
nok $root.is-parked, 'real Image outer card is still visible';
$root.mark-dirty;
$list.render;
nok $image.has-blit-plane,
'repeated zero-overlap leaves the Image teardown idempotent';
$list.scroll-to(6);
$list.render;
ok $image.has-blit-plane, 'real Image re-blits on positive re-entry';
$list.scroll-to(7);
$list.render;
nok $image.has-blit-plane, 'a second zero transition tears down again';
$list.destroy;
};
subtest "one-row card slices are routed to render-viewport-crop" => {
plan 9;
my $std = notcurses_stdplane($nc);
ncplane_resize_simple($std, 8, 20);
ncplane_erase($std);
my $list = Selkie::Widget::ViewportedCardList.new(sizing => Sizing.flex);
my @spies = CropSpy.new, CropSpy.new;
for @spies -> $spy {
$list.add-item(
$spy,
root => $spy,
height => 8,
min-display-height => 1,
);
}
$list.init-plane($std, y => 0, x => 0, rows => 8, cols => 20);
$list.set-viewport(abs-y => 0, abs-x => 0, rows => 8, cols => 20);
$list.render;
.clear-crop-calls for @spies;
$list.scroll-to(1);
$list.render;
is-deeply @spies[0].crop-calls,
[%(source-row => 1, rows => 7, dest-y => 0).item],
"offset 1 crops seven rows from the top card after its first row";
is-deeply @spies[1].crop-calls,
[%(source-row => 0, rows => 1, dest-y => 7).item],
"offset 1 routes the bottom card's first row to viewport row 7";
is @spies[0, 1].grep(*.is-parked).elems, 0,
"both positive-overlap cards stay unparked at offset 1";
.clear-crop-calls for @spies;
$list.scroll-to(7);
$list.render;
is-deeply @spies[0].crop-calls,
[%(source-row => 7, rows => 1, dest-y => 0).item],
"offset 7 still routes the top card's final row";
is-deeply @spies[1].crop-calls,
[%(source-row => 0, rows => 7, dest-y => 1).item],
"offset 7 routes seven bottom-card rows below it";
is @spies[0, 1].grep(*.is-parked).elems, 0,
"a one-row overlap does not park either card";
.clear-crop-calls for @spies;
$list.scroll-to(8);
$list.render;
is-deeply @spies[0].crop-calls, [],
"zero-overlap top card receives no crop call";
is-deeply @spies[1].crop-calls,
[%(source-row => 0, rows => 8, dest-y => 0).item],
"offset 8 routes the full second card at viewport row 0";
ok @spies[0].is-parked && !@spies[1].is-parked,
"only the zero-overlap card is parked";
$list.destroy;
};
subtest "backing plane stays 1×1 however tall the content is" => {
plan 5;
my ($list, $widgets, $roots) = build-list(heights => (40 xx 12), rows => 6, cols => 24);
$list.render;
is $list.content-height, 480, "content is 480 rows tall";
my $backing = backing-plane-of($list);
ok $backing.defined, "a card plane is bound to the backing plane";
is-deeply plane-dims($backing), (1, 1),
"backing plane is a 1×1 anchor, not a 480-row framebuffer";
# Scrolling is what used to re-erase the whole content-sized plane.
$list.scroll-to(300);
$list.render;
is-deeply plane-dims(backing-plane-of($list)), (1, 1),
"still 1×1 after a scroll re-render";
my @rows = content-rows($list, 6, 24);
ok @rows.grep({ .trim.chars }).elems > 0,
"viewport is painted after scrolling deep into the content";
$list.destroy;
};
subtest "cards positioned far outside the anchor still merge" => {
plan 3;
my ($list, $widgets, $roots) = build-list(heights => (10 xx 50), rows => 5, cols => 20);
$list.scroll-to(400);
$list.render;
is $list.scroll-offset, 400, "scrolled 400 rows into the content";
ok $list.visible-index-count > 0, "cards at content row 400 count as visible";
my @rows = content-rows($list, 5, 20);
ok @rows.grep({ .trim.chars }).elems >= 3,
"their planes — children of a 1×1 parent — painted into the viewport";
$list.destroy;
};
subtest "set-selected fires on first visibility and on selection change" => {
plan 5;
my ($list, $widgets, $roots) = build-list(heights => (3, 3, 3), rows => 9, cols => 20);
$list.render;
is-deeply $widgets[0].selected-calls, [True],
"selected card told it is selected on its first visible frame";
is-deeply $widgets[1].selected-calls, [False],
"unselected card told it is not selected";
$list.mark-dirty;
$list.render;
is-deeply $widgets[0].selected-calls, [True],
"a second render with no change does not re-notify";
$list.select-index(1);
$list.render;
is-deeply $widgets[0].selected-calls, [True, False],
"deselected card notified exactly once";
is-deeply $widgets[1].selected-calls, [False, True],
"newly selected card notified exactly once";
$list.destroy;
};
subtest "set-selected re-fires after a card leaves and re-enters the viewport" => {
plan 3;
my ($list, $widgets, $roots) = build-list(heights => (4 xx 4), rows => 5, cols => 20);
$list.render;
is-deeply $widgets[0].selected-calls, [True],
"selected card notified while visible";
$list.scroll-to-end; # content 16, viewport 5 → offset 11
$list.render;
ok $roots[0].is-parked, "card 0 parked once it left the viewport";
$list.scroll-to-start;
$list.render;
is-deeply $widgets[0].selected-calls, [True, True],
"hook re-fires on re-entry — selection may have moved while parked";
$list.destroy;
};
subtest "cards without the hook are left alone" => {
plan 2;
my ($list, $widgets, $roots) =
build-list(heights => (3, 3), rows => 8, cols => 20, hooks => False);
lives-ok { $list.render }, "render works with widgets that have no hooks";
my @rows = content-rows($list, 8, 20);
ok @rows.grep({ .trim.chars }).elems > 0, "cards still painted";
$list.destroy;
};
subtest "min-display-height parks cards clipped to a sliver" => {
plan 7;
# Cards of 8 rows in a 10-row viewport. At offset 0 card 0 is fully
# visible; scrolling to 4 leaves it 4 rows above the fold while
# card 1 gets 6. With a declared minimum of 6, card 0 becomes a
# sliver: it has to be actively parked (its plane already exists
# and would otherwise keep painting) and its band left empty.
my ($list, $widgets, $roots) =
build-list(heights => (8 xx 3), rows => 10, cols => 20,
min-display-height => 6);
$list.render;
nok $roots[0].is-parked, "card 0 rendered while fully visible";
$list.scroll-to(4);
$list.render;
is $list.visible-index-count, 1, "only the card above its threshold rendered";
ok $roots[0].is-parked, "the 4-row sliver was parked";
nok $roots[1].is-parked, "the 6-row slice rendered";
my @rows = content-rows($list, 10, 20);
is @rows[0 .. 3].grep({ .trim.chars }).elems, 0,
"the parked card's band is empty, not a sliver";
ok @rows[4 .. 9].grep({ .trim.chars }).elems > 0,
"the card that cleared the threshold painted below it";
# Same geometry, default threshold: the sliver renders as before.
my ($plain, $plain-widgets, $plain-roots) =
build-list(heights => (8 xx 3), rows => 10, cols => 20);
$plain.scroll-to(4);
$plain.render;
is $plain.visible-index-count, 2,
"default min-display-height of 1 keeps the pre-existing sliver behaviour";
$list.destroy;
$plain.destroy;
};
subtest "min-display-height threshold is capped by card and viewport height" => {
plan 4;
# A card shorter than its own declared minimum renders in full
# rather than never appearing at all.
my ($short, $short-widgets, $short-roots) =
build-list(heights => (2, 8), rows => 10, cols => 20,
min-display-height => 6);
$short.render;
nok $short-roots[0].is-parked, "a 2-row card declaring 6 still renders";
is $short.visible-index-count, 2, "both cards visible";
# A card taller than the pane covers the whole viewport; a minimum
# above the pane height must not blank the list.
my ($tall, $tall-widgets, $tall-roots) =
build-list(heights => (20, 20), rows => 6, cols => 20,
min-display-height => 12);
$tall.scroll-to(4);
$tall.render;
nok $tall-roots[0].is-parked,
"a card filling the whole viewport is never parked as a sliver";
ok $tall.visible-index-count >= 1, "the viewport still has a card in it";
$short.destroy;
$tall.destroy;
};