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 prefersscroll-content-page-by(Int $direction)on the card widget if it's available โ passing+1for PgDown and-1for 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 toscroll-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โ smallestdisplay-hat 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 to1(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 leastavatar-rows + 2rows 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 wrappedScrollView) 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
Selkie::Widget::ListView โ simpler, string-only version
Selkie::Widget::ScrollView โ non-interactive virtual scroll
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 SupplySupply 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 IntIndex 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 IntNumber of cards in the list.
method handle-resize
method handle-resize(
Int $rows where { ... },
Int $cols where { ... }
) returns MuResize 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 MuPark 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 MuThe 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 ListExpose 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 MuAppend 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 MuDestroy 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 MuUpdate 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 MuMove 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 MuJump 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 MuJump selection to the first card. Does not emit on on-select.
method scroll-up
method scroll-up() returns MuMove 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 MuMove selection one card down. See scroll-up.
method scroll-selected-page
method scroll-selected-page(
Int $direction
) returns BoolDelegate 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".