Search by

sugarcraft / sugar-bits

detain

PHP port of charmbracelet/bubbles — 17 pre-built TUI components (text input, dynamic-height textarea, list, table with per-cell styleFunc, tree, viewport, file picker, progress, spinner, etc.) for SugarCraft.

Package info

github.com/sugarcraft/sugar-bits

Documentation

pkg:composer/sugarcraft/sugar-bits

Statistics

Installs: 1 606

Dependents: 7

Suggesters: 0

Stars: 1

Open Issues: 0

dev-master 2026-10-08 20:56 UTC

This package is auto-updated.

Last update: 2026-10-14 01:56:40 UTC


README

sugar-bits

SugarBits

CI codecov Packagist Version License PHP

demo

sugar-bits — 17 TUI components for PHP 8.3+: 9 first-party components (Table, Tabs, Tree, Progress, Paginator, Help, Key, Timer, Stopwatch) and 8 deprecated alias re-exports from sugarcraft/candy-forms (TextInput, TextArea, Viewport, Cursor, ItemList, FilePicker, Scrollbar, Spinner), including the interactive Tree and per-cell Table::styleFunc(...).

composer require sugarcraft/sugar-bits

TextInput, TextArea, and Help expose short-form aliases for the most-used setters: placeholder / charLimit / width / height / prompt / validator / styles / separator / ellipsis. The with* long forms still work side-by-side.

Components

9 first-party components: Help, Key, Progress (includes AnimatedProgress spring-variant), Timer, Stopwatch, Paginator, Tree, Table, Tabs — plus two btop-derived render helpers, Input\TextEdit and Menu\OptionRow. 8 deprecated alias re-exports from sugarcraft/candy-forms: Cursor, TextInput, TextArea, Viewport, ItemList, FilePicker, Scrollbar, Spinner.

Component What it does Notable msgs
Cursor\Cursor Animated text cursor (deprecated alias — re-exported from SugarCraft\Forms\Cursor) BlinkMsg
Help\Help Render short / full key-help footer from a KeyMap; Help::updateWithBinding($msg, $toggle) flips show-all in response to a key —
Key\Binding One key + label + help row; Binding::new(...), Binding::withDisabled(...) factories —
Spinner\Spinner Animated loading glyph — 12 built-in styles (deprecated alias — re-exported from SugarCraft\Forms\Spinner in sugarcraft/candy-forms) Spinner\TickMsg
Progress\Progress Static progress bar (gradient fill optional, withColors(...) / withColorFunc(...) / withShowValue(...)) —
Progress\AnimatedProgress Spring-physics-animated progress bar (HoneyBounce-driven) SpringTickMsg
Timer\Timer Countdown timer; interval(), timeout(), withInterval(float) Timer\TickMsg, TimeoutMsg
Stopwatch\Stopwatch Elapsed-time counter; interval(), withInterval(float) Stopwatch\TickMsg
TextInput\TextInput Single-line input with autocomplete + validators + ValidateOn timing + restrict pattern + vim mode + placeholder styling + prefix/suffix + Styles (deprecated alias — re-exported from SugarCraft\Forms\TextInput) —
TextArea\TextArea Multi-line editor with line numbers / set-prompt-func / focused() / cursor() / line() / column(); Ctrl+O opens the buffer in $EDITOR (withEditorExtension('.md') to control the syntax-highlight suffix) (deprecated alias — re-exported from SugarCraft\Forms\TextArea) TextArea\TextAreaEditedMsg
Viewport\Viewport Scrollable text region with mouse-wheel, scrollbar, horizontal scroll, setWidth(int) / setHeight(int) (deprecated alias — re-exported from SugarCraft\Forms\Viewport) —
Paginator\Paginator Dot / arabic page indicator —
ItemList\ItemList Selectable / scrollable / filterable list with status messages (deprecated alias — re-exported from SugarCraft\Forms\ItemList) —
Tree\Tree Interactive tree — cursor, expand/collapse, viewport scroll. —
Table\Table Selectable data table with Column struct + nav + multi-column sort —
Tabs\Tabs Tabbed panel — keyboard (Tab/Shift+Tab/1-9) + mouse navigation, wrap/clamp modes, scrollable overflow —
FilePicker\FilePicker Directory browser with icons / size / sort modes (deprecated alias — re-exported from SugarCraft\Forms\FilePicker) —
Scrollbar\Scrollbar Scrollbar indicator for scrollable viewports (deprecated alias — re-exported from SugarCraft\Forms\Scrollbar) —
Input\TextEdit Inline single-line editor value object with an underline caret (no hardware cursor), grapheme-cluster caret, cell-budget windowing, digits-only mode — btop Draw::TextEdit —
Menu\OptionRow Static renderer for btop's two-line options-menu row (bold centred name + ← value → / ↵ line, live TextEdit while editing) —

