sugarcraft / sugar-boxer
PHP port of treilik/bubbleboxer — box-drawing layout engine. Compose terminal content into H/V panels with box-drawing borders, padding, and dynamic dimension calculation.
Requires
- php: ^8.3
- sugarcraft/candy-buffer: dev-master
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-sprinkles: @dev
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-07-10 03:50:28 UTC
README
SugarBoxer
PHP port of treilik/bubbleboxer — box-drawing layout engine for composing terminal content into H/V panel layouts with borders and padding.
Features
- H/V composition — build arbitrary layouts by nesting
horizontal()andvertical()panels - Box-drawing borders — classic ANSI box characters (╭ ╮ ╰ ╯ │ ─ ├ ┤ ┬ ┴ ┼)
- No-border mode — render adjacent panels without separators
- Per-panel padding — inner whitespace around content
- ANSI-aware content — styled/coloured leaf text places by visible columns: escape sequences ride with the grapheme they style (zero width), wide graphemes keep their two columns, and a clipped or unbalanced span is auto-reset so colour never bleeds past the box
- Flex / grow children —
withFlex(int)/withGrow(): fixed children take their natural size, flex children fill the leftover by weight — so a layout can say "header = 1 fixed, content = FILL, status = 1 fixed" instead of an even weight split - Width/Height hints — nodes can specify min/max dimensions
- Dynamic dimension calculation — boxer computes total viewport from children
- Leaf content — any stringable content at leaf nodes
- Pure renderer — outputs ANSI box-drawing strings; works with any TUI framework
Install
composer require sugarcraft/sugar-boxer
Quick Start
use SugarCraft\Boxer\SugarBoxer; $boxer = SugarBoxer::new(); $layout = $boxer->vertical( $boxer->horizontal( $boxer->leaf("Left panel"), $boxer->leaf("Right panel"), ), $boxer->leaf("Bottom bar"), ); echo $boxer->render($layout, 60, 20);
Layout API
// Leaf node with string content $boxer->leaf('Hello, World!'); // Horizontal split (side by side) $boxer->horizontal(Node ...$children): Node // Vertical split (stacked) $boxer->vertical(Node ...$children): Node // Node with explicit dimensions $node->withMinWidth(20) ->withMaxWidth(80) ->withMinHeight(5) ->withMaxHeight(40) ->withPadding(1) // inner padding ->withBorder(true) // show box border ->withSpacing(1); // gap between children // No-border (flat) layout $boxer->noBorder(Node): Node
Styling with candy-sprinkles
sugar-boxer composes canonical styling primitives from
candy-sprinkles:
use SugarCraft\Sprinkles\{Align, Border, Style, VAlign}; // Set border character set (rounded / sharp / double / ascii / ...) // Passing null clears the style but preserves border visibility. $node->withBorderStyle(Border::rounded()); // ╭ ╮ ╰ ╯ │ $node->withBorderStyle(Border::double()); // ╔ ╗ ╚ ╝ ║ ═ $node->withBorderStyle(null); // clear explicit style // Apply foreground/background colors and attributes via Style $node->withStyle(new Style(fg: 'cyan', bg: 'black')); // Box title text rendered in the top border $node->withTitle('My Panel'); // Outer spacing (top, right, bottom, left) — sugar-boxer-specific $node->withMargin(1); // all sides $node->withMargin(1, 2); // top/bottom=1, left/right=2 $node->withMargin(1, 2, 1, 2); // explicit all four // Text alignment within the content area $node->withAlignH(Align::CENTER); $node->withAlignH(Align::RIGHT); $node->withAlignV(VAlign::MIDDLE); $node->withAlignV(VAlign::BOTTOM);
API Reference
| Method | Description |
|---|---|
$boxer->leaf(string) |
Leaf node with string content |
$boxer->horizontal(Node ...) |
Horizontal (row) layout |
$boxer->vertical(Node ...) |
Vertical (column) layout |
$boxer->noBorder(Node) |
Flat layout without separators |
$boxer->render(Node, int $width, int $height) |
Render to ANSI string |
Node::leaf(string) |
Static leaf constructor |
Node::horizontal(Node ...) |
Static horizontal constructor |
Node::vertical(Node ...) |
Static vertical constructor |
Node::noBorder(Node) |
Static no-border constructor |
->withMinWidth(int) |
Minimum width hint |
->withMaxWidth(int) |
Maximum width hint |
->withMinHeight(int) |
Minimum height hint |
->withMaxHeight(int) |
Maximum height hint |
->withPadding(int) |
Inner padding (cells) |
->withBorder(bool) |
Show/hide box border |
->withSpacing(int) |
Gap between children (cells) |
->withBorderStyle(?Border) |
Border char set from candy-sprinkles |
->withStyle(?Style) |
Style (color, attributes) from candy-sprinkles |
->withTitle(string) |
Box title text |
->withMargin(int $top, ...) |
Outer margin (top/right/bottom/left) |
->withAlignH(Align) |
Horizontal text alignment |
->withAlignV(VAlign) |
Vertical text alignment |
Border Characters
╭────┬────╮ ← top-left, top horiz, top-right, cross
│ │ │ ← vert bar
├────┼────┤ ← left-join, cross, right-join
╰────┴────╯ ← bottom-left, bottom horiz, bottom-right
Buffer diffing
The renderer maintains a ?Buffer $previousFrame across renders. On each render it
builds the current Buffer, computes current->diff(previous) (from
candy-buffer), and emits only
the delta ANSI ops via DiffEncoder::encode($ops). The current frame then replaces
previousFrame for the next render.
SSH bandwidth + flicker win: a one-character change in an 80×24 viewport produces ~8 bytes of delta ops instead of ~1 940 bytes for a full repaint. Over an SSH session this means far less per-frame data on the wire and eliminates the full-screen flicker of rewrite-based terminals. The first render after startup or a resize still emits a full Buffer (no diff possible), so behaviour is always correct.