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.

Maintainers

Package info

github.com/sugarcraft/candy-lister

pkg:composer/sugarcraft/candy-lister

Transparency log

Statistics

Installs: 0

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-07-04 22:42 UTC

This package is auto-updated.

Last update: 2026-07-10 03:49:05 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 matchingFuzzyMatch scores candidates via Smith-Waterman local alignment
  • Filter state machinewithFilterFn() / withoutFilter() with FilterState enum tracking (unfiltered / filtering / filtered)
  • 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};

$model = Model::new();
$model->setWidth(80)->setHeight(24);
$model->addItem(new StringItem('First item'));
$model->addItem(new StringItem('Second item'));
$model->addItem(new StringItem('Third item'));
$model->setPrefixer(new DefaultPrefixer());
$model->setSuffixer(new DefaultSuffixer());

echo $model->View();
// Renders the list with ╭ ├ │ prefixes, line numbers, and > cursor marker

Item Types

Note: Item values are emitted verbatim via (string) $value. If item text originates from a database or other untrusted source, sanitize it before adding to the model — the list does not perform any escaping.

// Plain string adapter
$model->addItem(new StringItem('Plain string item'));

// Any Stringable object
class MyItem implements \Stringable {
    public function __toString(): string { return 'Formatted item'; }
}
$model->addItem(new MyItem());

Custom Prefixer

use SugarCraft\Lister\{Prefixer, Model};

$model->setPrefixer(new class implements Prefixer {
    public function initPrefixer(
        \Stringable $value, int $currentIndex, int $cursorIndex,
        int $lineOffset, int $width, int $height
    ): 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->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->setWidth(80)->setHeight(25);
$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
$model = Model::new();
$model->setWidth(80)->setHeight(24);
foreach (['apple', 'banana', 'cherry', 'apricot', 'blueberry'] as $f) {
    $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 → FilterState::filtered

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
filtering filtered filter applied, items reduced
filtered unfiltered withoutFilter() called
filtering unfiltered filter cleared before result

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