Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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']);
Expand All @@ -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']);
Expand All @@ -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']);
```

<table>
Expand All @@ -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']);
Expand Down
32 changes: 32 additions & 0 deletions src/Builder/FieldBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -562,6 +593,7 @@ public function build(): Field {
$this->pickerStart,
$this->pickerExtensions,
$this->pickerShowHidden,
$this->pageSize,
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/Config/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions src/Theme/DefaultTheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Theme/ThemeInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +130 to +133

@coderabbitai coderabbitai Bot Jul 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'src/Theme/*' 'src/**/*.php' 'composer.json' 'README*' 'CHANGELOG*' 'UPGRADE*' 'docs/*' | sed -n '1,200p'

Repository: drevops/tui

Length of output: 5700


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the theme-related files and inspect the interface plus implementations.
ast-grep outline src/Theme/ThemeInterface.php --view expanded || true
ast-grep outline src/Theme/DefaultTheme.php --view expanded || true

Repository: drevops/tui

Length of output: 6579


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant source sections with line numbers.
wc -l src/Theme/ThemeInterface.php src/Theme/DefaultTheme.php 2>/dev/null || true
sed -n '1,240p' src/Theme/ThemeInterface.php
printf '\n----\n'
sed -n '1,260p' src/Theme/DefaultTheme.php

Repository: drevops/tui

Length of output: 13629


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## Files'
git ls-files 'src/Theme/*' 'src/**/*.php' 'composer.json' 'README*' 'CHANGELOG*' 'UPGRADE*' 'docs/*' | sed -n '1,200p'

echo
echo '## ThemeInterface outline'
ast-grep outline src/Theme/ThemeInterface.php --view expanded || true

echo
echo '## DefaultTheme outline'
ast-grep outline src/Theme/DefaultTheme.php --view expanded || true

echo
echo '## ThemeInterface content'
wc -l src/Theme/ThemeInterface.php
sed -n '1,240p' src/Theme/ThemeInterface.php

echo
echo '## DefaultTheme content'
wc -l src/Theme/DefaultTheme.php
sed -n '1,260p' src/Theme/DefaultTheme.php

Repository: drevops/tui

Length of output: 25702


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## Implementations of ThemeInterface'
rg -n "implements\s+ThemeInterface|class\s+.*ThemeInterface" src test tests . || true

echo
echo '## References to highlightMatch'
rg -n "highlightMatch\s*\(" src test tests . || true

echo
echo '## Package metadata'
sed -n '1,220p' composer.json

echo
echo '## ThemeInterface'
sed -n '1,220p' src/Theme/ThemeInterface.php

echo
echo '## DefaultTheme'
sed -n '1,220p' src/Theme/DefaultTheme.php

Repository: drevops/tui

Length of output: 16851


Keep ThemeInterface backward compatible
Adding highlightMatch() here breaks any custom theme implementation. Make it optional via a separate interface/trait, or treat this as a major-version API break and document the migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Theme/ThemeInterface.php` around lines 130 - 133, Adding highlightMatch()
to ThemeInterface breaks existing custom theme implementations; preserve
backward compatibility by moving it to a separate optional interface or reusable
trait, and update callers to detect or safely handle implementations that do not
provide it. If retaining the method on ThemeInterface, explicitly treat this as
a major API change and document the required migration.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping highlightMatch() on ThemeInterface.

The documented and only supported way to build a theme is to extend DefaultTheme (see its class docblock), which implements highlightMatch(), so consumer themes are unaffected. ThemeInterface deliberately declares every themeable atom as a required method - title, value, marker, radio, check, caret, heading, divider and ~40 others - and each widget feature has added its atom here as the library has grown; highlightMatch() follows that same established pattern rather than being a special case.

The package is pre-1.0 with no published stable-API contract yet, so there is no compatibility guarantee to break. Splitting a single atom into an optional secondary interface (forcing every call site to instanceof-check the theme) would be inconsistent with the other atoms and add complexity for no practical gain given the extend-DefaultTheme model.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexSkrypnyk Thanks for the clarification — that makes sense. Given the pre-1.0 status, the documented "extend DefaultTheme" convention, and the consistency with how every other themeable atom is declared as a required method on ThemeInterface, adding highlightMatch() here is the right call. Withdrawing this concern.


/**
* A non-selectable group heading in an option list.
*/
Expand Down
141 changes: 141 additions & 0 deletions src/Widget/AbstractWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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.
*
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;
}
Comment on lines +224 to +245

@coderabbitai coderabbitai Bot Jul 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a use import for InvalidArgumentException.

Static analysis flags the inline fully-qualified \InvalidArgumentException at line 241 for a missing import.

