Selkie.git | docs/api/ | Selkie--Widget--ViewportedCardList.md


NAME
====

Selkie::Widget::ViewportedCardList - Row-scrolled selectable list of card widgets

SYNOPSIS
========

```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);
```

DESCRIPTION
===========

Like [Selkie::Widget::CardList](Selkie--Widget--CardList.md), 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 to `1` (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 half

  * `prepare-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 at `add-item` time, so implementing it costs no per-frame method resolution:

```raku
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](Selkie--Widget--Image.md)) paint itself scroll-cropped instead of being cell-copied out of its plane. The hook also receives an empty slice (`rows == 0` or `cols == 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

```raku
sub warn-copy-shim-unavailable(
    Str:D $reason
) returns Nil
```

Module-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

```raku
sub viewported-cardlist-shim-available(
    Bool $val?
) returns Mu
```

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 `!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

```raku
method follow-active() returns Bool
```

Read-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

```raku
method add-item(
    $widget,
    :$root!,
    :$height!,
    :$border,
    Int :$min-display-height where { ... } = 1
) returns Mu
```

Append 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

```raku
method set-item-height(
    Int $idx,
    Int $height
) returns Mu
```

Update 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

```raku
method scroll-to(
    Int $row where { ... }
) returns Mu
```

Set 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

```raku
method render() returns Mu
```

Two-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

```raku
method min-display-threshold(
    $min-display-height,
    Int $h where { ... },
    Int $vh where { ... }
) returns Int
```

Smallest 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

```raku
method park-card(
    Int $idx
) returns Nil
```

Park 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

```raku
method apply-selected-state(
    Int $idx,
    Bool $selected
) returns Nil
```

Push 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

```raku
method ensure-backing-plane() returns Mu
```

Ensure 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

```raku
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 Mu
```

One-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

```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 Mu
```

Per-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.