Search by

sugarcraft / sugar-gallery

detain

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).

Package info

github.com/sugarcraft/sugar-gallery

Documentation

pkg:composer/sugarcraft/sugar-gallery

Statistics

Installs: 5 128

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-10-23 20:44 UTC

This package is auto-updated.

Last update: 2026-10-24 06:36:37 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. sugar-image), so this lib pulls in no image decoder.

Install

composer require sugarcraft/sugar-gallery

The poster pipeline example and its integration tests render real images, so they pull sugar-image in as a development dependency only — the library itself never depends on it.

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();            // ← → step one cell (crosses row edges)
$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)

The lib owns no keys — these methods are yours to map however you like:

return match ($key) {
    '↓' => $grid->down(),
    'J' => $grid->down(),      // vim-style, if that's your scheme
    default => $grid,
};

It also owns no colour: every frame ships unstyled text, and any SGR you see in output rode in on your card bytes (withPoster() / withStyledTitle()). Theming is entirely yours. (One heads-up for narrow layouts: the chrome glyphs ● ○ ▸ and the skeleton ░ are East-Asian-Ambiguous width — most modern terminals render them single-cell, but a user's ambiguous-width setting moves them, no font install required.)

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)).

Per-cell poster fill (the async-fill policy)

A range fetch gives you cards; the artwork arrives later, one cell at a time. visibleCards() and indicesNeedingPoster() are the grid's half of that loop — which cells are on screen, and which of them are still empty:

foreach ($grid->indicesNeedingPoster(overscanRows: 1) as $index) {
    // queue exactly the cells that are visible, loaded, and still skeleton-shaped
}

// A card whose art you cannot source (no URL, a rejected host, a lazy detail
// fetch) would otherwise be re-queued on every scroll. Your predicate replaces
// the default "has a posterUrl" test wholesale:
$pending = $grid->indicesNeedingPoster(
    overscanRows: 1,
    isFillable: static fn (PosterCard $c): bool => str_starts_with((string) $c->posterUrl, 'https://'),
);

// Skip cells whose rendered bytes are already in a disk cache, so you never
// queue a load that would resolve synchronously anyway:
$pending = $grid->indicesNeedingPoster(overscanRows: 1, isCached: $probeCache);

Both are renderer-agnostic on purpose: the grid knows geometry and sparseness, nothing about image protocols, URL schemes, host allow-lists, SSRF policy, or how many fetches may run at once. That is your application's transport domain, and it stays in your loader — see examples/poster-grid-mosaic.php for the whole pipeline against a real DiskCache.

visibleCards($overscanRows) returns the same window as index => PosterCard (ascending, skeletons absent) — the iteration you would otherwise hand-roll with item($i) in a loop, e.g. to release overlay-image handles for the cells that just scrolled away.

Render

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

An empty grid (isEmpty()) renders '' — zero rows, not a blank frame — so the owner can tell "nothing here yet" from "framed empty cells" and paint its own empty-state in its own chrome.

Pass a sugar-mouse 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);       // → Zone|null — read $zone->id ("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);

The heading rides the same title guard as a card — AnsiGuard::stripControls() plus a rail-width clamp — and the empty state is localised (Lang::t('rail.empty'), see Internationalisation). moveCursor() takes a delta; to restore an arbitrary (scroll, cursor) pair (e.g. returning to a remembered position) construct via new Rail(...) with the fourth argument.

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.

Feeding it from sugar-image

The card holds already-rendered bytes, so the widget pulls in no decoder — but sugar-image is the renderer this lib is built against, and the glue is three lines:

use SugarCraft\Image\{ImageSource, Mosaic, Scale};

$image  = ImageSource::fromFile($path);                    // or fromGd()/fromUrl()
$ansi   = Mosaic::halfBlock()->withScale(Scale::Fill)
    ->render($image, $cardWidth, $posterHeight);          // cells, not pixels
$grid   = $grid->withItem($index, $card->withPoster($ansi));

Mosaic::render() takes the same cell box the card reserves (cardWidth × posterHeight), so the poster drops into the grid with no resizing arithmetic on your side; anything too big or too small is boxed to the cell anyway.

Pixel-graphics protocols (sixel, kitty, iterm2) are not cell text — a Mosaic reports isInline() === false for them. Those bytes go to sugar-image's ImageLayer, and the card carries only the marker the runtime paints over:

$placed = $layer->placeTracked($bytes, $cardWidth, $posterHeight);   // sixel / kitty / iterm2

if ($placed->imageId !== null) {                 // null once the 6398-marker window is full
    $grid = $grid->withItem($index, $card->withImage($bytes, $placed->imageId));
}

imageId is nullable for exactly that reason: the overlay addresses images with private-use-area cells, and there are only 6398 of them (ImageOverlay::MAX_IMAGES), so an exhausted layer leaves the cell as the skeleton instead of painting the wrong art.

Cache the rendered bytes, not the source image — a render is expensive, a decode less so, and the cache key must change when the terminal's protocol does: DiskCache::key($url, $width, $height, $mosaic->protocol()).

Run the whole thing yourself (GD only, no network):

composer install          # the mosaic example needs the dev-only sugar-image dep…
php examples/poster-grid-mosaic.php   # …plus ext-gd, both pulled by the lib's own install

examples/poster-grid.php by contrast is pure PHP — no image stack, no extensions.