Vim mode

TextInput vim mode (Insert/Normal/Visual/VisualLine keybindings) is powered by candy-forms's shared VimKeyHandler — the same handler backing sugar-prompt and sugar-readline. Adding a new binding to the VimAction enum benefits all three libs at once. The per-lib opt-in flag withVimMode(true) is preserved; consumers control whether vim mode is enabled.

Msg routing cheat-sheet

Forward these into your model's update() so the embedded component can react: BlinkMsg (Cursor / TextInput), Spinner\TickMsg (Spinner), Timer\TickMsg + Timer\TimeoutMsg, Stopwatch\TickMsg, SpringTickMsg (AnimatedProgress), StartStopMsg (Timer / Stopwatch), TextArea\TextAreaEditedMsg (TextArea's Ctrl+O round-trip). Each component's update() filters by its own id() so multiple instances of the same component coexist on one event loop.

Quickstart — TextInput with autocomplete

use SugarCraft\Bits\TextInput\TextInput;
use SugarCraft\Core\{Cmd, Model, Msg, Program};
use SugarCraft\Core\Msg\KeyMsg;
use SugarCraft\Core\KeyType;

final class Search implements Model
{
    public function __construct(public readonly TextInput $ti) {}

    public function init(): ?\Closure { return null; }

    public function update(Msg $msg): array
    {
        if ($msg instanceof KeyMsg && $msg->type === KeyType::Enter) {
            return [$this, Cmd::quit()];
        }
        if ($msg instanceof KeyMsg && $msg->type === KeyType::Tab) {
            return [new self($this->ti->acceptSuggestion()), null];
        }
        [$ti, $cmd] = $this->ti->update($msg);
        return [new self($ti), $cmd];
    }

    public function view(): string
    {
        $body = $this->ti->view();
        if (($s = $this->ti->currentSuggestion()) !== null) {
            $body .= "\n  → $s";
        }
        return $body;
    }

    public function subscriptions(): ?\SugarCraft\Core\Subscriptions
    {
        return null;
    }
}

[$ti, $cmd] = TextInput::new()
    ->withSuggestions(['apple', 'apricot', 'banana', 'cherry'])
    ->showSuggestions()
    ->withValidator(fn(string $v) => strlen($v) >= 2 ? null : 'too short')
    ->focus();

(new Program(new Search($ti)))->run();

Quickstart — animated progress bar

use SugarCraft\Bits\Progress\AnimatedProgress;

$bar = AnimatedProgress::new()
    ->withWidth(40)
    ->withDefaultGradient();

[$bar, $cmd] = $bar->setPercent(0.75);
// dispatch $cmd via the Program — ticks re-fire from inside update()
// until the bar settles within 5e-4 of the target.

Quickstart — TextInput with placeholder styling and prefix/suffix

use SugarCraft\Bits\TextInput\TextInput;
use SugarCraft\Sprinkles\Style;
use SugarCraft\Core\Util\Color;

$ti = TextInput::new()
    ->withPlaceholder('Enter command…')
    ->withPlaceholderStyle(Style::new()->faint())           // default: dim
    ->withPrefix('$ ')                                      // fixed prefix
    ->withSuffix(' <');                                     // fixed suffix

echo $ti->view();
// $ Enter command… <

TextInput — ValidateOn and restrict

TextInput supports deferred and filtered validation via two new builders:

ValidateOn timing control

use SugarCraft\Bits\TextInput\{TextInput, ValidateOn};

$ti = TextInput::new()
    ->withValidateOn(ValidateOn::Blur);   // validate when focus leaves
Case When validation fires
ValidateOn::None Never (default — use when you drive validation manually)
ValidateOn::Blur When the input loses focus
ValidateOn::Change On every keystroke
ValidateOn::Submit Only on Enter keypress

Keystroke filter (restrict)

use SugarCraft\Bits\TextInput\TextInput;

// Accept only digits
$numeric = TextInput::new()->withRestrict('[0-9]');

// Accept alphanumeric only
$alphanum = TextInput::new()->withRestrict('[a-zA-Z0-9]');

TextInput notable builders

Method What it does
withValidateOn(ValidateOn $timing) Set validation timing (None / Blur / Change / Submit)
withRestrict(string $pattern) Set a PCRE regex — only matching characters are accepted (no delimiters)

