Selkie.git | t/ | 65-viewported-card-list.rakutest


use Test;
use lib 'lib';

use Selkie::Widget;
use Selkie::Widget::ViewportedCardList;
use Selkie::Layout::VBox;
use Selkie::Sizing;
use Selkie::Event;
use Selkie::Test::Keys;
use Notcurses::Native::Types;

class StubWidget does Selkie::Widget {
    has Bool $.clipped-top = False;
    has Bool $.clipped-bottom = False;
    method render() { self.clear-dirty }
    method set-clipped(Bool :$top = False, Bool :$bottom = False) {
        $!clipped-top = $top;
        $!clipped-bottom = $bottom;
    }
}

sub add-stub($list, Int :$height = 5) {
    my $widget = StubWidget.new;
    my $root = Selkie::Layout::VBox.new(sizing => Sizing.fixed($height));
    $list.add-item($widget, :$root, :$height);
    $widget;
}

plan 18;

subtest "construction defaults" => {
    plan 5;
    my $list = Selkie::Widget::ViewportedCardList.new;
    is $list.count, 0, "no items initially";
    is $list.selected, 0, "selected starts at 0";
    is $list.scroll-offset, 0, "scroll offset starts at 0";
    is $list.content-height, 0, "content height starts at 0";
    ok $list.focusable, "focusable by default";
};

subtest "content-height sums item heights" => {
    plan 1;
    my $list = Selkie::Widget::ViewportedCardList.new;
    add-stub($list, height => 3);
    add-stub($list, height => 7);
    is $list.content-height, 10, "content height is sum of cards";
};

subtest "scroll-to and scroll-by clamp to top and bottom" => {
    plan 3;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(6, 40);
    add-stub($list, height => 4) for ^4; # total 16, max offset 10
    $list.scroll-to(100);
    is $list.scroll-offset, 10, "scroll-to clamps at bottom";
    $list.scroll-by(-99);
    is $list.scroll-offset, 0, "scroll-by clamps at top";
    nok $list.at-end, "not at end after scrolling to top";
};

subtest "scroll-to-end reports at-end with ScrollView semantics" => {
    plan 2;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3; # total 12, max offset 7
    $list.scroll-to-end;
    is $list.scroll-offset, 7, "scroll-to-end goes to max offset";
    ok $list.at-end, "at-end is true at max offset";
};

subtest "selection movement minimally keeps selected card visible" => {
    plan 3;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 3) for ^4;
    $list.select-index(1);
    is $list.scroll-offset, 1, "second card bottom is brought into view";
    $list.scroll-down;
    is $list.selected, 2, "scroll-down moves selection by card";
    is $list.scroll-offset, 4, "third card is visible without whole-card scroll";
};

subtest "on-select fires only on selection changes" => {
    plan 2;
    my $list = Selkie::Widget::ViewportedCardList.new;
    add-stub($list) for ^3;
    my @seen;
    $list.on-select.tap: -> $idx { @seen.push($idx) };
    $list.select-index(0);
    is @seen.elems, 0, "selecting current index does not emit";
    $list.select-index(2);
    is @seen, [2], "changed selection emits once";
};

subtest "Up/Down navigates cards, PgUp/PgDown row-scrolls by viewport" => {
    plan 4;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;
    $list.handle-event(key-event(id => NCKEY_DOWN));
    is $list.selected, 1, "plain Down selects next card";
    is $list.scroll-offset, 3, "selection movement keeps card visible";
    $list.handle-event(key-event(id => NCKEY_PGDOWN));
    is $list.selected, 1, "PgDown leaves selection alone";
    is $list.scroll-offset, 7, "PgDown scrolls by one viewport (clamped to max)";
};

subtest "Shift+Up / Shift+Down scroll one row without moving selection" => {
    plan 4;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;
    $list.handle-event(key-event(id => NCKEY_DOWN, modifiers => Set(Mod-Shift)));
    is $list.selected, 0, "Shift+Down leaves selection alone";
    is $list.scroll-offset, 1, "Shift+Down scrolls by exactly one row";
    $list.handle-event(key-event(id => NCKEY_UP, modifiers => Set(Mod-Shift)));
    is $list.selected, 0, "Shift+Up leaves selection alone";
    is $list.scroll-offset, 0, "Shift+Up scrolls back to zero";
};

subtest "clear-items resets state" => {
    plan 4;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;
    $list.select-last;
    $list.clear-items;
    is $list.count, 0, "count reset";
    is $list.selected, 0, "selection reset";
    is $list.scroll-offset, 0, "offset reset";
    is $list.content-height, 0, "content height reset";
};

subtest "remove-index removes one card and clamps state" => {
    plan 7;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 3);
    add-stub($list, height => 4);
    add-stub($list, height => 5);
    $list.select-index(2);

    ok $list.remove-index(1), "remove existing index succeeds";
    is $list.count, 2, "count decremented";
    is $list.content-height, 8, "content-height subtracts removed height";
    is $list.selected, 1, "selection clamps to last remaining card";
    is $list.scroll-offset, 3, "offset clamps to new max";

    nok $list.remove-index(99), "out-of-bounds remove is a no-op";
    is $list.count, 2, "count unchanged after out-of-bounds remove";
};