🧹 Proposed fix
+use InvalidArgumentException;
+
 abstract class AbstractWidget implements WidgetInterface {
-      throw new \InvalidArgumentException(sprintf('Page size must be a positive integer, %d given.', $pageSize));
+      throw new InvalidArgumentException(sprintf('Page size must be a positive integer, %d given.', $pageSize));
🧰 Tools
🪛 PHPMD (2.15.0)

[error] 241-241: Missing class import via use statement (line '241', column '17'). (undefined)

(MissingImport)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Widget/AbstractWidget.php` around lines 224 - 245, Add a use import for
InvalidArgumentException in AbstractWidget.php, then update resolvePageSize() to
reference the imported class instead of the fully qualified
\InvalidArgumentException.

Source: Linters/SAST tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the fully-qualified \InvalidArgumentException.

This matches the existing convention in the codebase - DefaultTheme::validateOptions() throws \InvalidArgumentException the same way, with no use import, and passes phpcs and CI. The project's lint is phpcs + PHPStan + Rector (PHPMD is not part of it), and phpcs is satisfied with the fully-qualified global-namespace reference. Adding a use import here would make AbstractWidget inconsistent with the rest of the code.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'InvalidArgumentException' src/Theme/DefaultTheme.php src/**/*.php -g '*.php' | head -50

Length of output: 8229


@AlexSkrypnyk Confirmed — the codebase consistently uses the fully-qualified \InvalidArgumentException without a use import across src/Theme/DefaultTheme.php, src/Theme/ThemeManager.php, src/Input/KeyMap.php, and src/Input/KeyMapManager.php. Keeping AbstractWidget.php consistent with this convention makes sense, and since PHPMD isn't part of your lint pipeline (phpcs + PHPStan + Rector), there's no actual conflict. Withdrawing this suggestion.


🧬 Code Graph Analysis Results

Relevant snippet: src/Widget/AbstractWidget.php

resolvePageSize(?int $pageSize): int (throws fully-qualified \InvalidArgumentException) — lines 228-234

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;
}

Relevant snippets that call AbstractWidget::resolvePageSize()

src/Widget/MultiSelectWidget.php

Constructor uses resolvePageSize($pageSize) — lines 60-73

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) {
      if (isset($selectable[$value])) {
        $this->selected[$value] = TRUE;
      }
    }

    $this->cursor = $this->firstSelectable($this->options);
}

src/Widget/SuggestWidget.php

Constructor uses resolvePageSize($pageSize) — lines 38-41

public function __construct(array $values, string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, ?int $pageSize = NULL) {
    parent::__construct($validate, $transform);
    $this->pageSize = $this->resolvePageSize($pageSize);
}

src/Widget/SearchWidget.php

Constructor uses resolvePageSize($pageSize) — lines 47-52

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);
}

src/Widget/SelectWidget.php

Constructor uses resolvePageSize($pageSize) — lines 42-47

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);
}

Related snippet showing the same exception style (new \InvalidArgumentException(...))

src/Input/KeyMapManager.php

register() and create() throw fully-qualified \InvalidArgumentException — lines 20-85

public static function register(string $name, string $class): void {
  if (!is_a($class, DefaultKeyMap::class, TRUE)) {
    throw new \InvalidArgumentException(sprintf('Key-map preset class "%s" must extend %s.', $class, DefaultKeyMap::class));
  }

  self::$registry[$name] = $class;
}

/**
 * Create a resolved key map from a preset and optional overrides.
 *
 * Lowest friction first: a fully-qualified preset class name is instantiated
 * directly, so a config can point at a consumer's own preset class with no
 * registration. Otherwise the name is looked up in the registry ("default",
 * "vim" or a name passed to {`@see` register()}). An unknown name fails
 * loudly - a typo should not silently fall back to the defaults.
 *
 * `@param` string $name
 *   A registered name, a preset class name, or "" for the default preset.
 * `@param` list<\DrevOps\Tui\Input\Binding> $overrides
 *   Bindings appended after the preset's own, retuning individual bindings.
 *
 * `@return` \DrevOps\Tui\Input\KeyMap
 *   The resolved, validated key map.
 *
 * `@throws` \InvalidArgumentException
 *   When the name is neither registered nor a preset class name, or a binding
 *   conflicts or is malformed.
 */
public static function create(string $name = 'default', array $overrides = []): KeyMap {
  $name = $name === '' ? 'default' : $name;

  $class = self::$registry[$name] ?? (is_a($name, DefaultKeyMap::class, TRUE) ? $name : NULL);

  if ($class === NULL) {
    throw new \InvalidArgumentException(sprintf('Unknown key-map preset "%s". Use a registered name (%s), register one with KeyMapManager::register(), or pass a DefaultKeyMap subclass name.', $name, implode(', ', array_keys(self::$registry))));
  }

  return new KeyMap(array_merge((new $class())->bindings(), $overrides));
}


/**
* 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<int> $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.
*
Expand Down
34 changes: 34 additions & 0 deletions src/Widget/MatchResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace DrevOps\Tui\Widget;

/**
* The outcome of matching a query against a candidate label.
*
* Carries the relevance score used to rank a candidate against its peers and
* the label character indices the query matched, used to highlight them.
*
* @package DrevOps\Tui\Widget
*/
final readonly class MatchResult {

/**
* Construct a match result.
*
* @param int $score
* The relevance score: higher ranks ahead. Tiered so an exact match always
* outranks a prefix, a prefix a substring, and a substring a looser
* subsequence, whatever the finer refinement within a tier.
* @param list<int> $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,
) {
}

}
44 changes: 44 additions & 0 deletions src/Widget/MatchTier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace DrevOps\Tui\Widget;

/**
* How closely a candidate matches a query, coarsest band first.
*
* A tighter tier always outranks a looser one regardless of the finer
* within-tier refinement, so the tier's weight dominates the match score.
*
* @package DrevOps\Tui\Widget
*/
enum MatchTier {

// The candidate equals the query (case-insensitively).
case Exact;

// The query is a prefix of the candidate.
case Prefix;

// The query appears in the candidate as a contiguous substring.
case Substring;

// The query's characters appear in order but not contiguously.
case Subsequence;

/**
* The ranking weight: a higher weight ranks ahead.
*
* @return int
* The weight, from 4 (exact) down to 1 (subsequence).
*/
public function weight(): int {
return match ($this) {
self::Exact => 4,
self::Prefix => 3,
self::Substring => 2,
self::Subsequence => 1,
};
}

}
Loading
Loading