Table — multi-column sort

use SugarCraft\Bits\Table\{Table, SortDirection, SortState};

// Primary sort by Name ascending
$t = $table->withSort('Name');

// Tiebreaker: Age descending
$t = $table->thenSortBy('Age', SortDirection::Desc);

// Reset to insertion order
$t = $table->clearSort();

// Inspect current sort criteria
$state = $t->sortState(); // SortState
foreach ($state->criteria as [$colIndex, $dir]) {
    // $colIndex is an int, $dir is SortDirection::Asc or SortDirection::Desc
}

SortDirection enum

Case Value Description
SortDirection::Asc 'asc' Sort in ascending order
SortDirection::Desc 'desc' Sort in descending order

SortDirection::toggle() returns the opposite direction.

SortState DTO

Immutable list of sort criteria — each entry is a (column index, direction) pair. Applied in order: first entry is primary sort, second is tiebreaker, etc.

Method Returns Description
SortState::empty() SortState Factory for no criteria
SortState->withCriterion(int $col, SortDirection $dir) SortState Append a criterion
SortState->isEmpty() bool True when no criteria are set
SortState->criteria list<array{0:int,1:SortDirection}> Raw criteria list

Table sort builders

Method Description
withSort(string $column, SortDirection $dir = Asc) Set primary sort — clears any prior sort chain
thenSortBy(string $column, SortDirection $dir = Asc) Add a secondary (or further) tiebreaker criterion
clearSort() Remove all sort criteria, restoring insertion order
sortState(): SortState Return the current sort criteria (readonly accessor)

Sorting throws \InvalidArgumentException with message table.sort_unknown_column when the column name is not found. The exception message is localizable.

Table — filtering

use SugarCraft\Bits\Table\Table;

// Enable the filter feature (opt-in)
$t = $table->withFilterable(true);

// Set a query string — default: case-insensitive substring match across all visible columns
$t = $table->withFilter('foo');

// Custom filter: receives a row (list<string>), returns true to keep
$t = $table->withFilterPredicate(fn(array $row): bool =>
    str_contains(strtolower(implode("\t", $row)), 'foo')
);

// Inspect current filter state
$isFilterable = $t->filterable();   // bool
$query        = $t->filter();        // string
$predicate    = $t->filterPredicate(); // ?Closure(list<string>): bool

When withFilterPredicate() is set, it overrides the default substring-match behaviour. Pass null to restore the default.

Table filter builders

Method Description
withFilterable(bool $filterable) Enable or disable the filter feature
withFilter(string $query) Set the filter query string (non-empty enables filtering)
withFilterPredicate(?Closure(list<string>): bool $predicate) Custom filter callable — null restores the default
filterable(): bool Return whether filtering is enabled
filter(): string Return the current filter query string
filterPredicate(): ?Closure Return the current custom predicate

The default filter applies case-insensitive substring matching across all visible columns.

Table — pagination

use SugarCraft\Bits\Table\Table;

// Enable pagination: 10 rows per page
$t = $table->withPageSize(10);

// Navigate pages
$t = $t->withPage(1);   // zero-based — go to page 1 (second page)
$t = $t->nextPage();
$t = $t->prevPage();
$t = $t->pageFirst();
$t = $t->pageLast();

// Inspect pagination state
$pageSize   = $t->pageSize();      // int — rows per page (0 = pagination disabled)
$current   = $t->currentPage();   // int — zero-based current page
$totalPages = $t->paginator()->totalPages();  // int — 1 when pagination is disabled

// Wire a Paginator to the table for UI rendering
$paginator = $t->paginator();    // Paginator instance

Table pagination builders

Method Description
withPageSize(int $size) Set rows per page — 0 disables pagination; ≥1 enables it
withPage(int $page) Navigate to a zero-based page (clamps to valid range)
nextPage() Advance one page
prevPage() Retreat one page
pageFirst() Jump to the first page
pageLast() Jump to the last page
pageSize(): int Return rows per page (0 = pagination off)
currentPage(): int Return the current zero-based page
paginator()->totalPages(): int Return the total page count (1 when pagination is disabled)
paginator(): Paginator Return a Paginator instance wired to the table's current page state

Pagination works with sort and filter: changing the sort order, filter query, or page size automatically re-clamps the cursor to the first row of the current page so the cursor never points to a row outside the current page boundary.

btop helpers — TextEdit, OptionRow, position-coloured Progress

