PHP port of charmbracelet/bubbles β
15 pre-built TUI components for SugarCraft, including the interactive
Tree (mirrors upstream Bubbles #233), dynamic-height TextArea
(mirrors #910), and per-cell Table::styleFunc(...) (mirrors #246).
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. The upstream-mirroringwith*long forms still work side-by-side.
Upstream Bubbles ships 13 components; SugarBits ships those 13 plus
AnimatedProgress (the spring-physics variant lives in its own class
to keep the static Progress lean).
| Component | What it does | Notable msgs |
|---|---|---|
Cursor\Cursor |
Animated text 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 | 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 |
β |
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) |
TextArea\TextAreaEditedMsg |
Viewport\Viewport |
Scrollable text region with mouse-wheel, scrollbar, horizontal scroll, setWidth(int) / setHeight(int) |
β |
Paginator\Paginator |
Dot / arabic page indicator | β |
ItemList\ItemList |
Selectable / scrollable / filterable list with status messages | β |
Tree\Tree |
Interactive tree β cursor, expand/collapse, viewport scroll. Mirrors upstream Bubbles #233. | β |
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 | β |
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.
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;
}
}
[$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();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.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 supports deferred and filtered validation via two new builders:
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 |
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]');| 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) |
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->getSortState(); // SortState
foreach ($state->criteria as [$colIndex, $dir]) {
// $colIndex is an int, $dir is SortDirection::Asc or SortDirection::Desc
}| Case | Value | Description |
|---|---|---|
SortDirection::Asc |
'asc' |
Sort in ascending order |
SortDirection::Desc |
'desc' |
Sort in descending order |
SortDirection::toggle() returns the opposite direction.
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 |
| 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 |
getSortState(): 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.
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->getFilterable(); // bool
$query = $t->getFilter(); // string
$predicate = $t->getFilterPredicate(); // ?Closure(list<string>): boolWhen withFilterPredicate() is set, it overrides the default substring-match behaviour. Pass null to restore the default.
| 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 |
getFilterable(): bool |
Return whether filtering is enabled |
getFilter(): string |
Return the current filter query string |
getFilterPredicate(): ?Closure |
Return the current custom predicate |
The default filter applies case-insensitive substring matching across all visible columns.
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->getPageSize(); // int β rows per page (0 = pagination disabled)
$current = $t->getCurrentPage(); // int β zero-based current page
$totalPages = $t->getTotalPages(); // int β 1 when pagination is disabled
// Wire a Paginator to the table for UI rendering
$paginator = $t->getPaginator(); // Paginator instance| 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 |
getPageSize(): int |
Return rows per page (0 = pagination off) |
getCurrentPage(): int |
Return the current zero-based page |
getTotalPages(): int |
Return the total page count (1 when pagination is disabled) |
getPaginator(): 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.
cd sugar-bits && composer install && vendor/bin/phpunit- SugarCraft monorepo
- Upstream: charmbracelet/bubbles















