sugarcraft / sugar-flexbox
Terminal-cell layout utilities: FlexBox (CSS-flexbox-like responsive row/column layout), Table (sortable, filterable data table with column definitions) and a scrolling Viewport.
Requires
- php: ^8.3
- sugarcraft/sugar-cellbuffer: dev-master
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-widgets: @dev
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:01:56 UTC
README
SugarFlexbox
Formerly published as
sugarcraft/sugar-stickers(renamed 2026-10). Namespace changed:SugarCraft\Stickers\→SugarCraft\Flexbox\.
sugar-flexbox — layout building blocks for terminal UIs, for PHP 8.3+. Provides four components: FlexBox (flexbox-like row/column layout), Table (sortable, filterable, styled grids with a cursor), Viewport (scrollable window with sticky regions), and Scrollbar (decorative scroll indicators).
Features
FlexBox
- CSS flexbox-like responsive grid layout for terminal UIs
- Direction: row (horizontal) or column (vertical)
- Align items: start/center/end/stretch
- Gap between items
- Ratio-based sizing — items with grow ratios fill available space
Table
- Sortable columns — sortBy()/sortByNext() toggle ascending/descending (mouse wiring belongs to the app)
- Filterable — filter rows by cell content
- Configurable columns — title, width, alignment, formatter
- Cell styling — per-column ANSI style support
- Cursor tracking — get current row/cell on selection
- Delta rendering — the stateful
TableRendereremits only the cells that changed between frames (see Buffer diffing below)
Viewport
- Scrollable viewport — composes
sugar-widgetsViewport(SSOT) - Keyboard navigation — line-up/down, page-up/down, half-page-up/down (
halfPageUp()/halfPageDown()), goto-top/bottom - Sticky regions —
withStickyHeader($rows)/withStickyFooter($rows)pin lines across scrolling; a row can never appear in both regions (the footer is clamped when the document is shorter than header + footer) - Sync partner —
syncWith($other)is a decorative association: it marks the viewport as synced and is readable viaisSynced(), but propagates nothing (scroll state never crosses to the partner) - Mouse wheel support with configurable delta
- Horizontal scrolling with step-based navigation
- Smooth scroll and scrollbar toggle; wide scrollbar runes reserve their true cell width
Scrollbar
- Scrollbar — composes
sugar-widgetsScrollbar(SSOT) - Vertical and horizontal scrollbars with configurable thumb/track characters
- Arrow toggling for scrollbar ends
Install
composer require sugarcraft/sugar-flexbox
FlexBox Quick Start
All dimensions (widths, heights, gaps, bases, ratios across FlexBox, Table and Viewport) are terminal cells — columns × rows — not bytes or pixels.
use SugarCraft\Flexbox\Flex\{FlexBox, FlexItem}; $box = FlexBox::row( FlexItem::new('Panel A')->withRatio(1), FlexItem::new('Panel B')->withRatio(2), FlexItem::new('Panel C')->withRatio(1), )->withGap(1); echo $box->render(80, 24);
How sizing works
Every dimension is in cells, and allocation follows one deterministic model (no CSS
justify-content, flex-wrap, or reverse-direction counterparts exist):
- Basis first. Each item's
withBasis(cells)reserves a fixed main-axis slice (row: width, column: height), clamped to the container; a zero ratio with a positive basis is a fixed-size item that never grows.withWidth()is a legacy alias ofwithBasis()— in column mode the reserved axis is the HEIGHT, so preferwithBasis. - Gaps next.
nitems cost(n - 1) x gapcells before any ratio math; the gap is reined in at render time if the budget cannot pay for it. - Ratios split the rest. Remaining cells are shared by grow ratios with an exact-sum guarantee: every item gets at least a 1-cell floor, and the rounding remainder is handed out in item order (80 cells across ratios 1:1:1 yields 27/27/26, always the same way). Widths never shrink when the container grows.
- Cross-axis align (
start/center/end/stretch) places an item's content within its allocated slice;render(w, h)output always fits the requested box.
To build a sidebar plus a fill panel: give the sidebar withBasis(20) and the panel
withRatio(1). There is no center-a-dialog primitive in this lib — pad with fixed-ratio
spacers (withRatio(1) on each side, content withRatio(n)) instead.
Table Quick Start
Table and Column are immutable: every builder returns a NEW instance, so the result must be assigned (or chained) — calling a method for effect discards it.
use SugarCraft\Flexbox\Table\{Table, Column}; $table = (new Table()) ->addColumn(Column::make('Name', 20)) ->addColumn(Column::make('Age', 5)->withAlign('right')) ->addColumn(Column::make('City', 15)) ->addRow(['Alice', '30', 'NYC']) ->addRow(['Bob', '25', 'LA']) ->addRow(['Carol', '35', 'Chicago']); $sorted = $table->sortBy(0); // sort by Name column → new Table $filtered = $sorted->filter('a'); // rows containing 'a' → new Table echo $filtered->render();
Viewport Quick Start
use SugarCraft\Flexbox\Viewport; $viewport = Viewport::withContent(str_repeat("Line\n", 50), 80, 24); $viewport = $viewport->withScrollbar(true); // Viewport is a plain immutable component, not a Core\Model: drive it from // your own Model's update() and render the view it returns. $viewport = $viewport->lineDown(3)->withStickyHeader(1); echo $viewport->view(); // first content line stays pinned at top
Buffer diffing (TableRenderer)
Table::render() is pure — it always returns a full frame. TableRenderer is the stateful
companion that maintains the previous frame as a SugarCraft\CellBuffer\Buffer and emits
deltas instead. On each render() it builds the current Buffer, computes current->diff(previous)
(from sugar-cellbuffer),
and returns only the changed cells' ANSI ops via DiffEncoder::encode($ops); the current frame
then replaces the previous one. Style changes are visible to the diff (colors are carried per
cell), and a dimension change resets the state so the next frame is complete again.
use SugarCraft\Flexbox\Table\{Column, Table, TableRenderer}; $renderer = new TableRenderer(); $table = Table::new() ->addColumn(Column::new('Status', 8)) ->addColumn(Column::new('Job', 20)) ->addRow(['running', 'nightly-backup']); echo $renderer->render($table); // full first frame $changed = Table::new() ->addColumn(Column::new('Status', 8)) ->addColumn(Column::new('Job', 20)) ->addRow(['done', 'nightly-backup']); echo $renderer->render($changed); // only the 'Status' cell is re-painted
Call reset() to force the next render() to emit a full frame (e.g. after the terminal
was cleared externally).
SSH bandwidth + flicker win: a one-character change emits only the ops for the affected cells instead of a full repaint — for an 80×24 screen (~1.9 KB of styled cells) that is orders of magnitude less per-frame data on the wire, and it avoids the 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.
Accessor deprecation (2026-10-24)
Per the house bare-accessor convention (AGENTS.md: "Bare accessors (no get"), Viewport's dimension accessors were renamed to their bare twins. The bare names were unclaimed in-lib (the mutators are the setWidth()/setHeight() spellings, so there is no fluent collision). The legacy names remain as byte-identical one-line BC forwarders, each marked @deprecated with REMOVED-IN: 2.0.0 in its docblock — they will be removed in sugar-flexbox v2.0.0. The forwards still answer through the wrapped canonical widgets viewport's own names; that hop belongs to the wrapped class, not this lib's era.
| Namespace | Class | Deprecated | Use instead |
|---|---|---|---|
Flexbox |
Viewport |
getWidth() |
width() |
Flexbox |
Viewport |
getHeight() |
height() |
tests/AccessorDeprecationTest.php pins the era from four directions: live-object BC identity per pair, forwarder shape (single return line + markers), a discovery-first src/ census, and this table — adding another deprecation without all rows goes red.
License
MIT
Credits & inspiration
Design antecedent: 76creates/stickers; SugarCraft is developed as a native PHP project.