sugarcraft / sugar-bits
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.
Requires
- php: ^8.3
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-forms: dev-master
- sugarcraft/candy-sprinkles: dev-master
- sugarcraft/candy-zone: dev-master
- sugarcraft/honey-bounce: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/candy-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 01:56:40 UTC
README
SugarBits
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, andHelpexpose short-form aliases for the most-used setters:placeholder/charLimit/width/height/prompt/validator/styles/separator/ellipsis. Thewith*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); accessorstext()/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 <= 0is unlimited. withNumeric(bool $on = true)strips existing non-digits (keeping the caret on the same surviving digit) sotext()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
File picker
Help
Item list
Paginator
Progress
Spinners
Stopwatch
Tabs
Table
Text area
Text input
Text input (enhanced)
Timer
Tree
Viewport
Related
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.















