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).
Requires
- php: ^8.3
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-sprinkles: dev-master
- sugarcraft/candy-zone: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
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