From a835c5530c85bc7092674c46216bc0f855545ddc Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:35:37 +1000 Subject: [PATCH 1/6] [#7] Added fuzzy matching, highlighting and paging to choice widgets. Replaces exact-substring type-to-filter in the 'suggest', 'search' and 'multisearch' widgets with ranked fuzzy (subsequence) matching via a new stateless 'Matcher': matches are scored in tiers (exact, prefix, substring, subsequence) so tighter matches always outrank looser ones, and each match reports the label character indices it hit. The hit characters are highlighted through a new theme-driven 'highlightMatch' atom, composed run-by-run so it reads on both a plain and a cursor row and strips cleanly in Unicode and ASCII modes. Adds a configurable '->pageSize()' that bounds the visible option list: the select, multiselect, suggest, search and multisearch widgets now page a cursor-following window with scroll indicators instead of overflowing the editor. The multiselect filter stays substring-only. Covered by unit tests for ranking, highlight boundaries and paging. --- README.md | 12 +- src/Builder/FieldBuilder.php | 32 +++ src/Config/Field.php | 5 + src/Theme/DefaultTheme.php | 7 + src/Theme/ThemeInterface.php | 5 + src/Widget/AbstractWidget.php | 118 +++++++++ src/Widget/MatchResult.php | 34 +++ src/Widget/Matcher.php | 233 ++++++++++++++++++ src/Widget/MultiSearchWidget.php | 18 +- src/Widget/MultiSelectWidget.php | 73 +++++- src/Widget/SearchWidget.php | 62 ++++- src/Widget/SelectWidget.php | 20 +- src/Widget/SuggestWidget.php | 48 +++- src/Widget/WidgetFactory.php | 10 +- tests/phpunit/Unit/Builder/FormTest.php | 23 ++ tests/phpunit/Unit/Theme/ThemeTest.php | 2 + tests/phpunit/Unit/Widget/MatcherTest.php | 119 +++++++++ .../Unit/Widget/MultiSearchWidgetTest.php | 39 ++- .../Unit/Widget/MultiSelectWidgetTest.php | 32 +++ .../phpunit/Unit/Widget/SearchWidgetTest.php | 57 ++++- .../phpunit/Unit/Widget/SelectWidgetTest.php | 19 ++ .../phpunit/Unit/Widget/SuggestWidgetTest.php | 66 ++++- .../phpunit/Unit/Widget/WidgetFactoryTest.php | 13 + 23 files changed, 993 insertions(+), 54 deletions(-) create mode 100644 src/Widget/MatchResult.php create mode 100644 src/Widget/Matcher.php create mode 100644 tests/phpunit/Unit/Widget/MatcherTest.php 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..15a0e47 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,102 @@ 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(); + } + + /** + * Compute the paging window that keeps the cursor visible, 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/Matcher.php b/src/Widget/Matcher.php new file mode 100644 index 0000000..04e82c2 --- /dev/null +++ b/src/Widget/Matcher.php @@ -0,0 +1,233 @@ + $char) { + if ($needle_index < $needle_count && $char === $needle_chars[$needle_index]) { + $positions[] = $index; + $needle_index++; + } + } + + if ($needle_index < $needle_count) { + return NULL; + } + + $tier = $this->tier($lower_haystack, $lower_needle); + + // A prefix, substring or exact hit is one contiguous run, so highlight that + // run rather than the greedily-collected subsequence indices. + if ($tier >= 2) { + $start = mb_strpos($lower_haystack, $lower_needle); + if ($start !== FALSE) { + $positions = range($start, $start + $needle_count - 1); + } + } + + return new MatchResult($tier * self::TIER_WEIGHT + $this->refine($positions, $haystack_chars), $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 { + return $this->match($haystack, $needle)?->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 $a, int $b): int => $b <=> $a); + + $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 $a, int $b): int => $b <=> $a); + + $ranked = []; + foreach (array_keys($scores) as $index) { + $ranked[] = $options[$index]; + } + + return $ranked; + } + + /** + * The match tier: exact (4), prefix (3), substring (2) or subsequence (1). + * + * @param string $haystack + * The lowercased candidate. + * @param string $needle + * The lowercased query. + * + * @return int + * The tier. + */ + protected function tier(string $haystack, string $needle): int { + if ($haystack === $needle) { + return 4; + } + + if (str_starts_with($haystack, $needle)) { + return 3; + } + + return str_contains($haystack, $needle) ? 2 : 1; + } + + /** + * The within-tier refinement, bounded below the tier weight. + * + * Rewards a match that starts early, sits at a word boundary and runs + * contiguously, so the more intuitive hit ranks first among same-tier peers. + * + * @param list $positions + * The matched indices, ascending. + * @param list $haystack_chars + * The lowercased candidate characters. + * + * @return int + * A refinement in the range 0..TIER_WEIGHT-1. + */ + protected function refine(array $positions, array $haystack_chars): int { + $first = $positions[0]; + $span = $positions[count($positions) - 1] - $first; + $gaps = $span - (count($positions) - 1); + $boundary = $this->isWordStart($first, $haystack_chars) ? 50 : 0; + + return max(0, min(self::TIER_WEIGHT - 1, 500 - $first * 10 - $gaps * 20 + $boundary)); + } + + /** + * 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 lowercased 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..3363164 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 = $pageSize ?? self::DEFAULT_PAGE_SIZE; $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 rewind paging when the query changes. + */ + 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 = strtolower($needle); + + return array_values(array_filter($this->options, static fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(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..26beeb6 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 = $pageSize ?? self::DEFAULT_PAGE_SIZE; } /** @@ -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 rewind paging when the query changes. + */ + 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..706097a 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 = $pageSize ?? self::DEFAULT_PAGE_SIZE; } /** @@ -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..e2f48a4 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 = $pageSize ?? self::DEFAULT_PAGE_SIZE; } /** @@ -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,10 +129,22 @@ 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); + $positions = $this->buffer === '' ? [] : $this->matcher()->positions($value, $this->buffer); + $lines[] = $theme->marker($current) . ' ' . $this->renderMatchedLabel($theme, $value, $positions, $current); + } + + if ($viewport->has_below) { + $lines[] = $theme->indicator(' ' . $theme->indicatorDown()); } return implode("\n", $lines); 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..67c66b1 --- /dev/null +++ b/tests/phpunit/Unit/Widget/MatcherTest.php @@ -0,0 +1,119 @@ +match('anything', ''); + + $this->assertInstanceOf(MatchResult::class, $result); + $this->assertSame(0, $result->score); + $this->assertSame([], $result->positions); + } + + #[DataProvider('dataProviderNoMatch')] + public function testMatchReturnsNullWhenNotASubsequence(string $haystack, string $needle): void { + $this->assertNull((new Matcher())->match($haystack, $needle)); + } + + public static function dataProviderNoMatch(): \Iterator { + yield 'missing character' => ['GitHub Actions', 'ghz']; + yield 'wrong order' => ['abc', 'cba']; + yield 'needle longer than haystack' => ['ab', 'abc']; + } + + #[DataProvider('dataProviderTierOrder')] + 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 dataProviderTierOrder(): \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']; + } + + #[DataProvider('dataProviderPositions')] + 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 dataProviderPositions(): \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]]; + } + + 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..a5024c7 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,39 @@ 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); + } + } diff --git a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php index 88c29ad..40713f9 100644 --- a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php @@ -218,4 +218,36 @@ 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 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..17cc05c 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,57 @@ 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 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..440bdd8 100644 --- a/tests/phpunit/Unit/Widget/SelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SelectWidgetTest.php @@ -137,4 +137,23 @@ public function testNoSelectableRowYieldsNoValue(): void { $this->assertSame('', $widget->value()); } + 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..9aa5e91 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,65 @@ 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 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'), From 7819bf633e1b90ca0d6d95ea098889190579795a Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:51:51 +1000 Subject: [PATCH 2/6] [#7] Selected the best-scoring fuzzy embedding and fixed multibyte offsets. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The greedy leftmost subsequence could return a looser embedding than one that existed later in the label, and because the score penalises gaps, the reported positions could score below an alternative the ranking would prefer - so the score and the highlighted characters disagreed. A small O(candidate * needle) dynamic program now returns the embedding the refinement rates highest. Matching also folded the whole string at once, so a case-fold that changes length (for example 'İ' becoming two code points) desynced the matched indices from the original-string offsets used for highlighting. Folding each code point on its own keeps every position mapped to the original string. --- src/Widget/Matcher.php | 132 +++++++++++++++------- tests/phpunit/Unit/Widget/MatcherTest.php | 6 + 2 files changed, 96 insertions(+), 42 deletions(-) diff --git a/src/Widget/Matcher.php b/src/Widget/Matcher.php index 04e82c2..ae5a2d2 100644 --- a/src/Widget/Matcher.php +++ b/src/Widget/Matcher.php @@ -45,37 +45,20 @@ public function match(string $haystack, string $needle): ?MatchResult { return new MatchResult(0, []); } - $lower_haystack = mb_strtolower($haystack); - $lower_needle = mb_strtolower($needle); - $haystack_chars = mb_str_split($lower_haystack); - $needle_chars = mb_str_split($lower_needle); - $needle_count = count($needle_chars); + // Fold each code point on its own, so a matched index maps straight back to + // the original string even when lowercasing changes a character's length. + $chars = mb_str_split($haystack); + $folded = array_map(static fn(string $char): string => mb_strtolower($char), $chars); + $needle_folded = array_map(static fn(string $char): string => mb_strtolower($char), mb_str_split($needle)); - $positions = []; - $needle_index = 0; - foreach ($haystack_chars as $index => $char) { - if ($needle_index < $needle_count && $char === $needle_chars[$needle_index]) { - $positions[] = $index; - $needle_index++; - } - } - - if ($needle_index < $needle_count) { + $best = $this->bestSubsequence($folded, $needle_folded, $chars); + if ($best === NULL) { return NULL; } - $tier = $this->tier($lower_haystack, $lower_needle); - - // A prefix, substring or exact hit is one contiguous run, so highlight that - // run rather than the greedily-collected subsequence indices. - if ($tier >= 2) { - $start = mb_strpos($lower_haystack, $lower_needle); - if ($start !== FALSE) { - $positions = range($start, $start + $needle_count - 1); - } - } + [$positions, $refinement] = $best; - return new MatchResult($tier * self::TIER_WEIGHT + $this->refine($positions, $haystack_chars), $positions); + return new MatchResult($this->tier(mb_strtolower($haystack), mb_strtolower($needle)) * self::TIER_WEIGHT + $refinement, $positions); } /** @@ -189,26 +172,91 @@ protected function tier(string $haystack, string $needle): int { } /** - * The within-tier refinement, bounded below the tier weight. + * The best-scoring ordered embedding of the needle in the candidate. * - * Rewards a match that starts early, sits at a word boundary and runs - * contiguously, so the more intuitive hit ranks first among same-tier peers. + * 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 $positions - * The matched indices, ascending. - * @param list $haystack_chars - * The lowercased candidate characters. + * @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 int - * A refinement in the range 0..TIER_WEIGHT-1. + * @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 refine(array $positions, array $haystack_chars): int { - $first = $positions[0]; - $span = $positions[count($positions) - 1] - $first; - $gaps = $span - (count($positions) - 1); - $boundary = $this->isWordStart($first, $haystack_chars) ? 50 : 0; + 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 max(0, min(self::TIER_WEIGHT - 1, 500 - $first * 10 - $gaps * 20 + $boundary)); + return [array_reverse($positions), max(0, min(self::TIER_WEIGHT - 1, 500 + $best))]; } /** @@ -217,7 +265,7 @@ protected function refine(array $positions, array $haystack_chars): int { * @param int $index * The index into the candidate. * @param list $haystack_chars - * The lowercased candidate characters. + * The candidate characters. * * @return bool * TRUE when the index starts a word. diff --git a/tests/phpunit/Unit/Widget/MatcherTest.php b/tests/phpunit/Unit/Widget/MatcherTest.php index 67c66b1..60f69af 100644 --- a/tests/phpunit/Unit/Widget/MatcherTest.php +++ b/tests/phpunit/Unit/Widget/MatcherTest.php @@ -58,6 +58,7 @@ public static function dataProviderTierOrder(): \Iterator { 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('dataProviderPositions')] @@ -74,6 +75,11 @@ public static function dataProviderPositions(): \Iterator { 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 the original indices 2, 3 and 4. + yield 'length-changing fold keeps original offsets' => ['İpsum', 'sum', [2, 3, 4]]; } public function testRankValuesOrdersMatchesAndDropsMisses(): void { From 6e2819ba80bf540504cf9fdd7f711e878c65d677 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:52:01 +1000 Subject: [PATCH 3/6] [#7] Made the checkbox filter multibyte-aware and tidied suggestion rendering. The multiselect substring filter now folds case with 'mb_strtolower' so it matches multibyte labels consistently with the fuzzy widgets. The suggest widget's inline match-position lookup moves into a 'positionsFor' helper, mirroring the search widget, and the multi-search paging test now also covers scrolling the window down. --- src/Widget/MultiSelectWidget.php | 4 ++-- src/Widget/SuggestWidget.php | 16 ++++++++++++++-- .../Unit/Widget/MultiSearchWidgetTest.php | 12 ++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php index 3363164..02794df 100644 --- a/src/Widget/MultiSelectWidget.php +++ b/src/Widget/MultiSelectWidget.php @@ -178,9 +178,9 @@ protected function visible(): array { * The matching option rows. */ protected function filterOptions(string $needle): array { - $lower = strtolower($needle); + $lower = mb_strtolower($needle); - return array_values(array_filter($this->options, static fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $lower))); + return array_values(array_filter($this->options, static fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(mb_strtolower($option->label), $lower))); } /** diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php index e2f48a4..b04fdbe 100644 --- a/src/Widget/SuggestWidget.php +++ b/src/Widget/SuggestWidget.php @@ -139,8 +139,7 @@ public function view(ThemeInterface $theme): string { foreach (array_slice($matches, $viewport->offset, $this->pageSize) as $slot => $value) { $index = $viewport->offset + $slot; $current = $index === $this->highlight; - $positions = $this->buffer === '' ? [] : $this->matcher()->positions($value, $this->buffer); - $lines[] = $theme->marker($current) . ' ' . $this->renderMatchedLabel($theme, $value, $positions, $current); + $lines[] = $theme->marker($current) . ' ' . $this->renderMatchedLabel($theme, $value, $this->positionsFor($value), $current); } if ($viewport->has_below) { @@ -150,4 +149,17 @@ public function view(ThemeInterface $theme): string { 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/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php index a5024c7..30d0d3d 100644 --- a/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php +++ b/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php @@ -128,4 +128,16 @@ public function testPagesLongOptionList(): void { $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); + } + } From 23ef8449eb1de205bd6fb3e13121f50040b7049b Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 10:05:58 +1000 Subject: [PATCH 4/6] [#7] Applied coding-standard fixes to the fuzzy matching changes. Renamed the 'Matcher' test data providers to match their test method names, wrapped docblock and comment lines at 80 characters, adopted the first-class callable form for the folding map, narrowed the nullable match result with 'instanceof', and asserted a missed match with 'assertNotInstanceOf'. --- src/Widget/AbstractWidget.php | 2 +- src/Widget/Matcher.php | 24 ++++++++++--------- src/Widget/MultiSelectWidget.php | 2 +- src/Widget/SearchWidget.php | 2 +- tests/phpunit/Unit/Widget/MatcherTest.php | 22 +++++++++-------- .../Unit/Widget/MultiSelectWidgetTest.php | 4 ++-- .../phpunit/Unit/Widget/SuggestWidgetTest.php | 4 ++-- 7 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php index 15a0e47..4ffcac0 100644 --- a/src/Widget/AbstractWidget.php +++ b/src/Widget/AbstractWidget.php @@ -222,7 +222,7 @@ protected function matcher(): Matcher { } /** - * Compute the paging window that keeps the cursor visible, storing its offset. + * Compute the cursor-visible paging window, storing its offset. * * @param int $total * The total number of option rows. diff --git a/src/Widget/Matcher.php b/src/Widget/Matcher.php index ae5a2d2..ba4393b 100644 --- a/src/Widget/Matcher.php +++ b/src/Widget/Matcher.php @@ -48,8 +48,8 @@ public function match(string $haystack, string $needle): ?MatchResult { // Fold each code point on its own, so a matched index maps straight back to // the original string even when lowercasing changes a character's length. $chars = mb_str_split($haystack); - $folded = array_map(static fn(string $char): string => mb_strtolower($char), $chars); - $needle_folded = array_map(static fn(string $char): string => mb_strtolower($char), mb_str_split($needle)); + $folded = array_map(mb_strtolower(...), $chars); + $needle_folded = array_map(mb_strtolower(...), mb_str_split($needle)); $best = $this->bestSubsequence($folded, $needle_folded, $chars); if ($best === NULL) { @@ -73,7 +73,9 @@ public function match(string $haystack, string $needle): ?MatchResult { * The matched indices. */ public function positions(string $haystack, string $needle): array { - return $this->match($haystack, $needle)?->positions ?? []; + $result = $this->match($haystack, $needle); + + return $result instanceof MatchResult ? $result->positions : []; } /** @@ -175,12 +177,12 @@ protected function tier(string $haystack, string $needle): int { * 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. + * 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. @@ -190,8 +192,8 @@ protected function tier(string $haystack, string $needle): int { * 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. + * 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); diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php index 02794df..8e78b8e 100644 --- a/src/Widget/MultiSelectWidget.php +++ b/src/Widget/MultiSelectWidget.php @@ -140,7 +140,7 @@ public function handle(Key $key): void { } /** - * Land the cursor on the first match and rewind paging when the query changes. + * Land the cursor on the first match and reset paging on a query change. */ protected function resetFilterCursor(): void { $this->cursor = $this->firstSelectable($this->visible()); diff --git a/src/Widget/SearchWidget.php b/src/Widget/SearchWidget.php index 26beeb6..c21260d 100644 --- a/src/Widget/SearchWidget.php +++ b/src/Widget/SearchWidget.php @@ -102,7 +102,7 @@ public function handle(Key $key): void { } /** - * Land the cursor on the first match and rewind paging when the query changes. + * Land the cursor on the first match and reset paging on a query change. */ protected function resetFilterCursor(): void { $this->cursor = $this->firstSelectable($this->visible()); diff --git a/tests/phpunit/Unit/Widget/MatcherTest.php b/tests/phpunit/Unit/Widget/MatcherTest.php index 60f69af..c5f448a 100644 --- a/tests/phpunit/Unit/Widget/MatcherTest.php +++ b/tests/phpunit/Unit/Widget/MatcherTest.php @@ -29,18 +29,20 @@ public function testEmptyNeedleMatchesWithZeroScore(): void { $this->assertSame([], $result->positions); } - #[DataProvider('dataProviderNoMatch')] - public function testMatchReturnsNullWhenNotASubsequence(string $haystack, string $needle): void { - $this->assertNull((new Matcher())->match($haystack, $needle)); + #[DataProvider('dataProviderMatchRejectsNonSubsequence')] + public function testMatchRejectsNonSubsequence(string $haystack, string $needle): void { + $result = (new Matcher())->match($haystack, $needle); + + $this->assertNotInstanceOf(MatchResult::class, $result); } - public static function dataProviderNoMatch(): \Iterator { + public static function dataProviderMatchRejectsNonSubsequence(): \Iterator { yield 'missing character' => ['GitHub Actions', 'ghz']; yield 'wrong order' => ['abc', 'cba']; yield 'needle longer than haystack' => ['ab', 'abc']; } - #[DataProvider('dataProviderTierOrder')] + #[DataProvider('dataProviderTighterMatchesRankAhead')] public function testTighterMatchesRankAhead(string $needle, string $stronger, string $weaker): void { $matcher = new Matcher(); @@ -52,7 +54,7 @@ public function testTighterMatchesRankAhead(string $needle, string $stronger, st $this->assertGreaterThan($weak->score, $strong->score); } - public static function dataProviderTierOrder(): \Iterator { + 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']; @@ -61,7 +63,7 @@ public static function dataProviderTierOrder(): \Iterator { yield 'tighter but later over looser but earlier' => ['ab', 'xxxxxab', 'axxxxxxb']; } - #[DataProvider('dataProviderPositions')] + #[DataProvider('dataProviderMatchLocatesHighlightPositions')] public function testMatchLocatesHighlightPositions(string $haystack, string $needle, array $expected): void { $result = (new Matcher())->match($haystack, $needle); @@ -69,7 +71,7 @@ public function testMatchLocatesHighlightPositions(string $haystack, string $nee $this->assertSame($expected, $result->positions); } - public static function dataProviderPositions(): \Iterator { + 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]]; @@ -77,8 +79,8 @@ public static function dataProviderPositions(): \Iterator { 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 the original indices 2, 3 and 4. + // 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]]; } diff --git a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php index 40713f9..a85718e 100644 --- a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php @@ -243,8 +243,8 @@ public function testPagesLongOptionList(): void { $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. + // 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/SuggestWidgetTest.php b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php index 9aa5e91..06084cb 100644 --- a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php @@ -114,8 +114,8 @@ public function testHighlightsMatchedCharacters(): void { $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. + // 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)); } From 15d96331e65249d579c896c8feef840e22a148a8 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 10:26:12 +1000 Subject: [PATCH 5/6] [#7] Modeled match tiers as an enum and rejected non-positive page sizes. Introduced a 'MatchTier' enum (exact, prefix, substring, subsequence) with a 'weight()' ranking multiplier, so the closed set of tiers is typed rather than raw integers. Guarded the choice-widget constructors against a non-positive page size with a shared 'resolvePageSize()' that throws before an empty paging window can render, matching the builder's own check, and covered it with tests. Renamed the sort comparator parameters for clarity. --- src/Widget/AbstractWidget.php | 23 ++++++++++ src/Widget/MatchTier.php | 44 +++++++++++++++++++ src/Widget/Matcher.php | 18 ++++---- src/Widget/MultiSelectWidget.php | 2 +- src/Widget/SearchWidget.php | 2 +- src/Widget/SelectWidget.php | 2 +- src/Widget/SuggestWidget.php | 2 +- tests/phpunit/Unit/Widget/MatcherTest.php | 2 + .../Unit/Widget/MultiSelectWidgetTest.php | 7 +++ .../phpunit/Unit/Widget/SelectWidgetTest.php | 7 +++ 10 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 src/Widget/MatchTier.php diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php index 4ffcac0..bcda540 100644 --- a/src/Widget/AbstractWidget.php +++ b/src/Widget/AbstractWidget.php @@ -221,6 +221,29 @@ 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. * 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 index ba4393b..1bb6a32 100644 --- a/src/Widget/Matcher.php +++ b/src/Widget/Matcher.php @@ -58,7 +58,7 @@ public function match(string $haystack, string $needle): ?MatchResult { [$positions, $refinement] = $best; - return new MatchResult($this->tier(mb_strtolower($haystack), mb_strtolower($needle)) * self::TIER_WEIGHT + $refinement, $positions); + return new MatchResult($this->tier(mb_strtolower($haystack), mb_strtolower($needle))->weight() * self::TIER_WEIGHT + $refinement, $positions); } /** @@ -100,7 +100,7 @@ public function rankValues(array $values, string $needle): array { // 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 $a, int $b): int => $b <=> $a); + uasort($scores, static fn(int $left_score, int $right_score): int => $right_score <=> $left_score); $ranked = []; foreach (array_keys($scores) as $index) { @@ -140,7 +140,7 @@ public function rankOptions(array $options, string $needle): array { // 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 $a, int $b): int => $b <=> $a); + uasort($scores, static fn(int $left_score, int $right_score): int => $right_score <=> $left_score); $ranked = []; foreach (array_keys($scores) as $index) { @@ -151,26 +151,26 @@ public function rankOptions(array $options, string $needle): array { } /** - * The match tier: exact (4), prefix (3), substring (2) or subsequence (1). + * The match tier: exact, prefix, substring or subsequence. * * @param string $haystack * The lowercased candidate. * @param string $needle * The lowercased query. * - * @return int + * @return \DrevOps\Tui\Widget\MatchTier * The tier. */ - protected function tier(string $haystack, string $needle): int { + protected function tier(string $haystack, string $needle): MatchTier { if ($haystack === $needle) { - return 4; + return MatchTier::Exact; } if (str_starts_with($haystack, $needle)) { - return 3; + return MatchTier::Prefix; } - return str_contains($haystack, $needle) ? 2 : 1; + return str_contains($haystack, $needle) ? MatchTier::Substring : MatchTier::Subsequence; } /** diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php index 8e78b8e..cb823e4 100644 --- a/src/Widget/MultiSelectWidget.php +++ b/src/Widget/MultiSelectWidget.php @@ -60,7 +60,7 @@ class MultiSelectWidget extends AbstractWidget { 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 = $pageSize ?? self::DEFAULT_PAGE_SIZE; + $this->pageSize = $this->resolvePageSize($pageSize); $selectable = array_fill_keys($this->selectableValues(), TRUE); foreach ($default as $value) { diff --git a/src/Widget/SearchWidget.php b/src/Widget/SearchWidget.php index c21260d..4b7be1b 100644 --- a/src/Widget/SearchWidget.php +++ b/src/Widget/SearchWidget.php @@ -48,7 +48,7 @@ public function __construct(array $options, string $default = '', ?\Closure $val parent::__construct($validate, $transform); $this->initOptions($options); $this->cursor = $this->cursorForDefault($this->options, $default); - $this->pageSize = $pageSize ?? self::DEFAULT_PAGE_SIZE; + $this->pageSize = $this->resolvePageSize($pageSize); } /** diff --git a/src/Widget/SelectWidget.php b/src/Widget/SelectWidget.php index 706097a..ff5b655 100644 --- a/src/Widget/SelectWidget.php +++ b/src/Widget/SelectWidget.php @@ -43,7 +43,7 @@ public function __construct(array $options, string $default = '', ?\Closure $val parent::__construct($validate, $transform); $this->initOptions($options); $this->cursor = $this->cursorForDefault($this->options, $default); - $this->pageSize = $pageSize ?? self::DEFAULT_PAGE_SIZE; + $this->pageSize = $this->resolvePageSize($pageSize); } /** diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php index b04fdbe..7cb0fa0 100644 --- a/src/Widget/SuggestWidget.php +++ b/src/Widget/SuggestWidget.php @@ -37,7 +37,7 @@ class SuggestWidget extends AbstractWidget { */ public function __construct(protected array $values, protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, ?int $pageSize = NULL) { parent::__construct($validate, $transform); - $this->pageSize = $pageSize ?? self::DEFAULT_PAGE_SIZE; + $this->pageSize = $this->resolvePageSize($pageSize); } /** diff --git a/tests/phpunit/Unit/Widget/MatcherTest.php b/tests/phpunit/Unit/Widget/MatcherTest.php index c5f448a..d20d3fc 100644 --- a/tests/phpunit/Unit/Widget/MatcherTest.php +++ b/tests/phpunit/Unit/Widget/MatcherTest.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\OptionKind; use DrevOps\Tui\Widget\Matcher; use DrevOps\Tui\Widget\MatchResult; +use DrevOps\Tui\Widget\MatchTier; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -18,6 +19,7 @@ */ #[CoversClass(Matcher::class)] #[CoversClass(MatchResult::class)] +#[CoversClass(MatchTier::class)] #[Group('widget')] final class MatcherTest extends TestCase { diff --git a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php index a85718e..a14a9f6 100644 --- a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php @@ -229,6 +229,13 @@ public function testFilterStaysSubstringNotFuzzy(): void { $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); diff --git a/tests/phpunit/Unit/Widget/SelectWidgetTest.php b/tests/phpunit/Unit/Widget/SelectWidgetTest.php index 440bdd8..5f05895 100644 --- a/tests/phpunit/Unit/Widget/SelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SelectWidgetTest.php @@ -137,6 +137,13 @@ 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); From 8fbb1026f6f418ad6010b14f6c2496e0eb61295b Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 10:41:24 +1000 Subject: [PATCH 6/6] [#7] Covered non-positive page size on the suggest and search widgets. Added the page-size guard regression test to the suggest and search widgets, so all four choice-widget constructors verify that a non-positive page size is rejected. --- tests/phpunit/Unit/Widget/SearchWidgetTest.php | 7 +++++++ tests/phpunit/Unit/Widget/SuggestWidgetTest.php | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/tests/phpunit/Unit/Widget/SearchWidgetTest.php b/tests/phpunit/Unit/Widget/SearchWidgetTest.php index 17cc05c..7ce2e05 100644 --- a/tests/phpunit/Unit/Widget/SearchWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SearchWidgetTest.php @@ -201,6 +201,13 @@ public function testHighlightsMatchedCharacters(): void { $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); diff --git a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php index 06084cb..2a7c55a 100644 --- a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php @@ -120,6 +120,13 @@ public function testHighlightsMatchedCharacters(): void { $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);