sugarcraft / sugar-treelist
Tree-list view component for terminal UIs in PHP — customisable prefix/suffix rendering, whitespace-faithful line wrapping, cursor navigation, and per-row delta repaint. Design antecedent: treilik/bubblelister.
Requires
- php: ^8.3
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-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-11-05 01:03:06 UTC
README
SugarTreelist
Formerly published as
sugarcraft/candy-lister(renamed 2026-10). Namespace changed:SugarCraft\Lister\→SugarCraft\TreeList\.
sugar-treelist — a tree-list view component for terminal UIs, for PHP 8.3+. 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, whitespace-faithfully (indentation and inter-word runs survive)
- Cursor navigation — current item highlighted with configurable style
- Viewport awareness — fixed-height rendering via
setViewport();setCursorOffset()controls the context gap kept above the cursor (render-clamped so it can never exceed the viewport) 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 by delegating to sugar-fuzzy'sSmithWatermanMatcher(SSOT — see Deprecation & delegation) - Filter state machine —
withFilterFn()/withoutFilter()withFilterStateenum tracking (unfiltered / filtering); a filter is a live view, mutations project through it - Delta rendering —
view()emits a full frame first, then repaints only the rows that changed;resetPreviousFrame()forces a repaint (see Delta rendering)
Install
composer require sugarcraft/sugar-treelist
Quick Start
use SugarCraft\TreeList\{Model, StringItem, DefaultPrefixer, DefaultSuffixer}; // Fluent setters RETURN NEW Models — rebind (or chain); instances are immutable // as far as data goes — but view()/resetPreviousFrame() mutate the delta-render // row memo in place (see Delta rendering). $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 a ╭ ├ ╰ box-drawing gutter, line numbers, and > cursor marker
Item Types
Note: Item values are rendered through the sugar-core sanitize choke point
(Sanitize::untrustedForMarkedFrames): C0/C1 control bytes, DEL, any ANSI
embedded in the item text, and mouse-zone sentinels (U+E000/U+E001) are
stripped at render time — item content can forge neither a frame nor a click
zone. The only styles that reach the output are the SGR parameters you pass to
setLineStyle()/setCurrentStyle() (both reject non-SGR escapes), and the
DefaultPrefixer/DefaultSuffixer glyph knobs refuse control bytes at init.
The library emits no colour of its own: if your host honours NO_COLOR, pass
empty style strings — sugar-treelist neither reads nor acts on the variable.
// 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
DefaultPrefixer renders the box-drawing gutter and has public knobs (all
glyph strings are validated at init — control bytes and malformed UTF-8 are
refused): $firstSep (top corner, default ╭), $separator (middle join,
default ├), $lastSep (bottom corner, default ╰), $separatorWrap
(continuation rows of a wrapped item, default │), $currentMarker (>),
$emptyMarker (a single space on non-cursor rows), $number (toggle the
number column — its field width is derived from the item count, so columns
stay aligned past nine items), $numberRelative (number as each item's
unsigned distance from the cursor: 2 1 0 1 2), and $prefixWrap (continuation
rows use $separatorWrap instead of the join glyph). The join glyph follows
the item's position in the list, so a rendered window shows a real tree.
DefaultSuffixer::$currentMarker (default <) marks the cursor row.
To implement the contract yourself:
use SugarCraft\TreeList\{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\TreeList\{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->setViewport(80, 25); // or setWidth(80)->setHeight(25) $model = $model->setCursorOffset(3); // keep up to 3 rows between cursor and the frame top $model = $model->setLineOffset(5); // context rows reported to initPrefixer()/initSuffixer() for relative markers (the viewport anchor is cursorOffset, not this) $model = $model->setWrap(2); // per-item displayed-row cap (0 = no cap)
Geometry setters reject negative values and dimensions beyond the
MAX_DIMENSION/MAX_TOTAL_CELLS caps (zero is accepted at set time and
refused loudly at render). setCursorOffset is additionally clamped at
render to height - 1, so it can never ask for more context rows than the
viewport has. Scrolling uses a scroll-end clamp: near the bottom of the list
the window slides back so the frame stays full and the cursor row is always
inside it — short lists simply show everything.
Filtering
Attach a filter function to narrow the visible items. The model tracks filter state via the FilterState enum:
use SugarCraft\TreeList\{Model, StringItem, FilterState}; // Start with a list (rebind each step — setters return new Models; // only the delta-render frame memo is mutated in place by view()) $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) |
A filter is a live view, not a one-time snapshot: while filtering,
addItem()/addItems()/addItemsFromArray()/removeItem()/clear()/sort()
all act on the authoritative list and the projection is re-derived immediately
— added items appear when (and only when) the predicate accepts them, removals
stick, and lifting the filter with withoutFilter() reveals exactly the
mutations that happened underneath. removeItem() re-anchors the cursor by
item identity: it stays on the same logical item it was on (clamping only
when that item was itself removed).
Fuzzy Matching
FuzzyMatch ranks candidates by Smith-Waterman local-alignment score. It is a
thin delegating shim: the DP core lives in sugar-fuzzy's SmithWatermanMatcher
(SSOT), and this class keeps sugar-treelist's own public contract —
\Stringable items and input-order tiebreak on equal scores (intentionally
not SmithWatermanMatcher::matchAll(), whose haystack-ascending tiebreak
would reorder equal-score items):
use SugarCraft\TreeList\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
Candidate cap (DoS guard): sugar-fuzzy caps input lengths
(SmithWatermanMatcher::DEFAULT_MAX_QUERY_LENGTH /
DEFAULT_MAX_CANDIDATE_LENGTH). Over-cap input is aligned on its prefix
on the same score scale as every other item, so a very long candidate ranks
consistently with short ones. The trade-off: text that exists only past the
candidate cap is not searched.
Deprecation & delegation
This lib is a façade over sugar-fuzzy for fuzzy scoring (reciprocally documented in sugar-fuzzy's README):
SugarCraft\TreeList\ScoringProfileis a@deprecatedclass_aliasto sugar-fuzzy'sSugarCraft\Fuzzy\ScoringProfile— the single source of truth. It is aliased, not subclassed, sonew FuzzyMatch(ScoringProfile::strict())hands the SSOT matcher an instance it accepts natively.SugarCraft\TreeList\FuzzyMatchis a thin delegating shim overSmithWatermanMatcher(keeping sugar-treelist's own\Stringable-item contract and input-order tiebreak, plus the prefix-alignment cap behaviour documented above).
New code should depend on SugarCraft\Fuzzy\* directly and use
sugar-fuzzy's SmithWatermanMatcher / ScoringProfile; the re-exports here
exist only for back-compat.
Delta rendering
Model::view() keeps a memo of the rows it emitted last time
(?list<string> $previousRows — styled row text, one entry per viewport row).
The first render emits a full frame; each later render compares rows one by
one and repaints only the changed rows, each addressed absolutely
(cursor-position + erase-line before and after the row's own bytes). Unchanged
rows cost nothing; a changed row costs its own content plus a fixed addressing
overhead. A resize, a filter transition, or resetPreviousFrame() clears the
memo and forces the next view() to emit a full repaint.
Because rows are written whole, styled rows round-trip exactly — wide glyphs are never split and SGR bytes never land in the grid as literal glyph cells. (The earlier cell-granular design projected rows into sugar-cellbuffer and diffed them; its style transport could not carry palette SGR spellings, so it corrupted styled frames after the first repaint. That dependency was retired with the row-granular rewrite.)
SSH bandwidth win: over a slow link only changed rows ride the wire, which greatly reduces per-frame data and the full-screen flicker of rewrite-based rendering.
Lineage caveat: the memo is per-instance render state carried through
clones. If you fork a model and later switch which fork's frames you paint
onto the same terminal region, call resetPreviousFrame() on the fork you are
about to render — otherwise it diffs against its sibling's last frame. Same
rule after any out-of-band write to the list's screen area.
Cursor & navigation API
Beyond the fluent setters shown above, the navigation surface is:
setCursor(int $index)— clamped to0..count-1(an out-of-range value lands on the nearest end; the setter always returns a fresh clone).cursorUp(int $n = 1)/cursorDown(int $n = 1)and the page helperscursorPageUp(int $pages = 1)/cursorPageDown(int $pages = 1)— movement saturates at the list ends (cursorDown(PHP_INT_MAX)lands on the last item, never wraps or overflows). A page spansheightitems, not displayed lines — a list of multi-line items pages visually faster.cursorToStart()/cursorToEnd()jump to the ends.cursorIndex()— the current row position.cursorItem()— the cursor item's\Stringable; throwsRuntimeExceptionon an empty list.tryCursorItem()returnsnullinstead, matching thetryItemAt()/itemAt()pair.length(),isEmpty(),itemAt(int),tryItemAt(int),find(\Stringable),itemIds()— read-only item access (indices address the visible projection, so they follow the active filter).lines()— the laid-out frame rows without any delta bookkeeping; throwsRuntimeExceptionwith a localized message (see below) on an empty list or zero viewport.linesStream()is a generator-shaped wrapper over the same eager computation — it interleaves nothing.view()— full frame or row delta as described above; the only method that mutates in place (its render memo), which is why the immutability contract is scoped to item/configuration data.
Localisation
The two refusal strings lines() can raise are live Lang::t() lookups
(list.no_items, list.zero_viewport) across the 16 shipped locales under
lang/. Locale selection is the host application's concern; the renderer only
formats through SugarCraft\TreeList\Lang.
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.
Design antecedent: treilik/bubblelister.