diff --git a/README.md b/README.md
index 9ca8636..16171e9 100644
--- a/README.md
+++ b/README.md
@@ -210,7 +210,7 @@ With `revealable()` on, pressing Tab in the editor reveals the value and the hin
### Select
-Single choice from a list. Up/Down move, Enter accepts the highlighted option. Pass `default` (an option key) to start on an option other than the first.
+Single choice from a list. Up/Down move, Enter accepts the highlighted option. Pass `default` (an option key) to start on an option other than the first. Long lists page around the cursor - set the window with `->pageSize()`.
```php
$p->select('profile', 'Install profile')->default('minimal')->options(['standard' => 'Standard', 'minimal' => 'Minimal', 'demo_umami' => 'Demo Umami']);
@@ -236,7 +236,7 @@ $p->select('profile', 'Install profile')->default('minimal')->options(['standard
### MultiSelect
-Multiple choice from a checkbox list. Space toggles the highlighted option, typing narrows the list, Right selects and Left deselects everything visible, Enter accepts. Pass `default` (a list of option keys) to pre-check options - ideal for opt-out lists.
+Multiple choice from a checkbox list. Space toggles the highlighted option, typing narrows the list by substring, Right selects and Left deselects everything visible, Enter accepts. Pass `default` (a list of option keys) to pre-check options - ideal for opt-out lists. Long lists page around the cursor - set the window with `->pageSize()`.
```php
$p->multiselect('services', 'Services')->default(['redis'])->options(['redis' => 'Redis', 'solr' => 'Solr', 'clamav' => 'ClamAV']);
@@ -262,7 +262,7 @@ $p->multiselect('services', 'Services')->default(['redis'])->options(['redis' =>
### Suggest
-Free text with autocomplete over a fixed option set: type anything, Up/Down highlight a matching suggestion, Enter accepts the highlighted suggestion or the typed text as-is.
+Free text with autocomplete over a fixed option set: as you type, the suggestions are fuzzy-matched and ranked by relevance with the matched characters highlighted; Up/Down highlight a suggestion, Enter accepts the highlighted suggestion or the typed text as-is. Long lists page around the cursor - set the window with `->pageSize()`.
```php
$p->suggest('php_version', 'PHP version')->default('8.4')->options(['8.1' => '8.1', '8.2' => '8.2', '8.3' => '8.3', '8.4' => '8.4']);
@@ -288,10 +288,10 @@ $p->suggest('php_version', 'PHP version')->default('8.4')->options(['8.1' => '8.
### Search
-Single choice with a visible type-to-filter line above the options: typing narrows the labels, Up/Down move, Enter accepts the highlighted option's key.
+Single choice with a visible filter line above the options: typing fuzzy-matches and ranks the labels - exact and prefix matches lead, looser subsequence matches follow - and highlights the matched characters; Up/Down move, Enter accepts the highlighted option's key. Long lists page around the cursor - set the window with `->pageSize()`.
```php
-$p->search('timezone', 'Timezone')->default('london')->options(['utc' => 'UTC', 'london' => 'Europe/London', 'paris' => 'Europe/Paris', 'sydney' => 'Australia/Sydney']);
+$p->search('timezone', 'Timezone')->default('london')->pageSize(8)->options(['utc' => 'UTC', 'london' => 'Europe/London', 'paris' => 'Europe/Paris', 'sydney' => 'Australia/Sydney']);
```
@@ -314,7 +314,7 @@ $p->search('timezone', 'Timezone')->default('london')->options(['utc' => 'UTC',
### MultiSearch
-A multi-select whose type-to-filter query is shown as a search line: type to narrow, Space toggles, Enter accepts the checked set.
+A multi-select whose filter query is shown as a search line: typing fuzzy-matches and ranks the options with the matched characters highlighted, Space toggles, Enter accepts the checked set. Long lists page around the cursor - set the window with `->pageSize()`.
```php
$p->multisearch('services', 'Services')->default(['redis'])->options(['redis' => 'Redis', 'solr' => 'Solr', 'clamav' => 'ClamAV', 'memcached' => 'Memcached']);
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index 1b381e3..316de82 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -131,6 +131,11 @@ final class FieldBuilder {
*/
protected bool $pickerShowHidden = FALSE;
+ /**
+ * Choice widgets only: the visible page size, when declared.
+ */
+ protected ?int $pageSize = NULL;
+
/**
* Construct a field builder.
*
@@ -375,6 +380,32 @@ public function showHidden(bool $show = TRUE): self {
return $this;
}
+ /**
+ * Choice widgets only: bound the visible option list to a page size.
+ *
+ * Longer lists page around the cursor rather than overflowing the viewport.
+ * Honoured by the select, multiselect, suggest, search and multisearch
+ * widgets; ignored by other types.
+ *
+ * @param int $size
+ * The number of option rows shown at once; must be positive.
+ *
+ * @return $this
+ * The builder.
+ *
+ * @throws \DrevOps\Tui\Config\ConfigException
+ * When the size is not positive.
+ */
+ public function pageSize(int $size): self {
+ if ($size < 1) {
+ throw new ConfigException(sprintf('Field "%s" declares a non-positive page size %d.', $this->id, $size));
+ }
+
+ $this->pageSize = $size;
+
+ return $this;
+ }
+
/**
* Set the conditional-visibility rule.
*
@@ -562,6 +593,7 @@ public function build(): Field {
$this->pickerStart,
$this->pickerExtensions,
$this->pickerShowHidden,
+ $this->pageSize,
);
}
diff --git a/src/Config/Field.php b/src/Config/Field.php
index 5fec8b8..0a5488a 100644
--- a/src/Config/Field.php
+++ b/src/Config/Field.php
@@ -81,6 +81,10 @@
* (dot-less, case-insensitive); empty allows every extension.
* @param bool $pickerShowHidden
* File picker only: whether dot-entries are shown when the browser opens.
+ * @param int|null $pageSize
+ * Choice widgets only: how many option rows show at once before the list
+ * pages; NULL uses the widget default. A purely visual bound - it does not
+ * constrain a headless value, so it is absent from the machine schema.
*/
public function __construct(
public string $id,
@@ -104,6 +108,7 @@ public function __construct(
public string $pickerStart = '',
public array $pickerExtensions = [],
public bool $pickerShowHidden = FALSE,
+ public ?int $pageSize = NULL,
) {
$this->options = Option::list($options);
}
diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php
index cec1c07..d09b57e 100644
--- a/src/Theme/DefaultTheme.php
+++ b/src/Theme/DefaultTheme.php
@@ -335,6 +335,13 @@ public function highlight(string $text): string {
return $this->paint($this->isDark ? '1;36' : '1;34', $text);
}
+ /**
+ * {@inheritdoc}
+ */
+ public function highlightMatch(string $text): string {
+ return $this->paint($this->isDark ? '1;33' : '1;35', $text);
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/src/Theme/ThemeInterface.php b/src/Theme/ThemeInterface.php
index 7b2e633..faf734e 100644
--- a/src/Theme/ThemeInterface.php
+++ b/src/Theme/ThemeInterface.php
@@ -127,6 +127,11 @@ public function indicator(string $text): string;
*/
public function highlight(string $text): string;
+ /**
+ * A run of query-matched characters within an option label.
+ */
+ public function highlightMatch(string $text): string;
+
/**
* A non-selectable group heading in an option list.
*/
diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php
index 57b32f0..bcda540 100644
--- a/src/Widget/AbstractWidget.php
+++ b/src/Widget/AbstractWidget.php
@@ -9,6 +9,8 @@
use DrevOps\Tui\Input\KeyMapManager;
use DrevOps\Tui\Input\Scope;
use DrevOps\Tui\Input\ScopedKeyMap;
+use DrevOps\Tui\Render\Scroller;
+use DrevOps\Tui\Render\Viewport;
use DrevOps\Tui\Theme\ThemeInterface;
/**
@@ -18,6 +20,11 @@
*/
abstract class AbstractWidget implements WidgetInterface {
+ /**
+ * The page size applied when a field declares none.
+ */
+ public const int DEFAULT_PAGE_SIZE = 10;
+
/**
* The resolved key bindings for this widget's scope.
*
@@ -26,6 +33,21 @@ abstract class AbstractWidget implements WidgetInterface {
*/
protected ?ScopedKeyMap $scoped = NULL;
+ /**
+ * The number of option rows shown at once before the list pages.
+ */
+ protected int $pageSize = self::DEFAULT_PAGE_SIZE;
+
+ /**
+ * The index of the first visible option row under paging.
+ */
+ protected int $offset = 0;
+
+ /**
+ * The fuzzy matcher, created on first use.
+ */
+ protected ?Matcher $matcher = NULL;
+
/**
* Whether a valid value has been accepted.
*/
@@ -189,6 +211,125 @@ protected function renderRadioRow(ThemeInterface $theme, string $label, bool $cu
return $theme->radio($current) . ' ' . $this->highlightLabel($theme, $label, $current);
}
+ /**
+ * The shared fuzzy matcher.
+ *
+ * @return \DrevOps\Tui\Widget\Matcher
+ * The matcher.
+ */
+ protected function matcher(): Matcher {
+ return $this->matcher ??= new Matcher();
+ }
+
+ /**
+ * Resolve the effective page size, rejecting a non-positive declared value.
+ *
+ * The builder rejects a non-positive page size, but a widget may be
+ * constructed directly, so the invariant is enforced here too.
+ *
+ * @param int|null $pageSize
+ * The declared page size, or NULL to use the default.
+ *
+ * @return int
+ * The effective page size.
+ *
+ * @throws \InvalidArgumentException
+ * When a declared page size is not positive.
+ */
+ protected function resolvePageSize(?int $pageSize): int {
+ if ($pageSize !== NULL && $pageSize < 1) {
+ throw new \InvalidArgumentException(sprintf('Page size must be a positive integer, %d given.', $pageSize));
+ }
+
+ return $pageSize ?? self::DEFAULT_PAGE_SIZE;
+ }
+
+ /**
+ * Compute the cursor-visible paging window, storing its offset.
+ *
+ * @param int $total
+ * The total number of option rows.
+ * @param int $cursor
+ * The cursor row index (a negative cursor pins the window to the top).
+ *
+ * @return \DrevOps\Tui\Render\Viewport
+ * The window: its offset and whether rows are scrolled off above or below.
+ */
+ protected function pageViewport(int $total, int $cursor): Viewport {
+ $viewport = (new Scroller())->compute($total, $this->pageSize, max(0, $cursor), $this->offset);
+ $this->offset = $viewport->offset;
+
+ return $viewport;
+ }
+
+ /**
+ * Style an option label, emphasising the query-matched characters.
+ *
+ * The label is split into runs of matched and unmatched characters, each run
+ * styled on its own so no SGR code nests inside another: matched runs get the
+ * match colour, and on the cursor row the rest keeps the highlight colour.
+ * With no matched positions this is exactly {@see highlightLabel()}.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $label
+ * The option label.
+ * @param list $positions
+ * The zero-based indices of the matched characters.
+ * @param bool $current
+ * Whether the option's row holds the cursor.
+ *
+ * @return string
+ * The styled label.
+ */
+ protected function renderMatchedLabel(ThemeInterface $theme, string $label, array $positions, bool $current): string {
+ if ($positions === []) {
+ return $this->highlightLabel($theme, $label, $current);
+ }
+
+ $matched = array_fill_keys($positions, TRUE);
+ $out = '';
+ $run = '';
+ $run_matched = FALSE;
+
+ foreach (mb_str_split($label) as $index => $char) {
+ $is_matched = isset($matched[$index]);
+
+ if ($run !== '' && $is_matched !== $run_matched) {
+ $out .= $this->styleRun($theme, $run, $run_matched, $current);
+ $run = '';
+ }
+
+ $run .= $char;
+ $run_matched = $is_matched;
+ }
+
+ return $out . $this->styleRun($theme, $run, $run_matched, $current);
+ }
+
+ /**
+ * Style one run of same-kind characters for {@see renderMatchedLabel()}.
+ *
+ * @param \DrevOps\Tui\Theme\ThemeInterface $theme
+ * The theme.
+ * @param string $run
+ * The run of characters.
+ * @param bool $matched
+ * Whether the run's characters matched the query.
+ * @param bool $current
+ * Whether the option's row holds the cursor.
+ *
+ * @return string
+ * The styled run.
+ */
+ protected function styleRun(ThemeInterface $theme, string $run, bool $matched, bool $current): string {
+ if ($matched) {
+ return $theme->highlightMatch($run);
+ }
+
+ return $current ? $theme->highlight($run) : $run;
+ }
+
/**
* Validate and, when valid, transform a value and complete the widget.
*
diff --git a/src/Widget/MatchResult.php b/src/Widget/MatchResult.php
new file mode 100644
index 0000000..277f8ac
--- /dev/null
+++ b/src/Widget/MatchResult.php
@@ -0,0 +1,34 @@
+ $positions
+ * The zero-based indices of the matched characters in the candidate, in
+ * ascending order, for highlighting.
+ */
+ public function __construct(
+ public int $score,
+ public array $positions,
+ ) {
+ }
+
+}
diff --git a/src/Widget/MatchTier.php b/src/Widget/MatchTier.php
new file mode 100644
index 0000000..bee6a5a
--- /dev/null
+++ b/src/Widget/MatchTier.php
@@ -0,0 +1,44 @@
+ 4,
+ self::Prefix => 3,
+ self::Substring => 2,
+ self::Subsequence => 1,
+ };
+ }
+
+}
diff --git a/src/Widget/Matcher.php b/src/Widget/Matcher.php
new file mode 100644
index 0000000..1bb6a32
--- /dev/null
+++ b/src/Widget/Matcher.php
@@ -0,0 +1,283 @@
+bestSubsequence($folded, $needle_folded, $chars);
+ if ($best === NULL) {
+ return NULL;
+ }
+
+ [$positions, $refinement] = $best;
+
+ return new MatchResult($this->tier(mb_strtolower($haystack), mb_strtolower($needle))->weight() * self::TIER_WEIGHT + $refinement, $positions);
+ }
+
+ /**
+ * The matched character indices, or an empty list when there is no match.
+ *
+ * @param string $haystack
+ * The candidate text.
+ * @param string $needle
+ * The query.
+ *
+ * @return list
+ * The matched indices.
+ */
+ public function positions(string $haystack, string $needle): array {
+ $result = $this->match($haystack, $needle);
+
+ return $result instanceof MatchResult ? $result->positions : [];
+ }
+
+ /**
+ * Filter and rank a list of string candidates by relevance, best first.
+ *
+ * @param list $values
+ * The candidate values.
+ * @param string $needle
+ * The query.
+ *
+ * @return list
+ * The matching values, most relevant first; ties keep their input order.
+ */
+ public function rankValues(array $values, string $needle): array {
+ $scores = [];
+ foreach ($values as $index => $value) {
+ $result = $this->match($value, $needle);
+ if ($result instanceof MatchResult) {
+ $scores[$index] = $result->score;
+ }
+ }
+
+ // Sort by score, best first; uasort is stable, so equal scores keep their
+ // insertion order - which is the input order.
+ uasort($scores, static fn(int $left_score, int $right_score): int => $right_score <=> $left_score);
+
+ $ranked = [];
+ foreach (array_keys($scores) as $index) {
+ $ranked[] = $values[$index];
+ }
+
+ return $ranked;
+ }
+
+ /**
+ * Filter and rank option rows by label relevance, best first.
+ *
+ * Only selectable-or-disabled Option rows take part; separators and headings
+ * carry no label and drop away, so the filtered result reads as a flat
+ * relevance list.
+ *
+ * @param list<\DrevOps\Tui\Config\Option> $options
+ * The option rows.
+ * @param string $needle
+ * The query.
+ *
+ * @return list<\DrevOps\Tui\Config\Option>
+ * The matching options, most relevant first; ties keep their input order.
+ */
+ public function rankOptions(array $options, string $needle): array {
+ $scores = [];
+ foreach ($options as $index => $option) {
+ if ($option->kind !== OptionKind::Option) {
+ continue;
+ }
+
+ $result = $this->match($option->label, $needle);
+ if ($result instanceof MatchResult) {
+ $scores[$index] = $result->score;
+ }
+ }
+
+ // Sort by score, best first; uasort is stable, so equal scores keep their
+ // insertion order - which is the declaration order.
+ uasort($scores, static fn(int $left_score, int $right_score): int => $right_score <=> $left_score);
+
+ $ranked = [];
+ foreach (array_keys($scores) as $index) {
+ $ranked[] = $options[$index];
+ }
+
+ return $ranked;
+ }
+
+ /**
+ * The match tier: exact, prefix, substring or subsequence.
+ *
+ * @param string $haystack
+ * The lowercased candidate.
+ * @param string $needle
+ * The lowercased query.
+ *
+ * @return \DrevOps\Tui\Widget\MatchTier
+ * The tier.
+ */
+ protected function tier(string $haystack, string $needle): MatchTier {
+ if ($haystack === $needle) {
+ return MatchTier::Exact;
+ }
+
+ if (str_starts_with($haystack, $needle)) {
+ return MatchTier::Prefix;
+ }
+
+ return str_contains($haystack, $needle) ? MatchTier::Substring : MatchTier::Subsequence;
+ }
+
+ /**
+ * The best-scoring ordered embedding of the needle in the candidate.
+ *
+ * Every ordered way the needle's characters appear in the candidate is an
+ * embedding; this returns the one the refinement rates highest - the
+ * tightest, earliest, most word-boundary-aligned - so the score and the
+ * highlighted characters always agree. The refinement rewards an early,
+ * word-boundary start and penalises gaps between matched characters; because
+ * that penalty is additive over consecutive characters, a short dynamic
+ * program finds the best embedding in O(candidate * needle) time.
+ *
+ * @param list $haystack
+ * The per-character-folded candidate.
+ * @param list $needle
+ * The per-character-folded query (non-empty).
+ * @param list $original
+ * The original candidate characters, for word-boundary tests.
+ *
+ * @return array{list, int}|null
+ * The matched indices and the bounded refinement, or NULL when the needle
+ * is not a subsequence of the candidate.
+ */
+ protected function bestSubsequence(array $haystack, array $needle, array $original): ?array {
+ $count = count($haystack);
+ $depth = count($needle);
+
+ // score[i] is the best path score placing the current needle character at
+ // candidate index i; NULL marks an unreachable placement. back[j][i] holds
+ // the predecessor index chosen there, for recovering the winning indices.
+ $score = array_fill(0, $count, NULL);
+ $back = [array_fill(0, $count, -1)];
+
+ for ($i = 0; $i < $count; $i++) {
+ if ($haystack[$i] === $needle[0]) {
+ $score[$i] = ($this->isWordStart($i, $original) ? 50 : 0) - 10 * $i;
+ }
+ }
+
+ for ($j = 1; $j < $depth; $j++) {
+ $next = array_fill(0, $count, NULL);
+ $step_back = array_fill(0, $count, -1);
+ $running = NULL;
+ $running_index = -1;
+
+ for ($i = 0; $i < $count; $i++) {
+ // Fold every predecessor p < i into a running best of score[p] + 20*p,
+ // so the gap penalty -20*(i - p - 1) reduces to that running maximum.
+ if ($i > 0 && $score[$i - 1] !== NULL) {
+ $candidate = $score[$i - 1] + 20 * ($i - 1);
+ if ($running === NULL || $candidate > $running) {
+ $running = $candidate;
+ $running_index = $i - 1;
+ }
+ }
+
+ if ($running !== NULL && $haystack[$i] === $needle[$j]) {
+ $next[$i] = 20 - 20 * $i + $running;
+ $step_back[$i] = $running_index;
+ }
+ }
+
+ $score = $next;
+ $back[$j] = $step_back;
+ }
+
+ $best = NULL;
+ $end = -1;
+ foreach ($score as $index => $value) {
+ if ($value !== NULL && ($best === NULL || $value > $best)) {
+ $best = $value;
+ $end = $index;
+ }
+ }
+
+ if ($best === NULL) {
+ return NULL;
+ }
+
+ $positions = [];
+ $index = $end;
+ for ($j = $depth - 1; $j >= 0; $j--) {
+ $positions[] = $index;
+ $index = $back[$j][$index];
+ }
+
+ return [array_reverse($positions), max(0, min(self::TIER_WEIGHT - 1, 500 + $best))];
+ }
+
+ /**
+ * Whether an index begins a word (string start or after a non-alphanumeric).
+ *
+ * @param int $index
+ * The index into the candidate.
+ * @param list $haystack_chars
+ * The candidate characters.
+ *
+ * @return bool
+ * TRUE when the index starts a word.
+ */
+ protected function isWordStart(int $index, array $haystack_chars): bool {
+ if ($index === 0) {
+ return TRUE;
+ }
+
+ return preg_match('/[\p{L}\p{N}]/u', $haystack_chars[$index - 1] ?? '') !== 1;
+ }
+
+}
diff --git a/src/Widget/MultiSearchWidget.php b/src/Widget/MultiSearchWidget.php
index be4e531..d72e310 100644
--- a/src/Widget/MultiSearchWidget.php
+++ b/src/Widget/MultiSearchWidget.php
@@ -7,12 +7,28 @@
use DrevOps\Tui\Theme\ThemeInterface;
/**
- * A multi-select whose type-to-filter query is shown as a search line.
+ * A multi-select whose query filters by fuzzy match, shown as a search line.
*
* @package DrevOps\Tui\Widget
*/
class MultiSearchWidget extends MultiSelectWidget {
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function filterOptions(string $needle): array {
+ return $this->matcher()->rankOptions($this->options, $needle);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ protected function matchPositions(string $label): array {
+ return $this->filter === '' ? [] : $this->matcher()->positions($label, $this->filter);
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php
index b2f76f5..cb823e4 100644
--- a/src/Widget/MultiSelectWidget.php
+++ b/src/Widget/MultiSelectWidget.php
@@ -53,10 +53,14 @@ class MultiSelectWidget extends AbstractWidget {
* Optional validator (see AbstractWidget).
* @param \Closure|null $transform
* Optional transformer (see AbstractWidget).
+ * @param int|null $pageSize
+ * The number of option rows shown at once before the list pages; NULL uses
+ * the default.
*/
- public function __construct(array $options, array $default = [], ?\Closure $validate = NULL, ?\Closure $transform = NULL) {
+ public function __construct(array $options, array $default = [], ?\Closure $validate = NULL, ?\Closure $transform = NULL, ?int $pageSize = NULL) {
parent::__construct($validate, $transform);
$this->initOptions($options);
+ $this->pageSize = $this->resolvePageSize($pageSize);
$selectable = array_fill_keys($this->selectableValues(), TRUE);
foreach ($default as $value) {
@@ -124,23 +128,31 @@ public function handle(Key $key): void {
if ($keys->matches($key, Action::DeleteBack)) {
$this->filter = substr($this->filter, 0, -1);
- $this->cursor = $this->firstSelectable($this->visible());
+ $this->resetFilterCursor();
return;
}
if ($key->isChar()) {
$this->filter .= $key->char ?? '';
- $this->cursor = $this->firstSelectable($this->visible());
+ $this->resetFilterCursor();
}
}
+ /**
+ * Land the cursor on the first match and reset paging on a query change.
+ */
+ protected function resetFilterCursor(): void {
+ $this->cursor = $this->firstSelectable($this->visible());
+ $this->offset = 0;
+ }
+
/**
* The rows currently visible under the filter.
*
* With no filter every row shows in declared order; once filtering, only
* matching options show - structural headings and separators drop away so the
- * result reads as a flat relevance list.
+ * result reads as a flat list.
*
* @return list<\DrevOps\Tui\Config\Option>
* The visible rows.
@@ -150,9 +162,41 @@ protected function visible(): array {
return $this->options;
}
- $needle = strtolower($this->filter);
+ return $this->filterOptions($this->filter);
+ }
- return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
+ /**
+ * Filter the options to those matching the query.
+ *
+ * The base checkbox list narrows by case-insensitive substring; the search
+ * variant overrides this to rank by fuzzy relevance.
+ *
+ * @param string $needle
+ * The query.
+ *
+ * @return list<\DrevOps\Tui\Config\Option>
+ * The matching option rows.
+ */
+ protected function filterOptions(string $needle): array {
+ $lower = mb_strtolower($needle);
+
+ return array_values(array_filter($this->options, static fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(mb_strtolower($option->label), $lower)));
+ }
+
+ /**
+ * The matched-character positions in a label, for highlighting.
+ *
+ * The base checkbox list does not highlight matches; the search variant
+ * overrides this to point at the fuzzy-matched characters.
+ *
+ * @param string $label
+ * The option label.
+ *
+ * @return list
+ * The matched indices (none by default).
+ */
+ protected function matchPositions(string $label): array {
+ return [];
}
/**
@@ -233,7 +277,16 @@ protected function liveValue(): mixed {
public function view(ThemeInterface $theme): string {
$lines = [];
- foreach ($this->visible() as $index => $option) {
+ $visible = $this->visible();
+ $viewport = $this->pageViewport(count($visible), $this->cursor);
+
+ if ($viewport->has_above) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorUp());
+ }
+
+ foreach (array_slice($visible, $viewport->offset, $this->pageSize) as $slot => $option) {
+ $index = $viewport->offset + $slot;
+
if ($option->kind === OptionKind::Heading) {
$lines[] = $this->renderHeadingRow($theme, $option);
@@ -253,7 +306,11 @@ public function view(ThemeInterface $theme): string {
}
$current = $index === $this->cursor;
- $lines[] = $theme->marker($current) . ' ' . $theme->check(isset($this->selected[$option->value])) . ' ' . $this->highlightLabel($theme, $option->label, $current);
+ $lines[] = $theme->marker($current) . ' ' . $theme->check(isset($this->selected[$option->value])) . ' ' . $this->renderMatchedLabel($theme, $option->label, $this->matchPositions($option->label), $current);
+ }
+
+ if ($viewport->has_below) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorDown());
}
$lines[] = $this->hint($theme);
diff --git a/src/Widget/SearchWidget.php b/src/Widget/SearchWidget.php
index 88344d5..4b7be1b 100644
--- a/src/Widget/SearchWidget.php
+++ b/src/Widget/SearchWidget.php
@@ -4,14 +4,13 @@
namespace DrevOps\Tui\Widget;
-use DrevOps\Tui\Config\Option;
use DrevOps\Tui\Config\OptionKind;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Key;
use DrevOps\Tui\Theme\ThemeInterface;
/**
- * A single-choice list with type-to-filter over the option labels.
+ * A single-choice list with fuzzy type-to-filter over the option labels.
*
* @package DrevOps\Tui\Widget
*/
@@ -41,11 +40,15 @@ class SearchWidget extends AbstractWidget {
* Optional validator (see AbstractWidget).
* @param \Closure|null $transform
* Optional transformer (see AbstractWidget).
+ * @param int|null $pageSize
+ * The number of option rows shown at once before the list pages; NULL uses
+ * the default.
*/
- public function __construct(array $options, string $default = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL) {
+ public function __construct(array $options, string $default = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, ?int $pageSize = NULL) {
parent::__construct($validate, $transform);
$this->initOptions($options);
$this->cursor = $this->cursorForDefault($this->options, $default);
+ $this->pageSize = $this->resolvePageSize($pageSize);
}
/**
@@ -80,30 +83,38 @@ public function handle(Key $key): void {
if ($keys->matches($key, Action::DeleteBack)) {
$this->filter = substr($this->filter, 0, -1);
- $this->cursor = $this->firstSelectable($this->visible());
+ $this->resetFilterCursor();
return;
}
if ($keys->matches($key, Action::InsertSpace)) {
$this->filter .= ' ';
- $this->cursor = $this->firstSelectable($this->visible());
+ $this->resetFilterCursor();
return;
}
if ($key->isChar()) {
$this->filter .= $key->char ?? '';
- $this->cursor = $this->firstSelectable($this->visible());
+ $this->resetFilterCursor();
}
}
+ /**
+ * Land the cursor on the first match and reset paging on a query change.
+ */
+ protected function resetFilterCursor(): void {
+ $this->cursor = $this->firstSelectable($this->visible());
+ $this->offset = 0;
+ }
+
/**
* The rows currently visible under the filter.
*
* With no filter every row shows in declared order; once filtering, only
- * matching options show - structural headings and separators drop away so the
- * result reads as a flat relevance list.
+ * matching options show, ranked by fuzzy relevance - structural headings and
+ * separators drop away so the result reads as a flat relevance list.
*
* @return list<\DrevOps\Tui\Config\Option>
* The visible rows.
@@ -113,9 +124,7 @@ protected function visible(): array {
return $this->options;
}
- $needle = strtolower($this->filter);
-
- return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
+ return $this->matcher()->rankOptions($this->options, $this->filter);
}
/**
@@ -141,7 +150,16 @@ protected function liveValue(): mixed {
public function view(ThemeInterface $theme): string {
$lines = [$this->filter . $theme->caret()];
- foreach ($this->visible() as $index => $option) {
+ $visible = $this->visible();
+ $viewport = $this->pageViewport(count($visible), $this->cursor);
+
+ if ($viewport->has_above) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorUp());
+ }
+
+ foreach (array_slice($visible, $viewport->offset, $this->pageSize) as $slot => $option) {
+ $index = $viewport->offset + $slot;
+
if ($option->kind === OptionKind::Heading) {
$lines[] = $this->renderHeadingRow($theme, $option);
@@ -160,10 +178,28 @@ public function view(ThemeInterface $theme): string {
continue;
}
- $lines[] = $this->renderRadioRow($theme, $option->label, $index === $this->cursor);
+ $current = $index === $this->cursor;
+ $lines[] = $theme->radio($current) . ' ' . $this->renderMatchedLabel($theme, $option->label, $this->positionsFor($option->label), $current);
+ }
+
+ if ($viewport->has_below) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorDown());
}
return implode("\n", $lines);
}
+ /**
+ * The matched-character positions in a label under the current filter.
+ *
+ * @param string $label
+ * The option label.
+ *
+ * @return list
+ * The matched indices, or an empty list when not filtering.
+ */
+ protected function positionsFor(string $label): array {
+ return $this->filter === '' ? [] : $this->matcher()->positions($label, $this->filter);
+ }
+
}
diff --git a/src/Widget/SelectWidget.php b/src/Widget/SelectWidget.php
index 20c6804..ff5b655 100644
--- a/src/Widget/SelectWidget.php
+++ b/src/Widget/SelectWidget.php
@@ -35,11 +35,15 @@ class SelectWidget extends AbstractWidget {
* Optional validator (see AbstractWidget).
* @param \Closure|null $transform
* Optional transformer (see AbstractWidget).
+ * @param int|null $pageSize
+ * The number of option rows shown at once before the list pages; NULL uses
+ * the default.
*/
- public function __construct(array $options, string $default = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL) {
+ public function __construct(array $options, string $default = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, ?int $pageSize = NULL) {
parent::__construct($validate, $transform);
$this->initOptions($options);
$this->cursor = $this->cursorForDefault($this->options, $default);
+ $this->pageSize = $this->resolvePageSize($pageSize);
}
/**
@@ -92,7 +96,15 @@ protected function liveValue(): mixed {
public function view(ThemeInterface $theme): string {
$lines = [];
- foreach ($this->options as $index => $option) {
+ $viewport = $this->pageViewport(count($this->options), $this->cursor);
+
+ if ($viewport->has_above) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorUp());
+ }
+
+ foreach (array_slice($this->options, $viewport->offset, $this->pageSize) as $slot => $option) {
+ $index = $viewport->offset + $slot;
+
if ($option->kind === OptionKind::Heading) {
$lines[] = $this->renderHeadingRow($theme, $option);
@@ -114,6 +126,10 @@ public function view(ThemeInterface $theme): string {
$lines[] = $this->renderRadioRow($theme, $option->label, $index === $this->cursor);
}
+ if ($viewport->has_below) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorDown());
+ }
+
return implode("\n", $lines);
}
diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php
index 04f63cc..7cb0fa0 100644
--- a/src/Widget/SuggestWidget.php
+++ b/src/Widget/SuggestWidget.php
@@ -9,7 +9,7 @@
use DrevOps\Tui\Theme\ThemeInterface;
/**
- * An autocomplete text input filtering a fixed option set.
+ * An autocomplete text input fuzzy-filtering a fixed option set.
*
* @package DrevOps\Tui\Widget
*/
@@ -31,9 +31,13 @@ class SuggestWidget extends AbstractWidget {
* Optional validator (see AbstractWidget).
* @param \Closure|null $transform
* Optional transformer (see AbstractWidget).
+ * @param int|null $pageSize
+ * The number of suggestions shown at once before the list pages; NULL uses
+ * the default.
*/
- public function __construct(protected array $values, protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL) {
+ public function __construct(protected array $values, protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, ?int $pageSize = NULL) {
parent::__construct($validate, $transform);
+ $this->pageSize = $this->resolvePageSize($pageSize);
}
/**
@@ -66,38 +70,44 @@ public function handle(Key $key): void {
if ($keys->matches($key, Action::DeleteBack)) {
$this->buffer = substr($this->buffer, 0, -1);
- $this->highlight = -1;
+ $this->resetFilterCursor();
return;
}
if ($keys->matches($key, Action::InsertSpace)) {
$this->buffer .= ' ';
- $this->highlight = -1;
+ $this->resetFilterCursor();
return;
}
if ($key->isChar()) {
$this->buffer .= $key->char ?? '';
- $this->highlight = -1;
+ $this->resetFilterCursor();
}
}
/**
- * The suggestions matching the current buffer.
+ * Reset the highlight and paging when the query changes.
+ */
+ protected function resetFilterCursor(): void {
+ $this->highlight = -1;
+ $this->offset = 0;
+ }
+
+ /**
+ * The suggestions matching the current buffer, ranked by fuzzy relevance.
*
* @return list
- * The matching suggestion values.
+ * The matching suggestion values, most relevant first.
*/
protected function matches(): array {
if ($this->buffer === '') {
return $this->values;
}
- $needle = strtolower($this->buffer);
-
- return array_values(array_filter($this->values, fn(string $value): bool => str_contains(strtolower($value), $needle)));
+ return $this->matcher()->rankValues($this->values, $this->buffer);
}
/**
@@ -119,13 +129,37 @@ protected function liveValue(): mixed {
public function view(ThemeInterface $theme): string {
$lines = [$this->buffer . $theme->caret()];
- foreach ($this->matches() as $index => $value) {
+ $matches = $this->matches();
+ $viewport = $this->pageViewport(count($matches), $this->highlight);
+
+ if ($viewport->has_above) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorUp());
+ }
+
+ foreach (array_slice($matches, $viewport->offset, $this->pageSize) as $slot => $value) {
+ $index = $viewport->offset + $slot;
$current = $index === $this->highlight;
- $marker = $theme->marker($current);
- $lines[] = $marker . ' ' . $this->highlightLabel($theme, $value, $current);
+ $lines[] = $theme->marker($current) . ' ' . $this->renderMatchedLabel($theme, $value, $this->positionsFor($value), $current);
+ }
+
+ if ($viewport->has_below) {
+ $lines[] = $theme->indicator(' ' . $theme->indicatorDown());
}
return implode("\n", $lines);
}
+ /**
+ * The matched-character positions in a suggestion under the current buffer.
+ *
+ * @param string $value
+ * The suggestion value.
+ *
+ * @return list
+ * The matched indices, or an empty list when not filtering.
+ */
+ protected function positionsFor(string $value): array {
+ return $this->buffer === '' ? [] : $this->matcher()->positions($value, $this->buffer);
+ }
+
}
diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php
index fb5035c..492051c 100644
--- a/src/Widget/WidgetFactory.php
+++ b/src/Widget/WidgetFactory.php
@@ -49,11 +49,11 @@ public function create(Field $field, mixed $current): WidgetInterface {
$widget = match ($field->type) {
FieldType::Confirm => new ConfirmWidget((bool) $current),
FieldType::Toggle => new ToggleWidget($this->labels($field), is_string($current) ? $current : ''),
- FieldType::Select => new SelectWidget($field->options, is_string($current) ? $current : ''),
- FieldType::MultiSelect => new MultiSelectWidget($field->options, $this->toList($current)),
- FieldType::MultiSearch => new MultiSearchWidget($field->options, $this->toList($current)),
- FieldType::Suggest => new SuggestWidget($field->selectableValues(), is_string($current) ? $current : ''),
- FieldType::Search => new SearchWidget($field->options, is_string($current) ? $current : ''),
+ FieldType::Select => new SelectWidget($field->options, is_string($current) ? $current : '', pageSize: $field->pageSize),
+ FieldType::MultiSelect => new MultiSelectWidget($field->options, $this->toList($current), pageSize: $field->pageSize),
+ FieldType::MultiSearch => new MultiSearchWidget($field->options, $this->toList($current), pageSize: $field->pageSize),
+ FieldType::Suggest => new SuggestWidget($field->selectableValues(), is_string($current) ? $current : '', pageSize: $field->pageSize),
+ FieldType::Search => new SearchWidget($field->options, is_string($current) ? $current : '', pageSize: $field->pageSize),
FieldType::FilePicker => new FilePickerWidget($field->pickerStart, is_string($current) ? $current : '', $field->pickerMode, $field->pickerExtensions, $field->pickerShowHidden),
FieldType::MultiFilePicker => new FilePickerWidget($field->pickerStart, $this->toList($current), $field->pickerMode, $field->pickerExtensions, $field->pickerShowHidden, multiple: TRUE),
FieldType::Number => new NumberWidget(is_int($current) || is_float($current) ? (string) (int) $current : '', bounds: $field->bounds),
diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php
index 388802b..8847229 100644
--- a/tests/phpunit/Unit/Builder/FormTest.php
+++ b/tests/phpunit/Unit/Builder/FormTest.php
@@ -259,6 +259,29 @@ public function testNumberNonPositiveStepThrows(): void {
->build();
}
+ public function testPageSizeAssembled(): void {
+ $config = Form::create('T')
+ ->panel('p', 'P', function (PanelBuilder $panel): void {
+ $panel->search('paged', 'Paged')->options(['a' => 'A'])->pageSize(5);
+ $panel->search('plain', 'Plain')->options(['a' => 'A']);
+ })
+ ->build();
+
+ $this->assertSame(5, $config->field('paged')?->pageSize);
+
+ // A field with nothing declared carries no page size and uses the default.
+ $this->assertNull($config->field('plain')?->pageSize);
+ }
+
+ public function testNonPositivePageSizeThrows(): void {
+ $this->expectException(ConfigException::class);
+ $this->expectExceptionMessage('Field "n" declares a non-positive page size 0.');
+
+ Form::create('T')
+ ->panel('p', 'P', fn(PanelBuilder $p): FieldBuilder => $p->search('n')->pageSize(0))
+ ->build();
+ }
+
public function testFilePickerOptions(): void {
$config = Form::create('T')
->panel('p', 'P', function (PanelBuilder $panel): void {
diff --git a/tests/phpunit/Unit/Theme/ThemeTest.php b/tests/phpunit/Unit/Theme/ThemeTest.php
index 0872e72..3214e6c 100644
--- a/tests/phpunit/Unit/Theme/ThemeTest.php
+++ b/tests/phpunit/Unit/Theme/ThemeTest.php
@@ -30,9 +30,11 @@ public static function dataProviderStyler(): \Iterator {
yield 'dark value' => [static fn(): string => (new DefaultTheme())->value('X'), '32'];
yield 'dark indicator' => [static fn(): string => (new DefaultTheme())->indicator('X'), '1;33'];
yield 'dark border' => [static fn(): string => (new DefaultTheme())->border('X'), '36'];
+ yield 'dark match highlight' => [static fn(): string => (new DefaultTheme())->highlightMatch('X'), '1;33'];
yield 'light title' => [static fn(): string => self::light()->title('X'), '1;34'];
yield 'light indicator' => [static fn(): string => self::light()->indicator('X'), '35'];
yield 'light border' => [static fn(): string => self::light()->border('X'), '34'];
+ yield 'light match highlight' => [static fn(): string => self::light()->highlightMatch('X'), '1;35'];
// These roles are mode-independent: dimmed chrome and the red error.
yield 'description' => [static fn(): string => (new DefaultTheme())->description('X'), '90'];
yield 'error' => [static fn(): string => (new DefaultTheme())->error('X'), '31'];
diff --git a/tests/phpunit/Unit/Widget/MatcherTest.php b/tests/phpunit/Unit/Widget/MatcherTest.php
new file mode 100644
index 0000000..d20d3fc
--- /dev/null
+++ b/tests/phpunit/Unit/Widget/MatcherTest.php
@@ -0,0 +1,129 @@
+match('anything', '');
+
+ $this->assertInstanceOf(MatchResult::class, $result);
+ $this->assertSame(0, $result->score);
+ $this->assertSame([], $result->positions);
+ }
+
+ #[DataProvider('dataProviderMatchRejectsNonSubsequence')]
+ public function testMatchRejectsNonSubsequence(string $haystack, string $needle): void {
+ $result = (new Matcher())->match($haystack, $needle);
+
+ $this->assertNotInstanceOf(MatchResult::class, $result);
+ }
+
+ public static function dataProviderMatchRejectsNonSubsequence(): \Iterator {
+ yield 'missing character' => ['GitHub Actions', 'ghz'];
+ yield 'wrong order' => ['abc', 'cba'];
+ yield 'needle longer than haystack' => ['ab', 'abc'];
+ }
+
+ #[DataProvider('dataProviderTighterMatchesRankAhead')]
+ public function testTighterMatchesRankAhead(string $needle, string $stronger, string $weaker): void {
+ $matcher = new Matcher();
+
+ $strong = $matcher->match($stronger, $needle);
+ $weak = $matcher->match($weaker, $needle);
+
+ $this->assertInstanceOf(MatchResult::class, $strong);
+ $this->assertInstanceOf(MatchResult::class, $weak);
+ $this->assertGreaterThan($weak->score, $strong->score);
+ }
+
+ public static function dataProviderTighterMatchesRankAhead(): \Iterator {
+ yield 'exact over prefix' => ['lon', 'lon', 'london'];
+ yield 'prefix over substring' => ['lon', 'london', 'ceylon'];
+ yield 'substring over subsequence' => ['lon', 'ceylon', 'lemon'];
+ yield 'earlier substring over later' => ['red', 'reddit', 'shredded'];
+ yield 'contiguous over scattered' => ['abc', 'zabc', 'aXbXc'];
+ yield 'tighter but later over looser but earlier' => ['ab', 'xxxxxab', 'axxxxxxb'];
+ }
+
+ #[DataProvider('dataProviderMatchLocatesHighlightPositions')]
+ public function testMatchLocatesHighlightPositions(string $haystack, string $needle, array $expected): void {
+ $result = (new Matcher())->match($haystack, $needle);
+
+ $this->assertInstanceOf(MatchResult::class, $result);
+ $this->assertSame($expected, $result->positions);
+ }
+
+ public static function dataProviderMatchLocatesHighlightPositions(): \Iterator {
+ yield 'prefix is a contiguous run' => ['London', 'lon', [0, 1, 2]];
+ yield 'substring run at its offset' => ['CircleCI', 'ci', [0, 1]];
+ yield 'scattered subsequence indices' => ['GitHub Actions', 'gha', [0, 3, 7]];
+ yield 'multibyte substring boundary' => ['Zürich', 'ür', [1, 2]];
+ yield 'multibyte trailing character' => ['Café', 'é', [3]];
+ // The tight "abc" cluster at the end beats the greedy leftmost a-b-c.
+ yield 'tightest embedding not the greedy one' => ['axxxxxxxabyc', 'abc', [8, 9, 11]];
+ // Lowercasing "İ" expands to two code points; positions must still
+ // index the original string, so "sum" lands on original indices 2-4.
+ yield 'length-changing fold keeps original offsets' => ['İpsum', 'sum', [2, 3, 4]];
+ }
+
+ public function testRankValuesOrdersMatchesAndDropsMisses(): void {
+ $matcher = new Matcher();
+
+ $ranked = $matcher->rankValues(['Europe/London', 'Australia/Sydney', 'lon', 'Ceylon'], 'lon');
+
+ $this->assertSame(['lon', 'Europe/London', 'Ceylon'], $ranked);
+ }
+
+ public function testRankValuesKeepsInputOrderForTies(): void {
+ $matcher = new Matcher();
+
+ $ranked = $matcher->rankValues(['alpha', 'aleph'], 'al');
+
+ $this->assertSame(['alpha', 'aleph'], $ranked);
+ }
+
+ public function testRankOptionsRanksAndDropsStructuralRows(): void {
+ $matcher = new Matcher();
+
+ $options = [
+ new Option('a', 'Apple'),
+ new Option('', 'Fruits', '', OptionKind::Heading),
+ new Option('p', 'Pineapple'),
+ new Option('', '', '', OptionKind::Separator),
+ new Option('g', 'Grape'),
+ ];
+
+ $ranked = $matcher->rankOptions($options, 'apple');
+
+ $values = array_map(static fn(Option $option): string => $option->value, $ranked);
+ $this->assertSame(['a', 'p'], $values);
+ }
+
+ public function testPositionsConvenienceReturnsEmptyOnMiss(): void {
+ $matcher = new Matcher();
+
+ $this->assertSame([0, 1, 2], $matcher->positions('London', 'lon'));
+ $this->assertSame([], $matcher->positions('London', 'xyz'));
+ }
+
+}
diff --git a/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
index 749ebc6..30d0d3d 100644
--- a/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
+++ b/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
@@ -54,9 +54,9 @@ public function testViewShowsQueryLineAboveOptions(): void {
$widget = new MultiSearchWidget($this->labels);
$widget->handle(Key::char('r'));
- $view = $widget->view(new DefaultTheme());
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
- $this->assertStringContainsString("r█\n", Ansi::strip($view));
+ $this->assertStringContainsString("r█\n", $view);
$this->assertStringContainsString('Redis', $view);
$this->assertStringNotContainsString('ClamAV', $view);
}
@@ -93,4 +93,51 @@ public function testRendersKindsBelowQueryLine(): void {
$this->assertStringContainsString('──', $view);
}
+ public function testFuzzyMatchesNonContiguousSubsequence(): void {
+ $widget = new MultiSearchWidget(['banana' => 'Banana', 'apple' => 'Apple', 'cherry' => 'Cherry']);
+
+ // "bn" is not a substring of any label but is a subsequence of "Banana".
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of('bn', Key::named(KeyName::Space), Key::named(KeyName::Enter)));
+
+ $this->assertSame(['banana'], $value);
+ }
+
+ public function testHighlightsMatchedCharacters(): void {
+ $theme = new DefaultTheme();
+ $widget = new MultiSearchWidget(['banana' => 'Banana']);
+
+ $widget->handle(Key::char('b'));
+ $widget->handle(Key::char('n'));
+ $view = $widget->view($theme);
+
+ // The non-contiguous match highlights each hit character on its own,
+ // leaving the intervening characters unstyled.
+ $this->assertStringContainsString($theme->highlightMatch('B'), $view);
+ $this->assertStringContainsString($theme->highlightMatch('n'), $view);
+ $this->assertStringContainsString('Banana', Ansi::strip($view));
+ }
+
+ public function testPagesLongOptionList(): void {
+ $widget = new MultiSearchWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry', 'd' => 'Date'], pageSize: 2);
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Apple', $view);
+ $this->assertStringContainsString('Banana', $view);
+ $this->assertStringNotContainsString('Cherry', $view);
+ $this->assertStringContainsString('▼', $view);
+ }
+
+ public function testPagingFollowsCursorDownTheList(): void {
+ $widget = new MultiSearchWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry', 'd' => 'Date'], pageSize: 2);
+
+ $widget->handle(Key::named(KeyName::Down));
+ $widget->handle(Key::named(KeyName::Down));
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Cherry', $view);
+ $this->assertStringContainsString('▲', $view);
+ $this->assertStringNotContainsString('Apple', $view);
+ }
+
}
diff --git a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
index 88c29ad..a14a9f6 100644
--- a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
+++ b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
@@ -218,4 +218,43 @@ public function testRendersHeadingSeparatorAndDisabled(): void {
$this->assertStringContainsString('──', $view);
}
+ public function testFilterStaysSubstringNotFuzzy(): void {
+ $widget = new MultiSelectWidget(['banana' => 'Banana', 'apple' => 'Apple']);
+
+ // "bn" is a subsequence of "Banana" but not a substring, so the checkbox
+ // list - which stays substring-only - narrows it away.
+ $widget->handle(Key::char('b'));
+ $widget->handle(Key::char('n'));
+
+ $this->assertStringNotContainsString('Banana', Ansi::strip($widget->view(new DefaultTheme())));
+ }
+
+ public function testRejectsNonPositivePageSize(): void {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Page size must be a positive integer, -3 given.');
+
+ new MultiSelectWidget(['a' => 'A'], pageSize: -3);
+ }
+
+ public function testPagesLongOptionList(): void {
+ $widget = new MultiSelectWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry', 'd' => 'Date'], pageSize: 2);
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Apple', $view);
+ $this->assertStringContainsString('Banana', $view);
+ $this->assertStringNotContainsString('Cherry', $view);
+ $this->assertStringContainsString('▼', $view);
+
+ $widget->handle(Key::named(KeyName::Down));
+ $widget->handle(Key::named(KeyName::Down));
+ $scrolled = Ansi::strip($widget->view(new DefaultTheme()));
+
+ // The window has followed the cursor down, so the "more above"
+ // indicator now shows and the first option has scrolled off.
+ $this->assertStringContainsString('Cherry', $scrolled);
+ $this->assertStringContainsString('▲', $scrolled);
+ $this->assertStringNotContainsString('Apple', $scrolled);
+ }
+
}
diff --git a/tests/phpunit/Unit/Widget/SearchWidgetTest.php b/tests/phpunit/Unit/Widget/SearchWidgetTest.php
index af16eb8..7ce2e05 100644
--- a/tests/phpunit/Unit/Widget/SearchWidgetTest.php
+++ b/tests/phpunit/Unit/Widget/SearchWidgetTest.php
@@ -87,9 +87,9 @@ public function testViewShowsQueryAndVisibleOptions(): void {
$widget = new SearchWidget($this->labels);
$widget->handle(Key::char('c'));
- $view = $widget->view(new DefaultTheme());
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
- $this->assertStringContainsString('c█', Ansi::strip($view));
+ $this->assertStringContainsString('c█', $view);
$this->assertStringContainsString('CircleCI', $view);
$this->assertStringNotContainsString('None', $view);
}
@@ -171,4 +171,64 @@ public function testRendersHeadingSeparatorAndDisabled(): void {
$this->assertStringContainsString('──', $view);
}
+ public function testFuzzyMatchesNonContiguousSubsequence(): void {
+ $widget = new SearchWidget(['gha' => 'GitHub Actions', 'gitlab' => 'GitLab CI', 'circle' => 'CircleCI']);
+
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of('gha', Key::named(KeyName::Enter)));
+
+ $this->assertSame('gha', $value);
+ }
+
+ public function testRanksPrefixAheadOfLooserSubsequence(): void {
+ $widget = new SearchWidget(['alpha' => 'Alpha', 'beta' => 'Beta', 'palace' => 'Palace']);
+
+ // "pa" prefixes Palace but only scatters through Alpha, so Palace ranks
+ // first and the cursor lands on it even though Alpha is declared earlier.
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of('pa', Key::named(KeyName::Enter)));
+
+ $this->assertSame('palace', $value);
+ }
+
+ public function testHighlightsMatchedCharacters(): void {
+ $theme = new DefaultTheme();
+ $widget = new SearchWidget(['palace' => 'Palace', 'alpha' => 'Alpha']);
+
+ $widget->handle(Key::char('p'));
+ $widget->handle(Key::char('a'));
+ $view = $widget->view($theme);
+
+ $this->assertStringContainsString($theme->highlightMatch('Pa'), $view);
+ $this->assertStringContainsString('Palace', Ansi::strip($view));
+ }
+
+ public function testRejectsNonPositivePageSize(): void {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Page size must be a positive integer, -2 given.');
+
+ new SearchWidget(['a' => 'A'], pageSize: -2);
+ }
+
+ public function testPagesLongOptionList(): void {
+ $widget = new SearchWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry', 'd' => 'Date'], pageSize: 2);
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Apple', $view);
+ $this->assertStringContainsString('Banana', $view);
+ $this->assertStringNotContainsString('Cherry', $view);
+ $this->assertStringContainsString('▼', $view);
+ }
+
+ public function testPagingFollowsCursorDownTheList(): void {
+ $widget = new SearchWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry', 'd' => 'Date'], pageSize: 2);
+
+ $widget->handle(Key::named(KeyName::Down));
+ $widget->handle(Key::named(KeyName::Down));
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Cherry', $view);
+ $this->assertStringContainsString('▲', $view);
+ $this->assertStringNotContainsString('Apple', $view);
+ }
+
}
diff --git a/tests/phpunit/Unit/Widget/SelectWidgetTest.php b/tests/phpunit/Unit/Widget/SelectWidgetTest.php
index 34f69ea..5f05895 100644
--- a/tests/phpunit/Unit/Widget/SelectWidgetTest.php
+++ b/tests/phpunit/Unit/Widget/SelectWidgetTest.php
@@ -137,4 +137,30 @@ public function testNoSelectableRowYieldsNoValue(): void {
$this->assertSame('', $widget->value());
}
+ public function testRejectsNonPositivePageSize(): void {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Page size must be a positive integer, 0 given.');
+
+ new SelectWidget(['a' => 'A'], pageSize: 0);
+ }
+
+ public function testPagesLongOptionList(): void {
+ $widget = new SelectWidget(['a' => 'Apple', 'b' => 'Banana', 'c' => 'Cherry', 'd' => 'Date'], pageSize: 2);
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Apple', $view);
+ $this->assertStringContainsString('Banana', $view);
+ $this->assertStringNotContainsString('Cherry', $view);
+ $this->assertStringContainsString('▼', $view);
+
+ $widget->handle(Key::named(KeyName::Down));
+ $widget->handle(Key::named(KeyName::Down));
+ $scrolled = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('Cherry', $scrolled);
+ $this->assertStringContainsString('▲', $scrolled);
+ $this->assertStringNotContainsString('Apple', $scrolled);
+ }
+
}
diff --git a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php
index 4e0222f..2a7c55a 100644
--- a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php
+++ b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php
@@ -7,6 +7,7 @@
use DrevOps\Tui\Input\ArrayKeyStream;
use DrevOps\Tui\Input\Key;
use DrevOps\Tui\Input\KeyName;
+use DrevOps\Tui\Render\Ansi;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Widget\AbstractWidget;
use DrevOps\Tui\Widget\SuggestWidget;
@@ -37,8 +38,8 @@ public function testNarrowsAndSelectsSuggestion(): void {
$widget->handle(Key::char('l'));
$widget->handle(Key::char('o'));
$widget->handle(Key::char('n'));
- $this->assertStringContainsString('Europe/London', $widget->view(new DefaultTheme()));
- $this->assertStringNotContainsString('Australia/Sydney', $widget->view(new DefaultTheme()));
+ $this->assertStringContainsString('Europe/London', Ansi::strip($widget->view(new DefaultTheme())));
+ $this->assertStringNotContainsString('Australia/Sydney', Ansi::strip($widget->view(new DefaultTheme())));
$value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::named(KeyName::Down), Key::named(KeyName::Enter)));
@@ -85,4 +86,72 @@ public function testSpaceAppendsToBuffer(): void {
$this->assertSame('a ', $widget->value());
}
+ public function testFuzzyMatchesNonContiguousSubsequence(): void {
+ $widget = new SuggestWidget(['GitHub Actions', 'GitLab CI', 'CircleCI']);
+
+ $value = WidgetRunner::run($widget, ArrayKeyStream::of('gha', Key::named(KeyName::Down), Key::named(KeyName::Enter)));
+
+ $this->assertSame('GitHub Actions', $value);
+ }
+
+ public function testRanksPrefixAheadOfLooserSubsequence(): void {
+ $widget = new SuggestWidget(['Alpha', 'Beta', 'Palace']);
+
+ // "pa" is a prefix of Palace but only a scattered subsequence of Alpha, so
+ // Palace ranks first and the first Down lands on it.
+ $widget->handle(Key::char('p'));
+ $widget->handle(Key::char('a'));
+ $widget->handle(Key::named(KeyName::Down));
+
+ $this->assertSame('Palace', $widget->value());
+ }
+
+ public function testHighlightsMatchedCharacters(): void {
+ $theme = new DefaultTheme();
+ $widget = new SuggestWidget(['Alpha', 'Beta', 'Palace']);
+
+ $widget->handle(Key::char('p'));
+ $widget->handle(Key::char('a'));
+ $view = $widget->view($theme);
+
+ // The matched "Pa" prefix is themed as a match run; the label is intact
+ // once the styling is stripped.
+ $this->assertStringContainsString($theme->highlightMatch('Pa'), $view);
+ $this->assertStringContainsString('Palace', Ansi::strip($view));
+ }
+
+ public function testRejectsNonPositivePageSize(): void {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Page size must be a positive integer, 0 given.');
+
+ new SuggestWidget(['x'], pageSize: 0);
+ }
+
+ public function testPagesLongSuggestionList(): void {
+ $widget = new SuggestWidget(['one', 'two', 'three', 'four', 'five'], pageSize: 2);
+
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ $this->assertStringContainsString('one', $view);
+ $this->assertStringContainsString('two', $view);
+ $this->assertStringNotContainsString('three', $view);
+ $this->assertStringContainsString('▼', $view);
+ }
+
+ public function testPagingFollowsHighlightDownTheList(): void {
+ $widget = new SuggestWidget(['one', 'two', 'three', 'four', 'five'], pageSize: 2);
+
+ $widget->handle(Key::named(KeyName::Down));
+ $widget->handle(Key::named(KeyName::Down));
+ $widget->handle(Key::named(KeyName::Down));
+ $view = Ansi::strip($widget->view(new DefaultTheme()));
+
+ // The window has scrolled to keep the third item ("three") in view and
+ // shows both scroll indicators.
+ $this->assertStringContainsString('three', $view);
+ $this->assertStringContainsString('▲', $view);
+ $this->assertStringContainsString('▼', $view);
+ $this->assertStringNotContainsString('one', $view);
+ }
+
}
diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
index 5a992a0..c37f989 100644
--- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
+++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php
@@ -12,6 +12,7 @@
use DrevOps\Tui\Input\Key;
use DrevOps\Tui\Input\KeyMapManager;
use DrevOps\Tui\Input\KeyName;
+use DrevOps\Tui\Render\Ansi;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Widget\ConfirmWidget;
use DrevOps\Tui\Widget\FilePickerWidget;
@@ -156,6 +157,18 @@ public function testInjectsScopedKeymapIntoWidget(): void {
$this->assertSame('b', $widget->value());
}
+ public function testPageSizePassedThrough(): void {
+ $options = ['a' => new Option('a', 'A'), 'b' => new Option('b', 'B'), 'c' => new Option('c', 'C')];
+ $field = new Field('f', 'F', '', FieldType::Select, '', $options, pageSize: 2);
+
+ $view = (new WidgetFactory())->create($field, 'a')->view(new DefaultTheme());
+
+ // A page size of 2 over three options hides the last one and shows the
+ // "more below" indicator, proving the field's page size reached the widget.
+ $this->assertStringContainsString('▼', $view);
+ $this->assertStringNotContainsString('C', Ansi::strip($view));
+ }
+
public function testSuggestReceivesSelectableValuesOnly(): void {
$field = new Field('tz', 'TZ', '', FieldType::Suggest, '', [
new Option('utc', 'UTC'),