Selkie--Widget--ViewportedCardList
NAME
Selkie::Widget::ViewportedCardList - Row-scrolled selectable list of card widgets
SYNOPSIS
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);DESCRIPTION
Like 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.
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). 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 (set-item-height from a streaming token, 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.
Item shape
Each item is registered through add-item:
$widget(positional) ā the renderable widget inside the card (what the user sees), and the receiver for the optional hooks below:rootā the outermost container for the card (usually a Border wrapping the inner widget). This is the widget that gets a plane, gets positioned in content space, and gets parked when it leaves the viewport:heightā the card's logical height in content rows:borderā optional Border, driven per render for the selection highlight:min-display-heightā smallest visible slice, in viewport rows, at which a partial render of this card is still meaningful. A card clipped by the top or bottom viewport edge to fewer rows than this is parked rather than drawn as a sliver, leaving its band of the viewport empty. Defaults to1(any positive sliver renders ā the pre-existing behaviour). The threshold is capped by the card's own height, so a card shorter than its declared minimum still renders in full, and by the viewport height, so a card tall enough to cover the whole pane is never parked out from under the user
Card hooks
Cards can opt into per-frame notifications by implementing methods on the $widget passed to add-item. All of them are duck-typed ā implement none, any, or all:
set-clipped(:$top, :$bottom)ā called every frame the card is visible, with whether it is cut off at the top or bottom viewport edge. Typical use: hide a header row that would otherwise be sliced in halfprepare-visible()ā called every frame the card is visible, immediately before its render. The place to fault in content that only matters on screen (decode an image, hydrate a body)set-selected(Bool)ā called when the card's selected state changes, and again on the first frame after the card re-enters the viewport. This is the selection affordance for cards registered without a:borderā a borderless card has no other way to show that it is the selected one. Probed once per card atadd-itemtime, so implementing it costs no per-frame method resolution:
class SelectableCard does Selkie::Widget {
has Bool $!selected = False;
method set-selected(Bool $on) {
return if $on == $!selected;
$!selected = $on;
self.set-style($on ?? $selected-style !! $normal-style);
self.mark-dirty;
}
method render() { ... }
}render-viewport-crop(...)ā lets a widget (in practice Selkie::Widget::Image) paint itself scroll-cropped instead of being cell-copied out of its plane. The hook also receives an empty slice (rows == 0orcols == 0) when the widget itself leaves the viewport while an ancestor card remains visible; crop implementations must treat that as a request to clear any out-of-band render state they still own
sub warn-copy-shim-unavailable
sub warn-copy-shim-unavailable(
Str:D $reason
) returns NilModule-level latch tracking whether libnotcurses_native_shim's 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.
sub viewported-cardlist-shim-available
sub viewported-cardlist-shim-available(
Bool $val?
) returns MuTest/benchmark hook to inspect or override the shim latch. Pass no argument to read; pass True/False to force the path used by subsequent !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.
has Bool $.bottom-anchor
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 bottom-anchor = False> (default) short content top-aligns.
has Bool $.follow-bottom
Auto-pin the bottom of content as it grows. When True, each render checks the follow-active latch; if set, the new scroll offset snaps to max-offset so streaming additions and height growth on the last card stay visible. The latch is maintained exclusively by 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-active
Persistent tail-follow latch, only meaningful when follow-bottom is True. Computed on every scroll-to call based on whether the user landed at max-offset. render reads this flag without touching it; mid-frame content-shape changes (set-item-height while a streamed message grows, add-item appending a new card) do not affect follow status. The previous implementation re-derived follow per frame from scroll-offset = max-offset> against the freshly-grown max-offset. That snapshot proved fragile: the very first streamed token grew content-height past the cached scroll-offset, the per-frame check flipped to False, and follow-bottom silently disengaged on token #1. Tracking persistent state survives.
has Bool $!layout-dirty
Layout-vs-content dirty distinction. Layout-dirty means card positions in 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 is-dirty latch is True via the parent-chain cascade from a descendant's mark-dirty) means a card's contents changed at stable cell positions ā typically an 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 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 scroll-to (when offset changes), set-item-height (when height changes), add- item, clear-items, handle-resize.
method follow-active
method follow-active() returns BoolRead-only view of the persistent tail-follow latch. True when follow-bottom is enabled and the user is at (or has been clamped to) max-offset. Always True when 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 add-item
method add-item(
$widget,
:$root!,
:$height!,
:$border,
Int :$min-display-height where { ... } = 1
) returns MuAppend a card. $widget (positional) ā the renderable inside the card, and the widget the optional duck-typed hooks are called on. :root ā the outermost container for the card (usually the Border); this is what gets a plane, gets positioned in content space, and gets parked. :height ā the card's logical height in content rows. :border ā optional. When given, its set-has-focus is driven per render so the selected card highlights regardless of where keyboard focus actually lives. :min-display-height ā the smallest visible slice, in viewport rows, at which a partial render of this card still reads as a card. A card clipped by the top or bottom viewport edge to fewer rows than this is parked instead of drawn as a sliver. Defaults to 1 ā any positive sliver renders, the pre-existing behaviour. $widget may also implement any of the duck-typed per-frame hooks ā set-clipped(:$top, :$bottom), prepare-visible(), set-selected(Bool), render-viewport-crop(...). See the "Card hooks" section of this module's documentation for what each one is called with and when. set-selected is probed here, once per card, rather than per frame.
method set-item-height
method set-item-height(
Int $idx,
Int $height
) returns MuUpdate the cached row height of the card at $idx and clamp the current scroll offset to the new max-offset. Does NOT route through scroll-to: that's the user-input funnel that recomputes $!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 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 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 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 scroll-to
method scroll-to(
Int $row where { ... }
) returns MuSet the scroll offset to $row (clamped to max-offset). All user-driven scroll mutators ā scroll-by, scroll-page-by, scroll-to-start, scroll-to-end, the mouse-wheel handler, key navigation, and !ensure-selected-visible ā funnel through here so the follow-active latch updates in exactly one place: re-engaged at max-offset, disengaged anywhere short of it. Content-shape changes (set-item-height, add-item) intentionally do not route through here; they clamp directly without disturbing the latch.
method render
method render() returns MuTwo-phase render. Phase 1 walks the items that intersect the viewport, positions and sizes their planes in the backing plane's content coordinate space, fires the per-card hooks (set-selected, set-clipped, prepare-visible), and renders each card whose subtree changed since the previous frame (or every card if $!layout-dirty ā scroll, height, add/remove). Cards that fall outside the viewport, and cards whose visible slice is under their min-display-height, are parked instead. Phase 2 merges the rendered card planes onto self.plane, by one of two paths. The layout-dirty path erases self.plane once and re-merges every visible card. Card positions in self.plane have shifted, so cells from the previous frame are stale everywhere. The content-dirty-only path leaves self.plane alone except for the dirty cards' regions: erase each dirty card's slice via 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 image-generation progress updates and during streaming text tokens that don't grow the wrap row count. The merge primitive itself is !copy-cells ā see that method's notes on why the obvious-looking 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 min-display-threshold
method min-display-threshold(
$min-display-height,
Int $h where { ... },
Int $vh where { ... }
) returns IntSmallest visible slice (in viewport rows) at which a card is worth drawing, given its declared min-display-height. Below it the render walk parks the card instead of drawing a sliver ā an avatar-plus-body chat card clipped to two rows reads as garbage merged into its neighbour, not as "the bottom of a message". Capped twice, and both caps matter. By the card's own $h: a card whose full height is already under its declared minimum (a 5-row card asking for 7) would otherwise never render at all. And by the viewport height $vh: a card taller than the pane can cover the whole viewport and still be "clipped", so without this cap a min-display-height above the pane height would blank the list entirely. The default min-display-height of 1 collapses the threshold to 1 (or 0 for a zero-height card), which is exactly the pre-existing "any positive sliver renders" behaviour.
method park-card
method park-card(
Int $idx
) returns NilPark the card at $idx off-screen and forget the selected state last pushed into its widget. Clearing the cache is what makes the duck-typed set-selected hook re-fire when the card scrolls back into view: selection can move any number of times while a card is parked, and a parked widget never sees those frames.
method apply-selected-state
method apply-selected-state(
Int $idx,
Bool $selected
) returns NilPush selection state into a card's widget via the optional set-selected(Bool) hook, if the widget declared one at add-item time. Fires only on a change (including the first frame a card is visible), so a widget can treat it as an edge trigger and rebuild styling there.
method ensure-backing-plane
method ensure-backing-plane() returns MuEnsure the backing plane exists, sits at the viewport origin, and is below self.plane in the pile. The backing plane is a 1Ć1 coordinate anchor, not a content-sized framebuffer. Card planes are created as its children at content coordinates (y = $cum-y), which notcurses stores as parent.abs + y ā no clamping, no clipping to the parent's rectangle (the same property that makes park work: a child at y=10,000 inside a 20-row parent is simply off-screen, not truncated). Everything downstream of that only ever reads the backing plane's origin: !merge-widget-plane subtracts ncplane_abs_y/x($!backing-plane) to recover a card's content- space position. Its own cells are never written ā cards render into their own planes and !copy-cells copies from those planes straight onto self.plane. Sizing it to the content was therefore pure cost, and O(content) at that: a long transcript (11,600 rows Ć 92 columns) allocated a ~17MB nccell framebuffer that nothing painted into, and the per-frame ncplane_erase memset all of it every time layout went dirty ā which is every scroll keystroke, every add-item, every set-item-height, every resize. At 1Ć1 both the allocation and the erase disappear and per-frame cost is O(viewport).
method copy-cells
method copy-cells(
Notcurses::Native::Types::NcplaneHandle $src,
Int :$src-y!,
Int :$src-x!,
Int :$dst-y!,
Int :$dst-x!,
Int :$rows! where { ... },
Int :$cols! where { ... }
) returns MuOne-call batched copy of a $rows Ć $cols rectangle from $src at (src-y, src-x) onto 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 ncplane_at_yx behaviour). Fast path: libnotcurses_native_shim's 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 (!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. ncplane_mergedown composites at absolute pile coordinates (it validates the slice args but doesn't actually use them; see src/lib/render.c in notcurses), not at the scroll-translated dst we need ā cards' planes live at content-space positions under the backing plane, so mergedown paints them there. Wrong for our usage. ncplane_contents bulk-reads cell glyphs but discards styles/colors. Lossy.
method copy-cells-raku
method copy-cells-raku(
Notcurses::Native::Types::NcplaneHandle $src,
Int :$src-y!,
Int :$src-x!,
Int :$dst-y!,
Int :$dst-x!,
Int :$rows! where { ... },
Int :$cols! where { ... }
) returns MuPer-cell Raku fallback for !copy-cells. Used only when the notcurses native shim isn't loadable. Reads each source cell via ncplane_at_yx_cell (the heap-stable variant of ncplane_at_yx ā see 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 self.plane via ncplane_putstr_yx with matched styles + channels.