sugarcraft/sugar-gallery

Poster grids and rails for media TUIs — a 2-D virtualized PosterGrid with sparse range paging, a horizontal Rail carousel, and a PosterCard tile. Renderer-agnostic (holds pre-rendered poster cells).

Maintainers

Package info

github.com/sugarcraft/sugar-gallery

Documentation

pkg:composer/sugarcraft/sugar-gallery

Transparency log

Statistics

Installs: 1 196

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-07-12 22:06 UTC

This package is auto-updated.

Last update: 2026-07-13 00:26:01 UTC


README

Poster grids and rails for media TUIs — a 2-D virtualized PosterGrid for large libraries, a horizontal Rail carousel for browse rows, and a PosterCard tile. 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.

Install

composer require sugarcraft/sugar-gallery

PosterGrid — virtualized, sparse, owner-paged

The grid knows the total item count up front but holds only the cards that have been fetched, keyed by their absolute index; missing indices render as skeletons. Only the rows inside the viewport are drawn, so a 50,000-item library renders as cheaply as a 50-item one.

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)

Owner-driven paging (the need-range pattern)

After each move, read the visible window and fetch the page(s) covering it, then splice the results back in at their absolute index. needsFetch() dedups the request against the last range you loaded, so the cursor can roam inside an already-fetched window without re-hitting your API:

$want = $grid->visibleRange(overscanRows: 1);
if ($grid->needsFetch($lastFetched, overscanRows: 1)) {
    // fetch items [$want[0], $want[1]] from your API, build cards keyed by index…
    $grid = $grid->withItems([$want[0] => $card0, $want[0] + 1 => $card1, /* … */]);
    $lastFetched = $want;

    // Keep the sparse card map bounded on a huge library: drop everything the
    // owner is no longer near (the map otherwise only ever grows).
    $grid = $grid->withoutItemsOutside($grid->visibleRange(overscanRows: 8));
}

Async poster arrived for one cell? ->withItem($index, $card->withPoster($ansi)).

Render

echo $grid->render(focused: true);     // cursor shown only when the grid is focused

Pass a candy-zone Manager to make cells mouse-clickable — each is wrapped as zone id cell:<index>:

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

Every card row is exactly width cells wide and the grid normalizes each cell to cardWidth × (posterHeight + 2), so columns and rows always line up whether or not a card carries a progress bar.

Title trust boundary

The plain title is treated as untrusted (it is typically DB-sourced): render() strips C0 control bytes — cursor-move, clear-screen, ESC, BEL — before drawing it, so a hostile title can't corrupt the terminal.

withStyledTitle($ansi) is the escape hatch for a pre-styled title (e.g. a candy-fuzzy match highlight). It is emitted verbatim — only ANSI-aware truncated, never stripped — because sanitising it would destroy the very SGR escapes it exists to carry. That makes you the trust boundary: pass only styling you produced yourself over already-safe text, never raw untrusted / DB-sourced bytes. When the source is untrusted, leave the styled title unset and rely on the sanitised plain title.

License

MIT © Joe Huss