sugarcraft / candy-lister
PHP port of treilik/bubblelister — tree-list view component with customisable prefix/suffix rendering, line wrapping, cursor navigation, and per-item styling hooks.
Requires
- php: ^8.3
- sugarcraft/candy-async: dev-master
- sugarcraft/candy-buffer: dev-master
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-fuzzy: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-03 15:31:48 UTC
README
CandyLister
PHP port of treilik/bubblelister — a tree-list view component for terminal UIs. Renders items with custom prefix/suffix hooks, line wrapping, and cursor-aware styling.
Features
- Customisable Prefixer — generates per-line prefix strings (line numbers, box-drawing borders, tree branches)
- Customisable Suffixer — generates per-line suffix strings (status markers, padding)
- Line wrapping — items wrap to multiple lines within a fixed viewport width
- Cursor navigation — current item highlighted with configurable style
- Viewport awareness — respects
Width×Heightviewport;CursorOffsetgap from edges Stringableitems — any PHP object with__toString()orStringableworks as a list itemStringItemadapter — wrap plain strings as list items without a classLessFunc/EqualsFunc— plug-in sorting and equality comparison- Fuzzy matching —
FuzzyMatchscores candidates via Smith-Waterman local alignment - Filter state machine —
withFilterFn()/withoutFilter()withFilterStateenum tracking (unfiltered / filtering) - Pure rendering — outputs ANSI-styled strings; integrate with any TUI framework
Install
composer require sugarcraft/candy-lister
Quick Start
use SugarCraft\Lister\{Model, StringItem, DefaultPrefixer, DefaultSuffixer}; // Fluent setters RETURN NEW Models — rebind (or chain); instances are immutable. $model = Model::new()->setWidth(80)->setHeight(24); $model = $model->addItem(new StringItem('First item')); $model = $model->addItem(new StringItem('Second item')); $model = $model->addItem(new StringItem('Third item')); $model = $model->setPrefixer(new DefaultPrefixer()); $model = $model->setSuffixer(new DefaultSuffixer()); echo $model->view(); // Renders the list with ╭ ├ │ prefixes, line numbers, and > cursor marker
Item Types
Note: Item values are rendered through the candy-core sanitize choke point
(Sanitize::untrusted): C0/C1 control bytes, DEL, and any ANSI embedded in the
item text are stripped at render time. The only styles that reach the output
are the SGR parameters you pass to setLineStyle()/setCurrentStyle() (both
reject non-SGR escapes).
// Plain string adapter $model = $model->addItem(new StringItem('Plain string item')); // Any Stringable object class MyItem implements \Stringable { public function __toString(): string { return 'Formatted item'; } } $model = $model->addItem(new MyItem());
Custom Prefixer
use SugarCraft\Lister\{Prefixer, Model}; $model = $model->setPrefixer(new class implements Prefixer { public function initPrefixer( \Stringable $value, int $currentIndex, int $cursorIndex, int $lineOffset, int $width, int $height, int $totalItems ): int { return 0; // no prefix width } public function prefix(int $currentLine, int $totalLines): string { return $currentLine === 0 ? '• ' : ' '; } });
Custom Suffixer
use SugarCraft\Lister\{Suffixer, Model}; $model = $model->setSuffixer(new class implements Suffixer { public function initSuffixer( \Stringable $value, int $currentIndex, int $cursorIndex, int $lineOffset, int $width, int $height ): int { return 0; } public function suffix(int $currentLine, int $totalLines): string { return ''; } });
Viewport
Set the rendering viewport dimensions before calling view():
$model = $model->setWidth(80)->setHeight(25); $model = $model->setCursorOffset(3); // keep 3 lines between cursor and screen edge
Filtering
Attach a filter function to narrow the visible items. The model tracks filter state via the FilterState enum:
use SugarCraft\Lister\{Model, StringItem, FilterState}; // Start with a list (rebind each step — the model is immutable) $model = Model::new()->setWidth(80)->setHeight(24); foreach (['apple', 'banana', 'cherry', 'apricot', 'blueberry'] as $f) { $model = $model->addItem(new StringItem($f)); } // Filter to items starting with "a" $filtered = $model->withFilterFn( fn(\Stringable $item) => stripos((string) $item, 'a') === 0 ); // filterState is now FilterState::filtering echo $filtered->length(); // 2 (apple, apricot) echo $filtered->view(); // Remove filter and restore original items $restored = $filtered->withoutFilter(); // filterState is now FilterState::unfiltered echo $restored->length(); // 5
Filter state transitions:
| From | To | Trigger |
|---|---|---|
unfiltered |
filtering |
withFilterFn() called (filter applied, items reduced) |
filtering |
unfiltered |
withoutFilter() called (original items restored) |
Fuzzy Matching
FuzzyMatch implements Smith-Waterman local alignment to rank candidates by relevance to a query string. It is memory-efficient (two-row DP matrix) and penalizes gaps and mismatches while rewarding consecutive character matches:
use SugarCraft\Lister\FuzzyMatch; $matcher = new FuzzyMatch(); // Score a single candidate $score = $matcher->score('april', 'apricot'); // 27 (consecutive match bonus applied) // Filter and rank a list of items $items = [ new StringItem('April'), new StringItem('September'), new StringItem('June'), new StringItem('July'), new StringItem('November'), ]; $results = $matcher->match('sep', $items); // Returns [ [StringItem('September'), 19], ... ] sorted by score descending
Buffer diffing
The Model::view() maintains a ?Buffer $previousFrame across renders. On each render it
builds the current Buffer, computes current->diff(previous) (from
candy-buffer), and emits only
the delta ANSI ops via DiffEncoder::encode($ops). The current frame then replaces
previousFrame for the next render.
SSH bandwidth + flicker win: a one-character change in an 80×24 viewport produces ~8 bytes of delta ops instead of ~1 940 bytes for a full repaint. Over an SSH session this means far less per-frame data on the wire and eliminates the full-screen 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.