Skip to content

sugarcraft/sugar-bits

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

124 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

sugar-bits

SugarBits

CI codecov Packagist Version License PHP

demo

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, and Help expose short-form aliases for the most-used setters: placeholder / charLimit / width / height / prompt / validator / styles / separator / ellipsis. The upstream-mirroring with* long forms still work side-by-side.

Components

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 β€”

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;
    }
}

[$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->getSortState(); // 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
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.

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->getFilterable();   // bool
$query        = $t->getFilter();        // string
$predicate    = $t->getFilterPredicate(); // ?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
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.

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->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

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
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.

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

About

🧩 PHP port of πŸ“¦ bubbles β€” 14 pre-built TUI widgets: Viewport, TextArea, TextInput, List, Table, Spinner, Progress, FilePicker, Pager & more.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages