Selkie--Widget--CardList

NAME

Selkie::Widget::CardList - Cursor-navigated scrollable list of variable-height widgets

SYNOPSIS

use Selkie::Widget::CardList;
use Selkie::Widget::Border;
use Selkie::Widget::RichText;
use Selkie::Sizing;

my $cards = Selkie::Widget::CardList.new(sizing => Sizing.flex);

# Each card has: an inner widget, a root (often a Border wrapping the
# inner widget), a height, and an optional border for focus highlighting.
for @messages -> %msg {
    my $rich = Selkie::Widget::RichText.new(sizing => Sizing.flex);
    $rich.set-content(%msg<spans>);
    my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
    $border.set-content($rich);
    $cards.add-item($rich, root => $border, height => 3, :$border);
}

$cards.on-select.tap: -> UInt $idx { show-detail($cards.selected-item) };

DESCRIPTION

Like Selkie::Widget::ListView, but each item is an arbitrary widget of configurable height rather than a string. The cursor moves between cards; the selected card always fully fits in the viewport, and the card at the opposite end may be partially clipped (with a visual truncation hint if the card's widget supports set-clipped).

Use this when your items are structured: chat messages with avatars, tasks with metadata, email threads, etc. Use ListView if items are just strings.

Keybindings

  • Up / Down / mouse wheel โ€” move the selection between cards.

  • Home / End โ€” jump to first / last card.

  • PageUp / PageDown โ€” scroll within the selected card. CardList prefers scroll-content-page-by(Int $direction) on the card widget if it's available โ€” passing +1 for PgDown and -1 for PgUp lets the card decide what one page means relative to its OWN viewport (chat messages have body viewports much smaller than the chat pane itself, and over-scrolling by the chat pane's row count would jump straight past most of the body). Falls back to scroll-content-by(ยฑself.rows) for legacy widgets. Cards without either method simply absorb the keypress (no cross-card movement on PgUp/PgDown โ€” Up/Down is the only cross-card movement, by design, so a long scrollable card never surprises the user by jumping to a neighbour). Use it for chat messages, code blocks, log entries โ€” anywhere a single card can outgrow its slot.

Item shape

Each item is registered with:

  • $widget (positional) โ€” the renderable widget inside the card (what the user sees)

  • :root โ€” the outermost container for the card (usually a Border wrapping the inner widget)

  • :height โ€” the card's logical height in rows

  • :border โ€” optional Border for focus-highlight integration

  • :min-display-height โ€” smallest display-h at which a partial render of this card is still meaningful. Non-selected cards whose visible height would fall below this threshold are parked rather than rendered as a sliver. Defaults to 1 (any positive sliver renders, the pre-existing behaviour). Useful for cards with structural minimums โ€” e.g. a chat card with a fixed-height avatar plus a name row plus a border edge needs at least avatar-rows + 2 rows before its partial render reads as "the bottom of a message" instead of "merged into the neighbour". The selected card is always exempt from this check; if it can't fully fit, the list relies on its own internal scrolling (e.g. a wrapped ScrollView) to handle the overflow.

EXAMPLES

Chat messages

See examples/chat.raku for the full version. In brief:

$app.store.subscribe-with-callback(
    'chat-cards',
    -> $s { $s.get-in('messages') // [] },
    -> @msgs {
        $cards.clear-items;
        for @msgs -> %m { $cards.add-item(|build-card(%m)) }
        $cards.select-last;
    },
    $cards,
);

SEE ALSO

has Bool $.bottom-anchor

When True and the rendered cards (from scroll-top through the last item) sum to less than the viewport height, render shifts every visible card down so the LAST item ends at the bottom of the viewport rather than leaving empty space below it. Designed for chat-style consumers where new content arrives at the bottom and the user expects the latest message to be anchored there even when the whole conversation fits on screen. Default False โ€” classic top-aligned list rendering for inventory / file-browser / pickers (e.g. AvatarList) where empty space below the last item is the right behaviour.

method on-select

method on-select() returns Supply

Supply that emits the new selected index whenever the selection moves (Up / Down / mouse click / select-index / select-first / select-last). Does not fire on add-item or clear-items โ€” those only mark-dirty.

method selected

method selected() returns Int

Index of the selected card. Stable across resizes / rebuilds. Returns 0 when the list is empty (selection is conventionally at index 0 for empty lists; pair with count if you need to disambiguate).

method count

method count() returns Int

Number of cards in the list.

method handle-resize

method handle-resize(
    Int $rows where { ... },
    Int $cols where { ... }
) returns Mu

Resize own plane only. Cards are sized / positioned / parked in render based on the current viewport โ€” a single authoritative pass per frame. Cascading handle-resize here with each card's stored logical height had produced the "two-state plane" bug where cards briefly had logical-height planes that extended past CardList's new bounds, bleeding into whatever widget sits below.

method park

method park() returns Mu

Park self plus every card root. CardList stores its items in @!items rather than self.children, so the standard Container.park doesn't reach them; we recurse explicitly here. Without this override, when a CardList scrolls or its host screen is swapped out, sprixels carried by Image widgets inside cards keep painting on the terminal at their last screen position.

method selected-item

method selected-item() returns Mu

The inner widget of the selected card (the $widget argument passed to add-item), or Nil when the list is empty / index is out of range.

method children

method children() returns List

Expose each card's root (and its border, if any) as children so Container-level cascade helpers (notably !unsubscribe-tree) reach them. CardList stores its cards in @!items rather than the inherited @!children array, so without this override the cascade walks an empty list and leaks subscriptions anchored inside cards.

method add-item

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

Append a card. The four parameters describe the card's structure: #| #| $widget โ€” the inner widget the card represents. Returned by #| selected-item. Usually a RichText, Text, or custom widget; #| does not need to manage its own plane (the :root handles that). #| #| :root! โ€” the widget that gets a plane and is rendered. Often a #| Border wrapping the inner widget, or the inner widget itself if #| no border is wanted. CardList drives reposition / resize / park on #| this root each frame. #| #| :height! โ€” the card's logical height in cells. CardList uses #| this to lay out cards stacked top-to-bottom and decide which fit #| in the viewport. Variable-height cards are the whole point โ€” every #| card can have its own height. #| #| :border โ€” optional. When provided, CardList drives this widget's #| set-has-focus per render so the border highlights the selected #| card regardless of where keyboard focus actually lives. Pass the #| same Border instance you used as :root to wire the highlight. #| #| :min-display-height โ€” when a card is partially clipped at the #| top or bottom edge of the viewport, this is the minimum visible #| height before CardList parks the card entirely instead of showing #| a sliver. Default 1.

method clear-items

method clear-items() returns Mu

Destroy every card and reset selection / scroll. Calls destroy on each card's root, so any subscriptions or sprixels owned by cards are cleaned up. Use before rebuilding the list to avoid leaks; for incremental updates, prefer set-item-height + selective add-item.

method set-item-height

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

Update an existing card's logical height (e.g. when its content reflows after a viewport resize). No-op when $idx is out of range. Triggers re-layout on the next render.

method select-index

method select-index(
    Int $idx
) returns Mu

Move the selection to $idx (clamped to the valid range). Emits on on-select. No-op when the list is empty.

method select-last

method select-last() returns Mu

Jump selection to the last card. Useful after appending content in chat-style consumers where the user wants to track the latest message. Does not emit on on-select โ€” symmetry with select-first.

method select-first

method select-first() returns Mu

Jump selection to the first card. Does not emit on on-select.

method scroll-up

method scroll-up() returns Mu

Move selection one card up. Alias for the internal !select-prev so external callers can advance the cursor without registering a keybind.

method scroll-down

method scroll-down() returns Mu

Move selection one card down. See scroll-up.

method scroll-selected-page

method scroll-selected-page(
    Int $direction
) returns Bool

Delegate a one-page scroll to the selected card. Tries scroll-content-page-by(ยฑ1) first so the card can use its OWN viewport size (a chat message's body is far smaller than the chat pane); falls back to scroll-content-by(ยฑself.rows) for legacy cards that only expose the absolute-delta API. Returns True (event handled) whenever a card is selected, even when the card doesn't support either method โ€” the alternative would be falling through to cross-card navigation, which surprises users who expect PgDown to walk further into the current message. Always returning True keeps PgUp/PgDown's contract simple: "scroll inside if you can, otherwise nothing happens".

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.