Selkie.git | lib/Selkie/Widget/ | ViewportedCardList.rakumod
=begin pod
=head1 NAME
Selkie::Widget::ViewportedCardList - Row-scrolled selectable list of card widgets
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::ViewportedCardList;
use Selkie::Sizing;
# Standard chat-history pane: latest message anchored to the bottom
# of the viewport with empty space above when content is shorter
# than the pane, and auto-scroll-to-bottom while a streaming
# message grows.
my $chat = Selkie::Widget::ViewportedCardList.new(
sizing => Sizing.flex,
bottom-anchor => True,
follow-bottom => True,
);
$chat.add-item(
$message,
root => $message.root,
height => $message-height,
border => $message.border,
);
# Streaming token arrived — last card grew. The viewport tracks the
# new bottom automatically as long as the user hasn't scrolled
# upward; if they have, the new content piles up below their parked
# position until they scroll back to the bottom.
$chat.set-item-height($chat.count - 1, $new-height);
=end code
=head1 DESCRIPTION
Like L<Selkie::Widget::CardList>, each item is an arbitrary widget with
a logical height and optional focus border. Unlike CardList, scrolling is
by content row: a viewport can start in the middle of any card.
C<bottom-anchor> aligns the last item to the bottom of the viewport
when total content is shorter than the pane (chat-history semantics
— short transcripts hug the input row, long ones scroll). C<follow-
bottom> keeps the bottom row visible as content grows or new cards
are added (streaming text, log tails). The follow latch is
maintained exclusively by user-driven scroll calls; mid-frame
content-shape changes (C<set-item-height> from a streaming token,
C<add-item> for a new message) clamp without disturbing it, so a
growing or appended message stays visible without yanking the
viewport away from a user who has scrolled up to read history.
=end pod
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Notcurses::Native::Cell;
use Selkie::Widget;
use Selkie::Widget::FocusableByDefault;
use Selkie::Widget::Border;
use Selkie::Event;
use Selkie::Sizing;
use Selkie::Trace;
unit class Selkie::Widget::ViewportedCardList does Selkie::Widget does Selkie::Widget::FocusableByDefault;
#|( Module-level latch tracking whether C<libnotcurses_native_shim>'s
C<notcurses_native_copy_cells> is callable in this process.
Optimistic at start; flipped to False the first time the shim
binding throws (typically "Cannot locate native library" on
installs where the shim wasn't compiled — see Notcurses::Native's
Build.rakumod for when that can happen). Once flipped, every
instance uses the Raku per-cell fallback for the rest of the
process — re-trying would just re-throw and be slower than the
fallback. )
my Bool $shim-available = True;
my Bool $shim-warning-emitted = False;
sub warn-copy-shim-unavailable(Str:D $reason --> Nil) {
return if $shim-warning-emitted;
$shim-warning-emitted = True;
my $line = "WARNING: notcurses_native_copy_cells unavailable ($reason); "
~ "Selkie::Widget::ViewportedCardList is using the per-cell Raku "
~ "merge fallback, which is correct but much slower. Rebuild or "
~ "reinstall Notcurses::Native with the native shim available.";
my $path = %*ENV<SELKIE_WARN_LOG> // %*ENV<SELKIE_DIAGNOSTIC_LOG>;
if $path.defined && $path.Str.trim.chars {
try spurt $path.Str, "$line\n", :append;
} else {
try $*ERR.say($line);
}
}
#|( Test/benchmark hook to inspect or override the shim latch.
Pass no argument to read; pass True/False to force the path
used by subsequent C<!copy-cells> calls. Used by xt/ tests to
drive both code paths against the same widget tree without
needing two separate processes. NOT part of the public API —
don't depend on this from app code; the binding's own load-
failure detection is the right hook for runtime decisions. )
sub viewported-cardlist-shim-available(Bool $val?) is export {
$shim-available = $val if $val.defined;
$shim-available;
}
has @!items;
has @!height-prefix;
has %!visible-indices;
has Int $!selected = 0;
has UInt $!scroll-offset = 0;
has UInt $!content-height = 0;
has Supplier $!select-supplier = Supplier.new;
has NcplaneHandle $!backing-plane;
has Bool $.show-scrollbar = True;
#|( Anchor the last item to the bottom of the viewport when total
content is shorter than the pane. Empty space appears above the
cards instead of below — standard chat-history layout where the
transcript reads upward from the input. With C<bottom-anchor =>
False> (default) short content top-aligns. )
has Bool $.bottom-anchor = False;
#|( Auto-pin the bottom of content as it grows. When True, each
render checks the C<follow-active> latch; if set, the new
scroll offset snaps to C<max-offset> so streaming additions and
height growth on the last card stay visible. The latch is
maintained exclusively by C<scroll-to> (the funnel for every
user-driven scroll mutator), so content-shape changes between
frames don't disturb follow status. Any user scroll up
disengages until they scroll back to the bottom. )
has Bool $.follow-bottom = False;
#|( Persistent tail-follow latch, only meaningful when
C<follow-bottom> is True. Computed on every C<scroll-to> call
based on whether the user landed at C<max-offset>. C<render>
reads this flag without touching it; mid-frame content-shape
changes (C<set-item-height> while a streamed message grows,
C<add-item> appending a new card) do not affect follow status.
The previous implementation re-derived follow per frame from
C<scroll-offset >= max-offset> against the freshly-grown
max-offset. That snapshot proved fragile: the very first
streamed token grew C<content-height> past the cached
C<scroll-offset>, the per-frame check flipped to False, and
C<follow-bottom> silently disengaged on token #1. Tracking
persistent state survives. )
has Bool $!follow-active = True;
#|( Layout-vs-content dirty distinction. Layout-dirty means card
positions in C<self.plane> have shifted: scroll moved, a card's
height changed, items were added or removed, the viewport
resized. The whole visible region needs a full erase + full
re-merge. Content-only dirty (this flag is False, but VCL's
own C<is-dirty> latch is True via the parent-chain cascade
from a descendant's C<mark-dirty>) means a card's contents
changed at stable cell positions — typically an
C<image-gen/progress> bar update or a streaming text token
that fits within the existing wrapped row count. Only that
card's region needs erasing and re-merging; other visible
cards' cells in C<self.plane> are still valid from the
previous frame.
Default True so the first render does the full path. Cleared
at the end of every render. Set by C<scroll-to> (when offset
changes), C<set-item-height> (when height changes), C<add-
item>, C<clear-items>, C<handle-resize>. )
has Bool $!layout-dirty = True;
submethod TWEAK() {
self.on-click: -> $ev {
my $row = self.local-row($ev);
if $row >= 0 {
my Int $content-row = $row - self!bottom-shift.Int;
my $idx = $content-row >= 0
?? self!card-index-at-content-row(($!scroll-offset + $content-row.UInt).UInt)
!! -1;
self.select-index($idx) if $idx >= 0 && $idx != $!selected;
}
};
# Mouse-wheel scrolling. Registered as an explicit on-scroll
# handler rather than a `given/when` branch on $ev.id inside
# handle-event: a click anywhere inside a card bubbles up through
# Text → VBox → Border → ViewportedCardList, and the explicit
# registration lets the framework's `dispatch-mouse-handlers` see
# this widget as the scroll target during that bubble. Three rows
# per tick matches CardList's "wheel = move by one logical unit"
# idiom while staying fine-grained enough that quick wheel flicks
# don't overshoot the next card in a row-scrolling viewport.
self.on-scroll: -> $ev {
given $ev.id {
when NCKEY_SCROLL_UP { self.scroll-by(-3) }
when NCKEY_SCROLL_DOWN { self.scroll-by( 3) }
}
};
}
method on-select(--> Supply) { $!select-supplier.Supply }
method selected(--> Int) { $!selected }
method count(--> Int) { @!items.elems }
method scroll-offset(--> UInt) { $!scroll-offset }
method content-height(--> UInt) { $!content-height }
method viewport-height(--> UInt) { self.rows }
method at-end(--> Bool) { $!scroll-offset >= self!max-offset }
method visible-index-count(--> Int) { %!visible-indices.elems }
#|( Read-only view of the persistent tail-follow latch. True when
C<follow-bottom> is enabled and the user is at (or has been
clamped to) C<max-offset>. Always True when C<follow-bottom> is
False — the flag is simply unused. Useful for surfacing a
"follow-mode" indicator in the UI and for tests that want to
verify follow transitions without driving a full render. )
method follow-active(--> Bool) { $!follow-active }
method selected-item() {
return Nil unless $!selected >= 0 && $!selected < @!items.elems;
@!items[$!selected]<widget>;
}
method children(--> List) {
gather {
for @!items -> %item {
take %item<border> if %item<border>.defined;
take %item<root> if %item<root>.defined;
}
}.List;
}
method add-item($widget, :$root!, :$height!, :$border, UInt :$min-display-height = 1) {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.add-item', cat => 'ui',
args => %(count => @!items.elems, height => $height))
!! Nil;
$border.focus-from-store = False if $border;
$root.parent = self if $root.defined && !$root.parent.defined;
$border.parent = self if $border.defined && !$border.parent.defined;
$root.set-store(self.store) if self.store && $root.can('set-store');
my UInt $h = ($height max 0).UInt;
@!items.push({ :$widget, :$root, height => $h, :$border, :$min-display-height });
$!content-height += $h;
@!height-prefix.push($!content-height);
$!layout-dirty = True; # new card → backing-plane grows + new region
self.mark-dirty;
$span.finish(count => @!items.elems,
content_height => $!content-height) with $span;
}
method clear-items() {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.clear-items', cat => 'ui',
args => %(count => @!items.elems,
content_height => $!content-height))
!! Nil;
for @!items -> %item {
%item<root>.destroy if %item<root>.plane;
}
@!items = ();
@!height-prefix = ();
%!visible-indices = ();
$!selected = 0;
$!scroll-offset = 0;
$!content-height = 0;
$!layout-dirty = True; # everything is gone; force full redraw
self.mark-dirty;
$span.finish(ok => True) with $span;
}
method remove-index(Int:D $idx --> Bool) {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.remove-index', cat => 'ui',
args => %(index => $idx,
count => @!items.elems,
content_height => $!content-height))
!! Nil;
my Bool $trace-ok = False;
LEAVE {
$span.finish(ok => $trace-ok,
count => @!items.elems,
content_height => $!content-height,
selected => $!selected,
scroll_offset => $!scroll-offset) with $span;
}
return False unless $idx >= 0 && $idx < @!items.elems;
my %item = @!items[$idx];
%item<root>.destroy if %item<root>.defined && %item<root>.plane;
my UInt $old-height = (%item<height> // 0).UInt;
@!items.splice($idx, 1);
@!height-prefix.splice($idx, 1);
$!content-height = $!content-height >= $old-height
?? $!content-height - $old-height
!! 0;
for $idx .. @!height-prefix.end -> $i {
@!height-prefix[$i] = ((@!height-prefix[$i].Int - $old-height.Int) max 0).UInt;
}
%!visible-indices = ();
if @!items {
$!selected = $!selected min @!items.end;
$!scroll-offset = $!scroll-offset min self!max-offset;
} else {
$!selected = 0;
$!scroll-offset = 0;
$!follow-active = True;
}
$!layout-dirty = True;
self.mark-dirty;
$trace-ok = True;
True;
}
#|( Update the cached row height of the card at C<$idx> and clamp
the current scroll offset to the new C<max-offset>. Does NOT
route through C<scroll-to>: that's the user-input funnel that
recomputes C<$!follow-active> from where the caller landed,
and a content-shape change is not a user action. Routing
through it would clobber the latch the moment the last card
grew (old offset is no longer >= new max), defeating
C<follow-bottom> on the very first streamed token. The render
pass re-engages the latch separately if a content shrink
leaves the offset exactly at the new max — see C<render>.
Skips entirely (no clamp, no mark-dirty, no layout-dirty) when
the new height equals the cached one. Streaming consumers call
this on every token via C<self!card-height($content, $role)>;
most tokens append to an existing wrapped line and don't grow
the card's row count, so the call is a true no-op. Forcing a
re-render in that case used to trigger a full ViewportedCardList
re-merge per token. )
method set-item-height(Int $idx, Int $height) {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.set-item-height', cat => 'ui',
args => %(index => $idx,
height => $height,
count => @!items.elems))
!! Nil;
my Bool $trace-changed = False;
my Int $trace-delta = 0;
LEAVE {
$span.finish(changed => $trace-changed,
delta => $trace-delta,
content_height => $!content-height,
scroll_offset => $!scroll-offset) with $span;
}
return unless $idx >= 0 && $idx < @!items.elems;
my UInt $new-height = ($height max 0).UInt;
my UInt $old-height = @!items[$idx]<height>.UInt;
return if $old-height == $new-height;
@!items[$idx]<height> = $new-height;
my Int $delta = $new-height.Int - $old-height.Int;
$trace-delta = $delta;
$!content-height = ($!content-height.Int + $delta).UInt;
for $idx .. @!height-prefix.end -> $i {
@!height-prefix[$i] = (@!height-prefix[$i].Int + $delta).UInt;
}
my UInt $max = self!max-offset;
$!scroll-offset = $!scroll-offset min $max;
$!layout-dirty = True; # card positions below this one shift
self.mark-dirty;
$trace-changed = True;
}
method select-index(Int $idx) {
return unless @!items;
my $new = ($idx max 0) min @!items.end;
return if $new == $!selected;
$!selected = $new;
self!ensure-selected-visible;
self.mark-dirty;
$!select-supplier.emit($!selected);
}
method select-first() {
return unless @!items;
$!selected = 0;
self.scroll-to-start;
}
method select-last() {
return unless @!items;
$!selected = @!items.end;
self.scroll-to-end;
}
method scroll-up() { self!select-prev }
method scroll-down() { self!select-next }
#|( Set the scroll offset to C<$row> (clamped to C<max-offset>). All
user-driven scroll mutators — C<scroll-by>, C<scroll-page-by>,
C<scroll-to-start>, C<scroll-to-end>, the mouse-wheel handler,
key navigation, and C<!ensure-selected-visible> — funnel
through here so the C<follow-active> latch updates in exactly
one place: re-engaged at C<max-offset>, disengaged anywhere
short of it. Content-shape changes (C<set-item-height>,
C<add-item>) intentionally do not route through here; they
clamp directly without disturbing the latch. )
method scroll-to(UInt $row) {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.scroll-to', cat => 'ui',
args => %(requested => $row,
current => $!scroll-offset,
content_height => $!content-height,
rows => self.rows))
!! Nil;
my Bool $trace-changed = False;
LEAVE {
$span.finish(changed => $trace-changed,
scroll_offset => $!scroll-offset,
follow_active => $!follow-active) with $span;
}
my UInt $max = self!max-offset;
my UInt $new = $row min $max;
$!follow-active = $new >= $max;
return if $new == $!scroll-offset;
$!scroll-offset = $new;
$!layout-dirty = True; # card positions in self.plane shift
self.mark-dirty;
$trace-changed = True;
}
method scroll-by(Int $delta) {
my Int $new = $!scroll-offset + $delta;
$new = $new max 0;
self.scroll-to($new.UInt);
self!select-nearest-visible-if-needed;
}
method scroll-page-by(Int $direction) {
self.scroll-by($direction * self.rows.Int);
}
method scroll-to-start() { self.scroll-to(0) }
method scroll-to-end() { self.scroll-to(self!max-offset) }
method handle-resize(UInt $rows, UInt $cols) {
my $changed = $rows != self.rows || $cols != self.cols;
return unless $changed;
self.resize($rows, $cols);
$!layout-dirty = True; # viewport resized; full re-layout
self!on-resize;
}
method park() {
self.reposition(self.park-y, 0);
self!park-children(@!items.map(*.<root>));
}
method destroy() {
.<root>.destroy for @!items;
@!items = ();
ncplane_destroy($!backing-plane) if $!backing-plane;
$!backing-plane = NcplaneHandle;
self!destroy-plane;
}
#|( Two-phase render. Phase 1 walks visible items, positions and
sizes their planes inside the backing plane, and renders each
card whose subtree changed since the previous frame (or every
card if C<$!layout-dirty> — scroll, height, add/remove). Phase
2 merges the rendered card planes onto C<self.plane>:
=item * Layout-dirty path: erase C<self.plane> once and
re-merge every visible card. Card positions in C<self.plane>
have shifted, so cells from the previous frame are stale
everywhere.
=item * Content-dirty-only path: leave C<self.plane> alone
except for the dirty cards' regions. Erase each dirty
card's slice via C<ncplane_erase_region>, then merge that
card. Other cards' cells are still in the right place from
last frame and survive untouched. This is the hot path
during ComfyUI image-gen progress and during streaming
text tokens that don't grow the wrap row count.
The merge primitive itself is C<!copy-cells> — see that
method's notes on why the obvious-looking C<ncplane_mergedown>
swap is wrong here (mergedown composites at absolute pile
coordinates, not at the scroll-translated dst we need). The
layout/content split above is what saves work in the common
streaming + progress cases. )
method render() {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.render', cat => 'render',
args => %(count => @!items.elems,
rows => self.rows,
cols => self.cols,
content_height => $!content-height,
scroll_offset => $!scroll-offset,
layout_dirty => $!layout-dirty))
!! Nil;
my Str $trace-exit = 'done';
my Int $trace-visible = 0;
my Int $trace-rendered = 0;
my Int $trace-merged = 0;
LEAVE {
$span.finish(exit => $trace-exit,
visible => $trace-visible,
rendered => $trace-rendered,
merged => $trace-merged,
layout_dirty => $!layout-dirty,
scroll_offset => $!scroll-offset,
follow_active => $!follow-active) with $span;
}
return without self.plane;
my UInt $vh = self.rows;
my UInt $vw = self.cols;
my UInt $content-w = self!content-width;
if !@!items || $vh == 0 || $vw == 0 || $content-w == 0 {
$trace-exit = 'empty';
ncplane_erase(self.plane);
$!layout-dirty = False;
return self.clear-dirty;
}
my UInt $max = self!max-offset;
if $!follow-bottom && $!follow-active {
if $!scroll-offset != $max {
$!scroll-offset = $max;
$!layout-dirty = True;
}
} else {
my $clamped = $!scroll-offset min $max;
if $clamped != $!scroll-offset {
$!scroll-offset = $clamped;
$!layout-dirty = True;
}
$!follow-active = True if $!scroll-offset >= $max;
}
self!ensure-backing-plane($!content-height max 1, $content-w);
# Backing plane content is always re-painted by visible cards'
# renders; erase only when layout shifted so old card content
# at moved positions doesn't bleed into new layout.
ncplane_erase($!backing-plane) if $!layout-dirty;
my Bool $show-bar = $!show-scrollbar && $!content-height > $vh;
my @visible;
my %new-visible;
my Int $start-idx = self!card-index-at-content-row($!scroll-offset);
$start-idx = 0 if $start-idx < 0;
my UInt $cum-y = self!item-top($start-idx);
my UInt $visible-bottom = $!scroll-offset + $vh;
for $start-idx ..^ @!items.elems -> $i {
last if $cum-y >= $visible-bottom;
my %item = @!items[$i];
my UInt $h = %item<height>.UInt;
my UInt $end = $cum-y + $h;
my Bool $visible = $end > $!scroll-offset && $cum-y < $!scroll-offset + $vh;
if !$visible {
%item<root>.park if %item<root>.plane;
$cum-y = $end;
next;
}
%new-visible{$i} = True;
my $border = %item<border>;
if $border {
$border.set-has-focus($i == $!selected);
$border.hide-top-border = False;
$border.hide-bottom-border = False;
}
my $widget = %item<widget>;
if $widget.can('set-clipped') {
$widget.set-clipped(
top => $cum-y < $!scroll-offset,
bottom => $end > $!scroll-offset + $vh,
);
}
if $widget.can('prepare-visible') {
$widget.prepare-visible;
}
my $root = %item<root>;
# Capture the dirty-cascade signal BEFORE the per-card
# set-up that resizes and may flip the latch via mark-dirty.
my Bool $card-was-dirty = !$root.plane.defined || $root.is-dirty;
if $root.plane {
$root.reposition($cum-y, 0);
$root.resize($h, $content-w);
} else {
$root.init-plane($!backing-plane, y => $cum-y, x => 0, rows => $h, cols => $content-w);
}
$root.set-viewport(
abs-y => self.abs-y + $cum-y.Int - $!scroll-offset.Int,
abs-x => self.abs-x,
rows => $h,
cols => $content-w,
);
if $!layout-dirty || $card-was-dirty {
$root.mark-dirty;
$root.render;
$trace-rendered++;
}
# Compute the destination slice in self.plane for the
# content-only re-merge path. Mirrors the math in
# !merge-widget-plane but at the card-root level. Note:
# `max 0` here is the infix clamp; `.max(0)` is the
# sort-key method form and would type-error on 0 as a
# Callable (see memory raku_max_min_method_trap).
my Int $dst-y = (($cum-y.Int - $!scroll-offset.Int) max 0)
+ self!bottom-shift.Int;
my Int $src-clip-top = ($!scroll-offset.Int - $cum-y.Int) max 0;
my Int $vh-clip = ($h.Int - $src-clip-top) min ($vh.Int - $dst-y);
my UInt $card-vh = ($vh-clip max 0).UInt;
@visible.push: %(
:$root, :$dst-y, :$card-vh,
should-merge => ($!layout-dirty || $card-was-dirty),
);
$cum-y = $end;
}
$trace-visible = @visible.elems;
for %!visible-indices.keys -> $key {
my Int $i = $key.Int;
next if %new-visible{$i};
next unless $i >= 0 && $i < @!items.elems;
my $root = @!items[$i]<root>;
$root.park if $root.defined && $root.plane;
}
%!visible-indices = %new-visible;
if $!layout-dirty {
ncplane_erase(self.plane);
for @visible -> %v {
self!merge-subtree(%v<root>);
$trace-merged++;
}
} else {
for @visible -> %v {
next unless %v<should-merge>;
# Erase just this card's slice in self.plane so cells
# in positions where the new render produces empty
# source cells (mergedown skips those) don't keep
# showing the previous frame's content. Width is
# $content-w, not $vw — the rightmost column belongs to
# the scrollbar and gets re-painted by !render-scrollbar.
ncplane_erase_region(self.plane, %v<dst-y>, 0,
%v<card-vh>.Int, $content-w.Int)
if %v<card-vh> > 0;
self!merge-subtree(%v<root>);
$trace-merged++;
}
}
ncplane_move_yx($!backing-plane, self.park-y, 0);
self!render-scrollbar if $show-bar;
$!layout-dirty = False;
self.clear-dirty;
}
method handle-event(Selkie::Event $ev --> Bool) {
return True if self!check-keybinds($ev);
return False unless @!items;
if $ev.event-type ~~ MouseEvent {
# Scroll wheel handled here in addition to the on-scroll TWEAK
# registration. The explicit given/when works for the historic
# path where this widget is the direct event target; the
# on-scroll registration kicks in when the framework's bubble
# finds it through `dispatch-mouse-handlers`. Belt-and-suspenders
# — at least one of the two reliably fires regardless of how
# the host App routes mouse events.
given $ev.id {
when NCKEY_SCROLL_UP { self.scroll-by(-3); return True }
when NCKEY_SCROLL_DOWN { self.scroll-by( 3); return True }
}
return True if self!dispatch-mouse-handlers($ev);
}
if $ev.event-type ~~ KeyEvent {
given $ev.id {
when NCKEY_UP {
if $ev.has-modifier(Mod-Shift) { self.scroll-by(-1) } else { self!select-prev }
return True;
}
when NCKEY_DOWN {
if $ev.has-modifier(Mod-Shift) { self.scroll-by( 1) } else { self!select-next }
return True;
}
when NCKEY_PGUP { self.scroll-page-by(-1); return True }
when NCKEY_PGDOWN { self.scroll-page-by( 1); return True }
when NCKEY_HOME { self.select-first; return True }
when NCKEY_END { self.select-last; return True }
}
}
False;
}
method !select-next() {
return unless @!items && $!selected < @!items.end;
$!selected++;
self!ensure-selected-visible;
self.mark-dirty;
$!select-supplier.emit($!selected);
}
method !select-prev() {
return unless @!items && $!selected > 0;
$!selected--;
self!ensure-selected-visible;
self.mark-dirty;
$!select-supplier.emit($!selected);
}
method !max-offset(--> UInt) {
my UInt $vh = self.rows;
$!content-height > $vh ?? $!content-height - $vh !! 0;
}
method !content-width(--> UInt) {
$!show-scrollbar ?? (self.cols - 1) max 0 !! self.cols;
}
method !ensure-backing-plane(UInt $rows, UInt $cols) {
if $!backing-plane {
ncplane_resize_simple($!backing-plane, $rows, $cols);
ncplane_move_yx($!backing-plane, 0, 0);
ncplane_move_family_below($!backing-plane, self.plane);
} else {
my $opts = NcplaneOptions.new(y => 0, x => 0, :$rows, :$cols);
$!backing-plane = ncplane_create(self.plane, $opts);
die "Failed to create ViewportedCardList backing plane" without $!backing-plane;
ncplane_move_family_below($!backing-plane, self.plane);
}
}
method !ensure-selected-visible() {
return unless @!items;
my ($top, $bot) = self!item-bounds($!selected);
my UInt $vh = self.rows;
if $top < $!scroll-offset {
self.scroll-to($top);
} elsif $bot > $!scroll-offset + $vh {
my Int $new = $bot.Int - $vh.Int;
self.scroll-to(($new max 0).UInt);
}
}
method !select-nearest-visible-if-needed() {
return unless @!items;
my ($top, $bot) = self!item-bounds($!selected);
return if $bot > $!scroll-offset && $top < $!scroll-offset + self.rows;
my $idx = self!card-index-at-content-row($!scroll-offset);
if $idx >= 0 && $idx != $!selected {
$!selected = $idx;
$!select-supplier.emit($!selected);
}
}
method !item-bounds(Int $idx --> List) {
return (0, 0) unless $idx >= 0 && $idx < @!items.elems;
my UInt $top = self!item-top($idx);
($top, @!height-prefix[$idx].UInt);
}
method !card-index-at-content-row(UInt $row --> Int) {
return -1 unless @!height-prefix;
my Int $lo = 0;
my Int $hi = @!height-prefix.end;
while $lo <= $hi {
my Int $mid = (($lo + $hi) div 2).Int;
if $row < @!height-prefix[$mid] {
$hi = $mid - 1;
} else {
$lo = $mid + 1;
}
}
$lo < @!height-prefix.elems ?? $lo !! @!items.end;
}
method !item-top(Int $idx --> UInt) {
return 0 if $idx <= 0;
@!height-prefix[$idx - 1].UInt;
}
method !merge-subtree($widget) {
self!merge-widget-plane($widget);
if $widget.can('children') {
for $widget.children -> $child {
self!merge-subtree($child);
}
}
# Border (and any single-content Container that doesn't push its
# content into @!children) exposes its wrapped widget only via
# .content. Without this branch the contents of a Border never get
# merged onto self.plane and the card renders as an empty frame.
if $widget.can('content') {
my $content = $widget.content;
self!merge-subtree($content) if $content.defined;
}
}
method !merge-widget-plane($widget) {
return without $widget.plane;
my Int $wy = ncplane_abs_y($widget.plane) - ncplane_abs_y($!backing-plane);
my Int $wx = ncplane_abs_x($widget.plane) - ncplane_abs_x($!backing-plane);
my UInt $wh = $widget.rows;
my UInt $ww = $widget.cols;
return if $wh == 0 || $ww == 0;
my Int $src-top = ($!scroll-offset.Int - $wy) max 0;
my Int $src-left = (0 - $wx) max 0;
my Int $dst-y = (($wy - $!scroll-offset.Int) max 0) + self!bottom-shift.Int;
my Int $dst-x = $wx max 0;
my Int $len-y = (($wy + $wh.Int) min ($!scroll-offset.Int + self.rows.Int)) - ($wy + $src-top);
my Int $len-x = (($wx + $ww.Int) min self!content-width.Int) - ($wx + $src-left);
return if $len-y <= 0 || $len-x <= 0;
my Bool $handled-image = False;
if $widget.can('render-viewport-crop') {
$handled-image = $widget.render-viewport-crop(
parent-plane => self.plane,
dest-y => $dst-y,
dest-x => $dst-x,
source-row => $src-top,
source-col => $src-left,
rows => $len-y.UInt,
cols => $len-x.UInt,
);
}
self!copy-cells(
$widget.plane,
src-y => $src-top,
src-x => $src-left,
dst-y => $dst-y,
dst-x => $dst-x,
rows => $len-y.UInt,
cols => $len-x.UInt,
) unless $handled-image;
}
#|( One-call batched copy of a C<$rows × $cols> rectangle from
C<$src> at (src-y, src-x) onto C<self.plane> at (dst-y, dst-x).
Substitutes the source plane's base cell into empty cells so
Border interiors carry the theme background through the copy
(matches notcurses's own C<ncplane_at_yx> behaviour).
Fast path: C<libnotcurses_native_shim>'s
C<notcurses_native_copy_cells> — one C call per invocation
instead of 5+ NativeCall trips per cell. For a typical 30×100
widget plane that's a 15,000× reduction in boundary crossings.
Fallback path (C<!copy-cells-raku>): the original per-cell
Raku loop, used when the shim isn't loadable (no C toolchain
AND no prebuilt-bundled shim — see Notcurses::Native's
Build.rakumod). Functionally identical, just slower; the latch
flips once per process so we don't retry on every render.
Other primitives considered and rejected:
=item C<ncplane_mergedown> — composites at absolute pile
coordinates (validates the slice args but doesn't actually
use them; see C<src/lib/render.c> in notcurses), not at the
scroll-translated dst we need. Cards' planes live at
backing-plane positions, so mergedown paints them there.
Wrong for our usage.
=item C<ncplane_contents> — bulk-reads cell glyphs but discards
styles/colors. Lossy. )
method !copy-cells(
NcplaneHandle $src,
Int :$src-y!,
Int :$src-x!,
Int :$dst-y!,
Int :$dst-x!,
UInt :$rows!,
UInt :$cols!,
) {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.copy-cells', cat => 'render',
args => %(rows => $rows,
cols => $cols,
cells => ($rows * $cols),
shim_available => $shim-available))
!! Nil;
my Str $trace-path = $shim-available ?? 'native' !! 'raku';
my Bool $trace-ok = False;
LEAVE { $span.finish(ok => $trace-ok, path => $trace-path) with $span; }
if $shim-available {
my $rc = try notcurses_native_copy_cells(
$src, self.plane,
$src-y, $src-x,
$dst-y, $dst-x,
$rows, $cols,
);
if $rc.defined {
$trace-ok = True;
return;
}
# Shim binding failed — most likely the lib wasn't compiled
# at install time and isn't in the prebuilt either. Flip the
# latch so subsequent calls skip straight to the Raku path,
# and report once so the user knows perf is degraded.
$shim-available = False;
warn-copy-shim-unavailable($!.message // 'unknown');
}
$trace-path = 'raku';
self!copy-cells-raku(
$src,
:$src-y, :$src-x,
:$dst-y, :$dst-x,
:$rows, :$cols,
);
$trace-ok = True;
}
#|( Per-cell Raku fallback for !copy-cells. Used only when the
notcurses native shim isn't loadable. Reads each source cell
via C<ncplane_at_yx_cell> (the heap-stable variant of
C<ncplane_at_yx> — see C<memory/nativecall_str_free_trap.md>
for why we don't use the malloc'ing version), substitutes the
source plane's base cell when a cell has an empty glyph (the
Border-interior case), and writes each cell to C<self.plane>
via C<ncplane_putstr_yx> with matched styles + channels. )
method !copy-cells-raku(
NcplaneHandle $src,
Int :$src-y!,
Int :$src-x!,
Int :$dst-y!,
Int :$dst-x!,
UInt :$rows!,
UInt :$cols!,
) {
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('vcl.copy-cells-raku', cat => 'render',
args => %(rows => $rows,
cols => $cols,
cells => ($rows * $cols)))
!! Nil;
my Int $trace-written = 0;
LEAVE { $span.finish(written => $trace-written) with $span; }
my $cell = Nccell.new;
my $base = Nccell.new;
ncplane_base($src, $base);
my $base-egc = nccell_extended_gcluster($src, $base);
my $base-styles = $base.stylemask;
my $base-channels = $base.channels;
for ^$rows -> $row {
for ^$cols -> $col {
my $bytes = ncplane_at_yx_cell(
$src,
$src-y + $row.Int,
$src-x + $col.Int,
$cell,
);
next if $bytes < 0;
my $egc = nccell_extended_gcluster($src, $cell);
my ($write-egc, $write-styles, $write-channels);
if !$egc.defined || $egc eq '' {
$write-egc = $base-egc;
$write-styles = $base-styles;
$write-channels = $base-channels;
} else {
$write-egc = $egc;
$write-styles = $cell.stylemask;
$write-channels = $cell.channels;
}
next unless $write-egc.defined && $write-egc.chars;
ncplane_set_styles(self.plane, $write-styles);
ncplane_set_channels(self.plane, $write-channels);
ncplane_putstr_yx(
self.plane,
$dst-y + $row.Int,
$dst-x + $col.Int,
$write-egc,
);
$trace-written++;
}
}
}
method !bottom-shift(--> UInt) {
$!bottom-anchor && $!content-height < self.rows
?? self.rows - $!content-height
!! 0;
}
method !render-scrollbar() {
my UInt $vh = self.rows;
my UInt $sx = self.cols - 1;
my $track-style = self.theme.scrollbar-track;
my $thumb-style = self.theme.scrollbar-thumb;
my Rat $thumb-ratio = $vh / $!content-height;
my UInt $thumb-h = ($vh * $thumb-ratio).ceiling.UInt max 1;
my UInt $thumb-y = (($!scroll-offset / self!max-offset) * ($vh - $thumb-h)).floor.UInt;
for ^$vh -> $row {
if $row >= $thumb-y && $row < $thumb-y + $thumb-h {
ncplane_set_fg_rgb(self.plane, $thumb-style.fg) if $thumb-style.fg.defined;
ncplane_set_bg_rgb(self.plane, $thumb-style.bg) if $thumb-style.bg.defined;
ncplane_putstr_yx(self.plane, $row, $sx, '┃');
} else {
ncplane_set_fg_rgb(self.plane, $track-style.fg) if $track-style.fg.defined;
ncplane_set_bg_rgb(self.plane, $track-style.bg) if $track-style.bg.defined;
ncplane_putstr_yx(self.plane, $row, $sx, '│');
}
}
}