Search by

sugarcraft / candy-lister

detain

PHP port of treilik/bubblelister — tree-list view component with customisable prefix/suffix rendering, line wrapping, cursor navigation, and per-item styling hooks.

dev-master 2026-10-03 07:04 UTC

This package is auto-updated.

Last update: 2026-10-03 15:31:48 UTC


README

candy-lister

CI codecov Packagist Version License PHP

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 × Height viewport; CursorOffset gap from edges
  • Stringable items — any PHP object with __toString() or Stringable works as a list item
  • StringItem adapter — wrap plain strings as list items without a class
  • LessFunc / EqualsFunc — plug-in sorting and equality comparison
  • Fuzzy matching — FuzzyMatch scores candidates via Smith-Waterman local alignment
  • Filter state machine — withFilterFn() / withoutFilter() with FilterState enum 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.

License

MIT