Three pieces added for candy-top (the btop-inspired system monitor) that are useful to any TUI drawing overlays, option screens or gradient meters.

Input\TextEdit — underline-caret inline editor

A pure immutable value object (not a Model–Update–View component) mirroring btop Draw::TextEdit — the proc filter bar and options-menu value editor. The caret is drawn as an SGR underline (TextEdit::UL / TextEdit::UUL) on the cluster under it, so several editors can be on screen while the hardware cursor stays hidden. For a focusable, blinking, validated input use TextInput instead.

use SugarCraft\Bits\Input\TextEdit;

$edit = TextEdit::new('firefox')   // caret defaults to the end
    ->home()->right()->right()     // caret on "r"
    ->insert('X')                  // "fiXrefox"
    ->backspace();                 // "firefox", caret 2
echo $edit->view();                // "fi\e[4mr\e[24mefox"
echo TextEdit::new('a-very-long-filter-string')->view(10); // "er-string\e[4m \e[24m"

$pid = TextEdit::new('', numeric: true)->insert('12')->insert('a')->insert('3');
echo $pid->text();                 // "123" — non-digit input is rejected whole
  • new(string $text = '', ?int $caret = null, bool $numeric = false); accessors text() / caret() / numeric() / length().
  • Editing: left() / right() / home() / end() / withCaret(int) (clamped) / insert(string) / backspace() / delete() / clear().
  • The caret counts grapheme clusters (a ZWJ emoji or combining sequence moves and deletes as one); view(int $limit = 0) budgets in display cells, windowing around the caret with btop's half-budget law. Unlike btop, the end-caret cell is charged to the budget, so output never exceeds $limit. $limit <= 0 is unlimited.
  • withNumeric(bool $on = true) strips existing non-digits (keeping the caret on the same surviving digit) so text() is always parseable.

Menu\OptionRow — btop options-menu geometry

OptionRow::render(...) returns the two-line row from btop Menu::optionsMenu: line 1 the bold name centred in $nameCol (default OptionRow::NAME_COL = 29), line 2 " " + value centred in $valueCol (VALUE_COL = 25) + " ", with ← / → on the selected row (while not editing) when $hasArrows and ↵ (or E with tty: true) when $editable. Passing a TextEdit with editing: true renders its underline-caret view in valueCol - 1 cells.

use SugarCraft\Bits\Input\TextEdit;
use SugarCraft\Bits\Menu\OptionRow;
use SugarCraft\Core\Util\Color;
use SugarCraft\Sprinkles\Style;

echo OptionRow::render(
    'Update ms',
    TextEdit::new('2000', numeric: true),
    selected: true,
    editing: true,
    hasArrows: true,
    editable: true,
    selBg: Style::new()->background(Color::hex('#3d59a1')),
    selFg: Style::new()->foreground(Color::hex('#ffffff')),
);

Centering follows btop cjust (the odd spare column goes on the left, the opposite of Width::padCenter()); over-wide text truncates to the column in display cells. The selected style deliberately bleeds into the value line, so the selected option reads as one two-row block. Optional title: / main: styles colour the unselected name / value.

Progress::withColorFunc — bar width as a 4th argument

The colour closure is now called fn(int $i, int $filledCells, float $percent, int $barWidth): Color — $barWidth is the true cell width (filled + empty, after any percent suffix). That lets each filled cell take its colour from its position on a 101-stop ramp, btop-style. Three-parameter closures keep working.

use SugarCraft\Bits\Progress\Progress;
use SugarCraft\Core\Util\Color;

$ramp = Color::hex('#50fa7b')->blend1d(Color::hex('#ff5555'), 101);
echo Progress::new()
    ->withWidth(20)
    ->withShowPercent(false)
    ->withPercent(0.6)
    ->withColorFunc(static fn (int $i, int $filled, float $pct, int $barWidth): Color
        => $ramp[(int) round(($i + 1) * 100 / $barWidth)])
    ->view();

This gives btop's colouring, not its fill count (Progress fills round(percent * width) cells). For an exact btop meter use sugar-dash Meter::withGradient($stops, positionWise: true).

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

Test

cd sugar-bits && composer install && vendor/bin/phpunit

Demos

Cursor

cursor

File picker

file-picker

Help

help

Item list

item-list

Paginator

paginator

Progress

progress

Spinners

spinners

Stopwatch

stopwatch

Tabs

tabs

Table

table

Text area

text-area

Text input

text-input

Text input (enhanced)

text-input

Timer

timer

Tree

tree

Viewport

viewport

Related

Credits & inspiration

Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.