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.

sub warn-copy-shim-unavailable

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

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

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 set-item-height

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

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

method render() returns Mu

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 $!layout-dirty — scroll, height, add/remove). Phase 2 merges the rendered card planes onto self.plane: =item * Layout-dirty path: erase self.plane once and re-merge every visible card. Card positions in self.plane have shifted, so cells from the previous frame are stale everywhere. =item * Content-dirty-only path: leave 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 ComfyUI image-gen progress 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 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 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: =item ncplane_mergedown — composites at absolute pile coordinates (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 backing-plane positions, so mergedown paints them there. Wrong for our usage. =item 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 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.

Selkie v0.10.0

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.4.0+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::App
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalSequences
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.