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