Title trust boundary

The plain title is treated as untrusted (it is typically DB-sourced): PosterCard::render() runs it through AnsiGuard::stripControls() before drawing it — and since docs-review-1023 so does the Rail heading — removing every control byte and every escape sequence: C0 (including BEL, TAB, CR/LF, and the ESC that introduces a cursor-move or clear-screen), DEL, the 8-bit C1 controls in both wire forms, and any SGR styling a plain title has no business carrying. Both rail header lines (title and empty state) are additionally clamped to the rail width with an ellipsis, so no title — and no locale — can overflow it. Opaque bytes that are simply not valid UTF-8 stay put — they are mojibake, not controls, and one of them must never be able to disable the strip — with the single exception of a byte in the 8-bit C1 band that no well-formed sequence claimed: a terminal reads that one as a control, so it goes.

withStyledTitle($ansi) is the escape hatch for a pre-styled title (e.g. a sugar-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.

Three opt-ins turn that documented contract into an enforced one. All admit SGR styling only — ESC [ followed by nothing but digits, ; and : until a final m; a private (ESC [ ?…m) or intermediate-byte SGR (a … [ with a space before the final m) is not accepted — every other escape form (cursor movement, erase, OSC / DCS / APC payloads, bare C0 controls, an 8-bit C1 control written either as the raw byte or as the UTF-8 encoding of U+0080–U+009F, a truncated sequence) counts as unsafe, per AnsiGuard:

// Fail fast: throws InvalidArgumentException (offset + hex of the offender)
$card->withStyledTitle($highlight, assertSafe: true);
PosterCard::assertSafeAnsi($highlight);           // same guard, standalone

// Coerce instead: keep the colour, drop anything else
$card = $card->withSafeStyledTitle($maybeHostile);
$card = $card->withSafePoster($bytesFromYourCache);   // same sweep for poster bytes

AnsiGuard::isSafe() / ::sanitize() are public if you want to check or clean before you build the highlight. Nothing about the default path changed — withStyledTitle($ansi) is still verbatim, so existing callers are untouched.

Poster bytes carry the same contract as styled titles. withPoster($ansi) is emitted verbatim by design (cell-text art is SGR + block glyphs, and a strip would eat the art); whoever produced those bytes owns their safety — generate them yourself (e.g. a Mosaic render of an image you chose). If your poster can come from somewhere you do not control, withSafePoster($ansi) applies the SGR whitelist sweep row-by-row: colours and block glyphs stay byte-identical, every cursor-move / OSC / DCS / control sequence goes. withImage($bytes, $id) overlay bytes never enter the text frame at all — the card carries only the marker id — but they travel their own protocol's trust boundary on the way to the screen.

Internationalisation

Lang::t() resolves through src/Lang.php (namespace gallery) over the 16-locale family in lang/ — ar · cs · de · en · es · fr · it · ja · ko · nl · pl · pt · pt-br · ru · tr · zh-cn — with en.php as the source of truth. Exactly one key lives there today: rail.empty, the Rail carousel's empty-state string; everything else the widgets draw is geometry, not prose. tests/LocaleParityTest.php pins the file family, the key set, and the ordering against en.php, so a new key must land in all 16 files (or nowhere) to stay green. Locale selection follows the standard LC_ALL → LC_MESSAGES → LANG chain of sugar-core's T::detect().

API surface

Beyond the workflow above, the public surface is small and mostly self-evident:

PosterGrid — navigation left() / right() (one cell, crossing row edges, clamped at both ends), up() / down() (one row; down() steps onto the last item on a partial bottom row), pageUp() / pageDown() (one viewport: columns() × visibleRows()), home() / end(), moveTo($index) (clamped). State: total(), loadedCount() (sparse map size), cursorIndex(), cursorRow(), scrollRow(), item($i), cursorCard(), isEmpty(). Geometry: columns() = max(1, intdiv(cols + hSpacing, cardWidth + hSpacing)), visibleRows(), totalRows(), cellHeight() (= posterHeight + 2). Result set: reset($total), withTotal($total) (keeps cached cards, clamps the cursor), withItem() / withItems(), withoutItemsOutside([$a, $b]), withViewport($cols, $rows). Window: visibleRange($overscan), visibleCards($overscan), needsFetch($lastFetched, $overscan), indicesNeedingPoster(...) — all documented above.

Rail — withCards() / withCard() / withTitle() / withCursor(), moveCursor($delta, $perRow) (scrolls minimally to keep the cursor in view), focusedCard(), isEmpty(), and the static layout helper Rail::perRow($railWidth, $cardWidth, $spacing) (how many cards fit a row). Rail::render() takes focused: as a required argument — the asymmetry against PosterGrid::render(bool $focused = true) is deliberate (a rail in a media browser is usually not the focused pane and defaulting it lit up the wrong row in early apps).

PosterCard — plus two cache levers: PosterCard::clearFitCache() and PosterGrid::clearBoxCache() drop process-wide memos (self-bounding at BOX_CACHE_CAP = 2048 with oldest-half eviction; clearing them returns an entry count and never changes a single rendered byte).

Geometry is clamped at construction: cardWidth ≥ 4, posterHeight ≥ 1, both spacings ≥ 0 (and again inside render() — a card asked to render narrower than 4 cells re-clamps silently rather than emitting a negative pad).

License

MIT © Joe Huss