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