sugarcraft / sugar-table
PHP port of Evertras/bubble-table — customizable interactive table component for terminal UIs. Supports column definitions with keys/titles/widths, rows as key-value maps, styled cells, selection, pagination, sorting, filtering, frozen columns, horizontal scrolling, zebra striping, and ANSI border s
Requires
- php: ^8.3
- sugarcraft/candy-buffer: dev-master
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-sprinkles: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/candy-testing: dev-master
This package is auto-updated.
Last update: 2026-07-10 03:51:29 UTC
README
SugarTable
PHP port of Evertras/bubble-table — customizable interactive table component for terminal UIs.
Features
- Column definitions: unique key, title, width (fixed or flexible), optional style
- Row data: key-value map (
RowData), arbitrary values rendered viafmt.Sprintf("%v") - Styled cells:
StyledCellwraps value + ANSI style, overrides row/column/base styles - Row styles: zebra striping, bold rows, per-row ANSI styling
- Selection: single-row cursor, up/down navigation
- Pagination: page size, page navigation, auto footer
- Sorting: asc/desc, multi-column sort, numeric + string sort
- Filtering: filter by column text
- Frozen columns: pin columns from the left
- Horizontal scroll: max width with overflow, frozen columns stay visible
- Missing data indicator: configurable placeholder for absent cells
- Border styling:
withBorder(Border $border)— consume anySugarCraft\Sprinkles\Borderfamily (normal/rounded/thick/double/block/ascii/hidden/markdownBorder) +withBorderStyle(string $ansiStyle)for ANSI color/styling on default border - Borderless mode:
withBorderless()— render NO outer box (no top/bottom border rows, no left/right border columns; a single space separates columns) so the table composes inside another bordered shell (e.g. a sugar-boxer content box) without a double border. A selected row's reverse-video highlight stays continuous across the column gaps - Width-exact rendering:
withWidth(int $cols)— pin the total render width;ColumnWidth::Flexcolumns fill exactly the room left after Fixed/Percent columns so every line is exactly$colscells (deterministic for composition). DefaultcellPaddingis 0 (flush) - Viewport virtualization: render only visible rows via
withViewportHeight()+withScrollY() - Column width modes:
ColumnWidthenum — Fixed, Percent, Dynamic, Content, Flex - Cell text wrapping:
WrapModeenum — None, WordWrap, Character - Multi-line row rendering:
withMultilineMode(bool $multiline)— when enabled, rows expand to the maximum height of any cell; when disabled (default), cells are clamped to one line (backward compatible)
Install
composer require sugarcraft/sugar-table
Quick Start
use SugarCraft\Table\{Column, Row, RowData, Table}; $t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), ])->withRows([ Row::new(RowData::from(['id' => '1', 'name' => 'Alice', 'city' => 'NYC'])), Row::new(RowData::from(['id' => '2', 'name' => 'Bob', 'city' => 'LA'])), Row::new(RowData::from(['id' => '3', 'name' => 'Carol', 'city' => 'CHI'])), ]); echo $t->View();
Columns
Column::new($key, $title, $width) // key, display title, fixed width ->withFlexibleWidth($flex) // flexible width share ->withMaxWidth($max) // horizontal scroll cap ->withStyle('1;34') // ANSI SGR style ->withFilterable() // enable built-in filter ->withAlignLeft() // left-align (default is right) ->withColumnWidth($mode, $value) // ColumnWidth::Fixed|Percent|Dynamic|Content ->withWrapMode(WrapMode::None) // WrapMode::None|WordWrap|Character
ColumnWidth Enum
ColumnWidth specifies how a column's width is computed at render time.
The table computes actual widths in computeColumnWidths($tableWidth) and uses
them consistently throughout rendering (header, data cells, separators).
| Case | Description |
|---|---|
Fixed |
Fixed character count (uses Column.width) |
Percent |
Percentage of total table width (uses Column.percentValue, 0–100) |
Dynamic |
Min-width from content, flex share of remaining space |
Content |
Exactly fit content (min 1 char) |
use SugarCraft\Table\ColumnWidth; // Fixed 10-character column $col = Column::new('id', 'ID', 10) ->withColumnWidth(ColumnWidth::Fixed, 0); // 25% of table width $col = Column::new('name', 'Name', 20) ->withColumnWidth(ColumnWidth::Percent, 25.0); // Dynamic (content width or flex share, whichever is larger) $col = Column::new('city', 'City', 15) ->withColumnWidth(ColumnWidth::Dynamic, 0); // Content-based (exact fit to widest cell) $col = Column::new('email', 'Email', 30) ->withColumnWidth(ColumnWidth::Content, 0);
Dynamic + Content example — auto-size two columns while a third takes 25%:
$t = Table::fromColumns([ Column::new('id', 'ID', 5) ->withColumnWidth(ColumnWidth::Fixed, 0), Column::new('name', 'Name', 20) ->withColumnWidth(ColumnWidth::Dynamic, 0), // auto-size to content Column::new('note', 'Note', 10) ->withColumnWidth(ColumnWidth::Content, 0), // exact content fit Column::new('pct', 'Pct', 0) ->withColumnWidth(ColumnWidth::Percent, 25.0), // always 25% of table ])->withRows([...]); echo $t->View(); // columns 1+2 sized by content; column 3 fills remaining 75%
WrapMode Enum
WrapMode controls how cell text is wrapped:
| Case | Description |
|---|---|
None |
Truncate at column width |
WordWrap |
Break at word boundaries |
Character |
Break at any character, no padding on last line |
use SugarCraft\Table\{Column, WrapMode}; $col = Column::new('desc', 'Description', 20) ->withWrapMode(WrapMode::WordWrap); // or Character or None
Rows
Row::new($rowData) ->withStyle('1') // bold entire row ->withZebra() // alternating style // Styled cell (overrides row+column style) StyledCell::new('value', '31;1') // red bold cell
Navigation & Sorting
$t = $t->SortBy('name', ascending: true); $t = $t->Filter('name', 'alice'); // filter column by text $t = $t->SelectNext(); // move cursor down $t = $t->SelectPrevious(); // move cursor up $t = $t->CurrentRow(); // get selected RowData
Keyboard Navigation
Scroll the table vertically using keyboard input via scrollYForKey() and handleKey():
use SugarCraft\Table\Table; // Key constants for navigation $t = $t->handleKey(Table::KEY_ARROW_UP); // scroll up one row $t = $t->handleKey(Table::KEY_ARROW_DOWN); // scroll down one row $t = $t->handleKey(Table::KEY_PAGE_UP); // scroll up one viewport $t = $t->handleKey(Table::KEY_PAGE_DOWN); // scroll down one viewport $t = $t->handleKey(Table::KEY_HOME); // scroll to top $t = $t->handleKey(Table::KEY_END); // scroll to bottom
Key Constants
| Constant | Description |
|---|---|
KEY_ARROW_UP |
Scroll up by 1 row |
KEY_ARROW_DOWN |
Scroll down by 1 row |
KEY_PAGE_UP |
Scroll up by one viewport height |
KEY_PAGE_DOWN |
Scroll down by one viewport height |
KEY_HOME |
Scroll to first row |
KEY_END |
Scroll to last row |
scrollYForKey() — Raw Scroll Calculation
Returns the new scrollY value for a key without modifying the table:
$newScrollY = $t->scrollYForKey(Table::KEY_ARROW_UP); $t = $t->withScrollY($newScrollY);
This is useful when you need the raw integer value for your own integration logic.
handleKey() — Convenience Wrapper
Returns a new Table with scrollY already adjusted:
$t = $t->handleKey($keyFromInputHandler);
This combines scrollYForKey() + withScrollY() in one call.
Integration Example
use SugarCraft\Table\Table; // Create table with viewport virtualization enabled $t = Table::fromColumns([...]) ->withRows([...]) ->withViewportHeight(15); // Simulate keyboard input $key = 'arrowDown'; // from your input library (e.g., candy-pty) $t = $t->handleKey($key); // Or use the constants $t = $t->handleKey(Table::KEY_PAGE_DOWN);
How It Works
- Key mapping:
scrollYForKey()uses amatchexpression to map key names to scroll deltas - Bounds clamping: Scroll values are clamped to
0at the top andmaxScrollY()at the bottom - maxScrollY(): Returns
max(0, totalFilteredRows - viewportHeight)when viewport is active;0otherwise - No-op for unknown keys: Unrecognized keys return the current
scrollYunchanged - Requires viewport: Keyboard scrolling only works when
withViewportHeight()is set
// Combined: keyboard scroll + cursor selection $t = $t->withScrollY($t->scrollYForKey($key)) // update scroll ->SelectNext(); // move selection
Global Search
Search across all columns simultaneously with search():
$t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), ])->withRows([ Row::new(RowData::from(['id' => '1', 'name' => 'Alice', 'city' => 'NYC'])), Row::new(RowData::from(['id' => '2', 'name' => 'Bob', 'city' => 'LA'])), Row::new(RowData::from(['id' => '3', 'name' => 'Carol', 'city' => 'CHI'])), ]); $t = $t->search('alice'); // finds row with "Alice" (case-insensitive) $t = $t->search('ny'); // finds row with "NYC" $t = $t->search(''); // clears search (shows all rows) $t = $t->ClearSearch(); // alias for search('')
How It Works
- Case-insensitive:
search('ALICE')matches"alice","Alice","ALICE" - OR logic: A row matches if any column contains the search text
- Combines with filters: Global search is ANDed with column filters — a row must match both
- Resets selection:
search()automatically resetsselectedIndexto 0
// Combined: Filter by name column AND search all columns for "ny" $t = $t->Filter('name', 'alice') // name must contain "alice" ->search('ny'); // some column must contain "ny" // Row 1 (Alice, NYC): passes Filter, passes search ✅ // Row 2 (Bob, LA): fails Filter ❌ // Row 3 (Carol, CHI): fails both ❌
Interaction with Filter()
| Method | Scope | Logic |
|---|---|---|
Filter() |
Single column | AND (row must match ALL column filters) |
search() |
All columns | OR (row matches if ANY column contains text) |
Both can be active simultaneously:
$t = $t->Filter('city', 'ny') // city must contain "ny" ->search('li'); // some column must contain "li" // Row 1 (Alice, NYC): city matches "ny", search matches "li" in "Alice" ✅ // Row 2 (Bob, LA): city matches "la"? No ❌ // Row 3 (Carol, CHI): city fails, search matches "li" in "Carol" — fails Filter ❌
Pagination
$t = $t->withPageSize(25) // 25 rows per page ->withPage(2); // show page 2 echo $t->PageFooter(); // 'Page 2 of 4' (i18n-aware)
Footer Types
Control what the footer displays using the FooterType enum:
use SugarCraft\Table\{Table, FooterType}; $t = Table::fromColumns([...]) ->withRows([...]) ->withPageSize(25) ->withFooterType(FooterType::Page); // Default: "Page 2 of 4"
| Case | Footer Display | Method |
|---|---|---|
Page |
Page N of M |
PageFooter() |
Rows |
Showing X to Y of Z rows |
RowsFooter() |
Both |
`Page N of M | Showing X to Y of Z rows` |
// Show only row count footer $t = $t->withFooterType(FooterType::Rows); echo $t->View(); // Footer: "Showing 1 to 25 of 100 rows" // Show both page and row count $t = $t->withFooterType(FooterType::Both); echo $t->View(); // Footer: "Page 2 of 4 | Showing 26 to 50 of 100 rows"
The row count footer uses the showing_rows i18n key and updates automatically based on the current page and any active filters/searches.
Viewport Virtualization
Render only a visible slice of rows for large datasets:
$t = Table::fromColumns([...]) ->withRows($bigDataset) ->withViewportHeight(15) // show 15 rows at a time ->withScrollY(30); // start at row 30 echo $t->View(); // renders rows 30-44 $currentScroll = $t->scrollY(); // get current scroll offset
The table automatically slices the visible row range from the filtered+sorted view.
scrollY() returns the current vertical scroll offset.
Frozen Columns
Pin columns from the left so they remain visible when scrolling horizontally:
$t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), Column::new('note', 'Note', 40), ])->withRows([...]) ->withFrozenCols([0, 1]); // freeze ID and Name columns echo $t->View(); // ID and Name always visible
How It Works
- Frozen columns (specified by index) are always rendered, regardless of scroll position
- Non-frozen columns scroll horizontally: they become visible starting at index
count(frozenCols) + scrollX - Use
withScrollX($offset)to scroll the non-frozen columns
Frozen Columns with Horizontal Scroll
Combine frozen columns with scrollX for a spreadsheet-like experience:
$t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), Column::new('note', 'Note', 40), Column::new('tags', 'Tags', 20), ])->withRows([...]) ->withFrozenCols([0, 1]) // freeze ID and Name ->withScrollX(2); // skip 2 non-frozen columns (City, Note) echo $t->View(); // Visible columns: ID, Name, Tags // City and Note columns are hidden (scrolled out of view)
Visibility Logic
A column is visible when:
- Its index is in the
frozenColsarray (always visible), OR - Its index >=
count(frozenCols) + scrollX(in the scrollable region)
// Given: frozenCols = [0, 2], scrollX = 1 // Non-frozen columns start at index: 2 + 1 = 3 // Column 0 (frozen): visible // Column 1 (index 1): NOT visible (1 < 3) // Column 2 (frozen): visible // Column 3 (index 3): visible (3 >= 3) // Column 4 (index 4): visible (4 >= 3)
Horizontal Scroll
Scroll horizontally through columns that exceed the table width:
$t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), Column::new('note', 'Note', 40), ])->withRows([...]) ->withScrollX(2); // skip first 2 non-frozen columns echo $t->View(); // Columns 0 and 1 are hidden from view
How It Works
- scrollX skips
$offsetnon-frozen columns from the left of the scrollable region - Frozen columns (if any) are always visible regardless of scrollX
- Negative values are clamped to 0 automatically
- Excessive scroll values are tolerated — extra columns simply don't render
Interaction with Frozen Columns
When combining withScrollX() with withFrozenCols():
$t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), Column::new('note', 'Note', 40), Column::new('tags', 'Tags', 20), ])->withRows([...]) ->withFrozenCols([0]) // freeze ID column ->withScrollX(2); // skip 2 non-frozen columns echo $t->View(); // Column 0 (ID, frozen): visible // Column 1 (Name): skipped (index 1 < 1 + 2 = 3) // Column 2 (City): skipped (index 2 < 3) // Column 3 (Note): visible (index 3 >= 3) // Column 4 (Tags): visible (index 4 >= 3)
The visibility formula: a column is visible when its index is in frozenCols OR index >= count(frozenCols) + scrollX.
Column Visibility Toggle
Hide columns by index without removing them from the table:
use SugarCraft\Table\Table; $t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), Column::new('note', 'Note', 40), // this column can be hidden ])->withRows([...]) ->withHiddenCols([3]); // hide the Note column (index 3) echo $t->View(); // Only ID, Name, City columns are rendered; Note column is hidden
How It Works
- Hidden columns are excluded from rendering but still exist in the table
- Data, filters, and sorting still work on hidden columns — you can filter by a hidden column's data
- Useful for optional columns that can be toggled visible/invisible via UI
- Column indices refer to the original column order — not affected by scroll position
// Hide multiple columns $t = $t->withHiddenCols([2, 3]); // hide columns at indices 2 and 3 // Show all columns (empty array) $t = $t->withHiddenCols([]); // no columns hidden // Combine with frozen columns - hide a frozen column $t = $t->withFrozenCols([0]) ->withHiddenCols([0]); // freeze and hide are independent
Interaction with Frozen Columns and Scroll
Hidden columns are never rendered, regardless of frozen status or scroll position:
// Given: frozenCols = [0], hiddenCols = [2], scrollX = 0 // Column 0 (frozen): visible // Column 1 (index 1): visible (>= 1 + 0 = 1) // Column 2 (index 2): NEVER visible (in hiddenCols) // Column 3 (index 3): visible (>= 1 + 0 = 1)
Column Width Computation
Compute actual column widths from ColumnWidth enum values:
$widths = $t->computeColumnWidths(80); // Returns: [5, 20, 15, 40] (colIndex => character width) foreach ($t->Columns() as $i => $col) { printf("%s => %d chars\n", $col->title, $widths[$i]); }
Uses multi-pass computation:
- Pass 1: Collect Fixed/Percent widths, count Dynamic/Content columns
- Pass 2: Distribute remaining space among Dynamic/Content columns
- Dynamic:
max(contentWidth, flexShare) - Content: exact content width (min 1)
All translatable strings live in
lang/en.phpunder the'table'namespace.
Available keys (lang/en.php):
| Key | Default string | Parameters |
|---|---|---|
page_of |
Page {page} of {total} |
{page}, {total} |
no_data |
No data |
— |
showing_rows |
Showing {from} to {to} of {total} rows |
{from}, {to}, {total} |
sort |
Sort |
— |
filter |
Filter |
— |
To add a locale, copy lang/en.php to lang/<code>.php and translate the
values. The lookup chain follows SugarCraft\Core\I18n\T:
exact locale → base language → en → raw key.
Adding new translatable strings:
// In any source file: use SugarCraft\Table\Lang; $label = Lang::t('sort'); // 'Sort' $pager = Lang::t('page_of', ['page' => 2, 'total' => 4]); // 'Page 2 of 4'
Border Styling
Customize the table border using the SugarCraft\Sprinkles\Border family:
use SugarCraft\Table\Table; use SugarCraft\Sprinkles\Border; $t = Table::fromColumns([...]) ->withRows([...]) ->withBorder(Border::rounded()); // ─ │ ╭ ╮ ╰ ╯ // ->withBorder(Border::thick()) // ━ ┃ ┏ ┓ ┗ ┛ // ->withBorder(Border::double()) // ═ ║ ╔ ╗ ╚ ╝ // ->withBorder(Border::ascii()) // - | + + + + // ->withBorder(Border::hidden()) // all spaces // ->withBorder(Border::markdownBorder()) // | - | ... // Or style the default border with ANSI colors: $t = $t->withBorderStyle('1;32'); // bold green
Available border factories: Border::normal(), rounded(), thick(), double(), block(), ascii(), hidden(), markdownBorder().
Cell Padding Control
Add inner spacing inside each cell for better visual breathing room:
use SugarCraft\Table\Table; $t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('city', 'City', 15), ])->withRows([ Row::new(RowData::from(['id' => '1', 'name' => 'Alice', 'city' => 'NYC'])), Row::new(RowData::from(['id' => '2', 'name' => 'Bob', 'city' => 'LA'])), Row::new(RowData::from(['id' => '3', 'name' => 'Carol', 'city' => 'CHI'])), ])->withCellPadding(1); // 1 space on each side // ->withCellPadding(2); // 2 spaces for more breathing room // ->withCellPadding(0); // no padding (flush with borders) echo $t->View(); // With padding 1: "│ 1 │ Alice │ NYC │" // Without padding: "│ 1 │ Alice │ NYC │"
How It Works
- Padding adds whitespace on the left and right sides of each cell's content
- Does not affect column width calculations — the column width remains the same; padding is subtracted from the effective content width
- Applied to header and data cells — both headers and row data benefit from consistent inner padding
- Combined with other features — works with frozen columns, horizontal scroll, multiline mode, and row expansion
// Combine padding with other features $t = $t->withCellPadding(2) ->withFrozenCols([0]) ->withMultilineMode(true);
Multi-line Rows
Enable multi-line row rendering to display tall cell content that wraps within its column width:
use SugarCraft\Table\{Table, Column, Row, RowData, WrapMode}; $t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20)->withWrapMode(WrapMode::WordWrap), Column::new('city', 'City', 15)->withWrapMode(WrapMode::Character), ]) ->withRows([ Row::new(RowData::from([ 'id' => '1', 'name' => 'Alice Johnson', 'city' => 'New York City', ])), Row::new(RowData::from([ 'id' => '2', 'name' => 'Bob Smith with a very long name', 'city' => 'Los\nAngeles', // embedded newline ])), ]) ->withMultilineMode(true); // rows expand to max cell height // ->withMultilineMode(false); // default, clamps to single line echo $t->View();
How It Works
When withMultilineMode(true) is enabled:
- Row height equals the maximum number of lines across all visible cells after text wrapping
- Short cells are vertically padded with empty space to match row height
- Borders span the full row height on each line
- Cell wrapping respects the column's
WrapMode:WordWrapbreaks at word boundaries,Characterbreaks at any character,Nonetruncates
When disabled (the default), cells are clamped to one line for backward compatibility.
Interaction with WrapMode
Multiline mode requires WrapMode::WordWrap or WrapMode::Character on columns to produce multiple lines. Without wrapping enabled, cells remain single-line even in multiline mode.
// Word wrap example — breaks at word boundaries within 8 characters Column::new('bio', 'Bio', 8)->withWrapMode(WrapMode::WordWrap); // "one two three four" → "one two", "three", "four" (3 lines) // Character wrap example — breaks at any character within 5 characters Column::new('code', 'Code', 5)->withWrapMode(WrapMode::Character); // "ABCDEFGHIJ" → "ABCDE", "FGHIJ" (2 lines)
Row Expansion
Expand rows to display full content without column width truncation:
use SugarCraft\Table\{Table, Column, Row, RowData}; $t = Table::fromColumns([ Column::new('id', 'ID', 5), Column::new('name', 'Name', 20), Column::new('desc', 'Desc', 15), // truncated at 15 chars normally ]) ->withRows([ Row::new(RowData::from([ 'id' => '1', 'name' => 'Alice', 'desc' => 'This is a very long description that would normally be truncated', ])), Row::new(RowData::from([ 'id' => '2', 'name' => 'Bob', 'desc' => 'Short', ])), ]) ->withExpandedRows([0]); // expand row 0 (Alice) echo $t->View(); // Row 0 (Alice): full description visible — not truncated to 15 chars // Row 1 (Bob): normal truncation applies
Toggle Expansion
Use toggleExpanded() to interactively expand/collapse rows:
$t = $t->toggleExpanded(0); // expand row 0 (Alice) $t = $t->toggleExpanded(0); // collapse row 0 (back to truncated) $t = $t->toggleExpanded(1); // expand row 1 (Bob)
Check Expansion State
Use isExpanded() to query whether a row is currently expanded:
$t = $t->withExpandedRows([0]); $t->isExpanded(0); // true — row 0 is expanded $t->isExpanded(1); // false — row 1 is not expanded
How It Works
- Row identity: Expanded rows are tracked by object identity (
Rowinstance), not by index — this is stable across page navigation - Pagination: All expansion methods (
withExpandedRows,toggleExpanded,isExpanded) use page-relative indices viapagedRows() - Multiline mode: In
multilineMode=true, expanded rows also bypass column width constraints so all wrapped content is visible - No content filtering: Expansion does not filter or transform row data; it only controls rendering behavior (truncation vs full display)
- Fail fast: Invalid indices throw
OutOfBoundsException
// Combined with pagination — page 1 $t = $t->withPageSize(10)->withPage(1)->withExpandedRows([0]); // Row index 0 refers to the first row on page 1, not the first row overall $t->isExpanded(0); // true — row 0 on page 1 is expanded // Row index 5 on page 2 might be a different Row object $t = $t->withPage(2); $t->isExpanded(5); // depends on whether that row was expanded on page 2
Shared foundations
sugar-table adopts candy-buffer for all buffer-based rendering. The table's internal
Buffer instance is constructed once per View() call and passed through all
layout methods.
styleFunc signature
Table::withStyleFunc() accepts a callable with the signature:
function(int $row, int $col, string $value): Style|string
- Returns
Style— new preferred style (PHP 8.3+ typed return, immutable) - Returns
string— legacy ANSI SGR string, automatically wrapped viaStyle::fromAnsiString()for backward compatibility
Implementations should return Style when possible; the wrapper path is identical
to the old behavior but adds one allocation.
Snapshot tests
Render output is covered by golden-file snapshot tests. Fixture files live
in tests/fixtures/ with a .golden extension and are compared against
actual ANSI byte output via SugarCraft\Testing\Snapshot\Assertions::assertGoldenAnsi().
To re-record fixtures after intentional output changes:
UPDATE_GOLDENS=1 vendor/bin/phpunit