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'),