subtest "bottom-anchor flag is accepted" => {
    plan 2;
    my $list = Selkie::Widget::ViewportedCardList.new(bottom-anchor => True);
    ok $list.bottom-anchor, "bottom-anchor set via constructor";
    add-stub($list, height => 2);
    lives-ok { $list.scroll-to-start }, "bottom-anchor does not affect scroll API";
};

subtest "follow-active accessor and defaults" => {
    plan 3;
    my $list = Selkie::Widget::ViewportedCardList.new(follow-bottom => True);
    ok $list.follow-active, "follow-active starts True";

    my $no-follow = Selkie::Widget::ViewportedCardList.new;
    ok $no-follow.follow-active,
        "follow-active starts True even with follow-bottom False (latch unused)";
    nok $no-follow.follow-bottom, "follow-bottom defaults False";
};

# Regression: while a streamed token grew the last card, set-item-height
# routed through scroll-to($!scroll-offset). scroll-to recomputes
# follow-active against the freshly-grown max-offset, which left the
# old offset (still equal to the OLD max) below the NEW max — so
# follow-active flipped to False on the very first token and the
# snap-to-max in render() never fired. The fix has set-item-height
# clamp directly, leaving follow-active to the user-input funnel.
subtest "set-item-height preserves follow-active at bottom" => {
    plan 4;
    my $list = Selkie::Widget::ViewportedCardList.new(follow-bottom => True);
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;        # content=12, max=7
    $list.scroll-to-end;
    is $list.scroll-offset, 7, "scrolled to bottom";
    ok $list.follow-active, "follow-active engaged at bottom";

    $list.set-item-height(2, 6);                # content=14, max=9
    is $list.content-height, 14, "content-height reflects new height";
    ok $list.follow-active,
        "follow-active still engaged after last-card growth — render will snap";
};

subtest "set-item-height leaves follow-active off when user scrolled away" => {
    plan 3;
    my $list = Selkie::Widget::ViewportedCardList.new(follow-bottom => True);
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;        # content=12, max=7
    $list.scroll-to(2);                         # mid-content
    nok $list.follow-active, "follow-active off after scrolling away from bottom";

    $list.set-item-height(2, 6);                # last card grows
    nok $list.follow-active,
        "follow-active stays off — content growth doesn't yank user back";
    is $list.scroll-offset, 2,
        "scroll position preserved while user is reading history";
};

subtest "scroll-to is the funnel that maintains follow-active" => {
    plan 4;
    my $list = Selkie::Widget::ViewportedCardList.new(follow-bottom => True);
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;        # max=7
    $list.scroll-to-end;
    ok $list.follow-active, "scroll-to-end engages follow";
    $list.scroll-to(0);
    nok $list.follow-active, "scroll-to(0) disengages follow";
    $list.scroll-by(99);
    ok $list.follow-active, "scroll-by clamping to bottom re-engages follow";
    $list.scroll-by(-1);
    nok $list.follow-active, "single row up disengages follow";
};

subtest "add-item does not disturb follow-active" => {
    plan 2;
    my $list = Selkie::Widget::ViewportedCardList.new(follow-bottom => True);
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^3;
    $list.scroll-to-end;
    ok $list.follow-active, "engaged after scroll-to-end";
    add-stub($list, height => 5);               # new message appended
    ok $list.follow-active,
        "appending a new card preserves follow-active — render snaps";
};

# set-item-height is called on every streaming token (the orchestrator
# re-measures and writes back the cached height) AND on every ComfyUI
# image-gen progress event (the chat-view callback updates status
# text and re-measures the live card). Most of those calls land on
# the same height — the token appended within an existing wrapped
# row, the progress bar swapped one block character for another. The
# old implementation marked-dirty unconditionally and the
# ViewportedCardList re-rendered + re-merged every visible card on
# every event. The skip is what keeps the tick thread responsive.
subtest "set-item-height with same value is a no-op" => {
    plan 3;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 4);
    add-stub($list, height => 4);

    # Drain dirty so the post-call check is meaningful.
    $list.clear-dirty;
    nok $list.is-dirty, "list starts clean for the test";

    $list.set-item-height(0, 4);                # SAME height
    nok $list.is-dirty,
        "same-value set-item-height left the list clean";

    $list.set-item-height(0, 6);                # actually different
    ok $list.is-dirty,
        "different-value set-item-height re-marks dirty";
};

subtest "scroll-to with same offset is a no-op" => {
    plan 3;
    my $list = Selkie::Widget::ViewportedCardList.new;
    $list.resize(5, 40);
    add-stub($list, height => 4) for ^4;        # max-offset = 11

    $list.scroll-to(7);
    $list.clear-dirty;
    nok $list.is-dirty, "list clean before the no-op call";

    $list.scroll-to(7);                         # SAME offset
    nok $list.is-dirty,
        "same-offset scroll-to left the list clean";

    $list.scroll-to(8);                         # actually different
    ok $list.is-dirty,
        "different-offset scroll-to re-marks dirty";
};