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.

Maintainers

Package info

github.com/sugarcraft/sugar-boxer

pkg:composer/sugarcraft/sugar-boxer

Transparency log

Statistics

Installs: 392

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-07-04 22:42 UTC

This package is auto-updated.

Last update: 2026-07-10 03:50:28 UTC


README

sugar-boxer

CI codecov Packagist Version License PHP

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() and vertical() 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 childrenwithFlex(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.

License

MIT