← All libraries

SugarGallery

SugarGallery

Poster grids & rails for media TUIs — virtualized PosterGrid, Rail carousel, PosterCard

original — no upstream parallel grid gallery virtualization media

Browse-row widgets for media terminal UIs: a 2-D virtualized PosterGrid for large libraries, a horizontal Rail carousel for browse rows, and a single PosterCard tile. The grid is sparse — it knows the total item count up front but holds only the cards that have been fetched, keyed by their absolute index, so a 50,000-item library renders as cheaply as a 50-item one; missing indices draw as skeletons and only the rows inside the viewport are ever rendered. Paging is owner-driven: after every cursor move the owner reads visibleRange() (the absolute-index window now on screen — the terminal analogue of the web grid's need-range event) and splices the covering page back in with withItems(). The widgets are renderer-agnostic: a card holds already-rendered poster bytes (produce them however you like, e.g. candy-mosaic), so this lib pulls in no image decoder. Original SugarCraft component — no 1:1 upstream; inspired by charmbracelet/bubbles' list/viewport and the phlix web media grid.

Install

composer require sugarcraft/sugar-gallery

PosterGrid — virtualized, sparse, owner-paged

use SugarCraft\Gallery\PosterGrid;
use SugarCraft\Gallery\PosterCard;

$grid = PosterGrid::new(cardWidth: 16, posterHeight: 9)
    ->withViewport($cols, $rows)
    ->reset(total: 5000);          // a fresh result set

// Keyboard nav (map your keys to these — all clamp + keep the cursor on screen):
$grid = $grid->right();            // ← → move within a row
$grid = $grid->down();             // ↑ ↓ move between rows
$grid = $grid->pageDown();         // PgUp / PgDn
$grid = $grid->home()->end();      // Home / End
$grid = $grid->moveTo(2600);       // jump (e.g. an A–Z letter offset)

After each move, read the visible window and fetch the page(s) covering it, then splice the results back in at their absolute index — the need-range pattern:

[$start, $end] = $grid->visibleRange(overscanRows: 1);
if ($start <= $end && $start !== $lastFetchedStart) {
    // fetch items [$start, $end] from your API, build cards keyed by index…
    $grid = $grid->withItems([$start => $card0, $start + 1 => $card1, /* … */]);
}

// Async poster arrived for one cell?
$grid = $grid->withItem($index, $card->withPoster($ansi));

Render the visible rows (the cursor shows only when the grid is focused). Pass a candy-zone Manager to make each cell mouse-clickable — wrapped as zone id cell:<index>:

echo $grid->render(focused: true);

$frame = $grid->render(true, $zones);
$clean = $zones->scan($frame);                 // strip markers, record bounds
$zone  = $zones->anyInBounds($mouseMsg);       // → "cell:42"

Rail — horizontal carousel

use SugarCraft\Gallery\Rail;

$rail = new Rail('Continue Watching', $cards);
$rail = $rail->moveCursor(+1, Rail::perRow($railWidth, $cardWidth));
echo $rail->render($railWidth, focused: true, cardWidth: 16, posterHeight: 9);

PosterCard — one tile

use SugarCraft\Gallery\PosterCard;

$card = new PosterCard(id: '42', title: 'The Matrix', posterUrl: $url);
$card = $card->withPoster($renderedAnsi);   // attach when the async render lands
$card = $card->withProgress(0.6);           // optional continue-watching bar
echo $card->render(focused: true, width: 16, posterHeight: 9);

Behaviour

Sparse & absolute-indexedThe grid knows the total() up front but holds only fetched cards keyed by absolute index; indices with no card yet render as skeleton placeholders.
2-D virtualizationOnly the rows inside the viewport are ever rendered, so a 50,000-item library costs the same to draw as a 50-item one.
Owner-driven pagingvisibleRange(overscanRows) returns the absolute-index window on screen (with optional prefetch overscan) — the terminal analogue of the web grid's need-range event; the owner fetches and splices via withItems().
Clamped navigationleft/right/up/down/pageUp/pageDown/home/end/moveTo all clamp into range and scroll the minimum needed to keep the cursor's row visible.
Uniform cellsEach cell is a uniform cardWidth × (posterHeight + 2) box (reserving a title and a progress row), so columns and rows always line up regardless of which cards carry progress bars.
Optional mousePass a candy-zone Manager to render() and each real cell is wrapped as zone id cell:<index> for hit-testing — the caller scans the frame and resolves clicks.
Renderer-agnosticA PosterCard holds already-rendered poster bytes (produce them however you like, e.g. candy-mosaic), so the lib pulls in no image decoder.
ImmutableEvery mutator returns a new grid / rail / card and leaves the receiver untouched, slotting into the immutable-model (TEA) pattern; a no-op move returns the receiver by identity.

Use it for

Source & demos

Dependencies

Try the quickstart →

API

ClassMethodDescription
PosterGridnew(int $cardWidth, int $posterHeight, int $hSpacing = 2, int $vSpacing = 1): selfA new empty grid with the given card geometry
PosterGridreset(int $total): selfBegin a fresh result set — drop cached cards, return the cursor to the top
PosterGridwithTotal(int $total): selfUpdate the known total without dropping cards, clamping the cursor
PosterGridwithItems(array $items): selfSplice a page of cards in at their absolute indices (new cards win)
PosterGridwithItem(int $index, PosterCard $card): selfSet/replace a single card by absolute index (e.g. an async poster)
PosterGridwithViewport(int $cols, int $rows): selfResize the rendered area, re-clamping scroll to keep the cursor visible
PosterGridleft/right/up/down(): selfMove the cursor one cell, clamped and kept on screen
PosterGridpageUp/pageDown(): selfMove the cursor one viewport of rows
PosterGridhome/end(): selfJump the cursor to the first / last item
PosterGridmoveTo(int $index): selfJump the cursor to an absolute index (e.g. an A–Z offset), clamped
PosterGridvisibleRange(int $overscanRows = 0): arrayThe inclusive [start, end] absolute-index window on screen — the need-range; [0, -1] when empty
PosterGridcolumns/visibleRows/totalRows/cellHeight(): intDerived geometry of the current layout
PosterGridtotal/loadedCount/cursorIndex/cursorRow/scrollRow(): intCounts and cursor/scroll position accessors
PosterGriditem(int $index): ?PosterCardThe card at an absolute index, or null when not loaded
PosterGridcursorCard(): ?PosterCardThe card under the cursor, or null
PosterGridisEmpty(): boolWhether the grid has no items
PosterGridrender(bool $focused = true, ?ZoneManager $zones = null): stringRender the visible rows; cursor shown only when focused; optional candy-zone cell marking
Rail__construct(string $title, array $cards = [], int $cursor = 0, int $scroll = 0)A horizontal carousel of PosterCards with a title, cursor, and scroll offset
RailwithCards(array $cards): selfReplace the card list, clamping cursor/scroll into range
RailwithCard(PosterCard $card): selfReplace a single card by id (e.g. when its poster finishes loading)
RailfocusedCard(): ?PosterCardThe card under the cursor, or null
RailisEmpty(): boolWhether the rail has no cards
RailmoveCursor(int $delta, int $perRow): selfMove the cursor by $delta, scrolling so it stays visible
RailperRow(int $railWidth, int $cardWidth, int $spacing = 2): intHow many cards of $cardWidth fit in $railWidth at $spacing gap
Railrender(int $railWidth, bool $focused, int $cardWidth, int $posterHeight, int $spacing = 2): stringRender the title plus the visible slice of cards
PosterCard__construct(string $id, string $title, ?string $posterUrl = null, ?float $progress = null, ?string $poster = null)One tile: poster area, title, and optional progress bar
PosterCardwithPoster(string $ansi): selfAttach the rendered ANSI poster (e.g. when an async render resolves)
PosterCardwithProgress(?float $progress): selfSet (or clear) the continue-watching progress bar
PosterCardhasPoster(): boolWhether a rendered poster is attached
PosterCardrender(bool $focused, int $width, int $posterHeight): stringRender a fixed-$width block: poster (or placeholder) rows, a title row, and a progress row when set