From 6531fd748d5bc0d6bb6917e4ec6bc08275cf59e0 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:17:00 +1000 Subject: [PATCH 1/3] [#19] Added inline ghost-text autocomplete to text input. --- README.md | 10 ++ src/Builder/FieldBuilder.php | 28 ++++++ src/Config/Field.php | 6 ++ src/Input/Action.php | 1 + src/Input/DefaultKeyMap.php | 3 + src/Render/PanelController.php | 2 +- src/Theme/DefaultTheme.php | 7 ++ src/Theme/ThemeInterface.php | 12 +++ src/Widget/TextWidget.php | 93 ++++++++++++++++-- src/Widget/WidgetFactory.php | 34 ++++++- tests/phpunit/Unit/Builder/FormTest.php | 18 ++++ tests/phpunit/Unit/Input/KeyMapTest.php | 1 + tests/phpunit/Unit/Theme/ThemeTest.php | 9 ++ tests/phpunit/Unit/Widget/TextWidgetTest.php | 98 +++++++++++++++++++ .../phpunit/Unit/Widget/WidgetFactoryTest.php | 35 +++++++ 15 files changed, 348 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9ca8636..2e3b3a4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ Single-line text input with a movable caret. Type to insert, Left/Right move the $p->text('name', 'Site name')->default('Acme Site')->required(); ``` +Add `complete()` for inline ghost-text autocomplete: as you type, the best-matching candidate is shown dimmed after the caret and accepted with Tab or Right-arrow (at the end of the line). This is distinct from the `suggest` widget's dropdown - ghost-text keeps the eye on the input line. The source is a static list or a closure over the answers collected so far; the match is a case-insensitive prefix, with no match the field behaves as a plain input, and the ghost-text is suppressed when colour is off. + +```php +// Static candidates. +$p->text('timezone', 'Timezone')->complete(['UTC', 'Europe/London', 'Australia/Sydney']); + +// Candidates computed from prior answers. +$p->text('repo', 'Repository')->complete(fn(array $answers): array => [$answers['owner'] . '/site']); +``` + diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index 1b381e3..16b5afa 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -74,6 +74,13 @@ final class FieldBuilder { */ protected ?\Closure $transform = NULL; + /** + * The inline ghost-text completion source (a list or a closure). + * + * @var list|\Closure + */ + protected array|\Closure $completion = []; + /** * The processing weight. */ @@ -453,6 +460,26 @@ public function transform(\Closure $transformer): self { return $this; } + /** + * Text only: set the inline ghost-text completion source. + * + * As the user types, the first candidate the input is a prefix of is shown + * dimmed after the caret and accepted with Tab or Right-arrow. + * + * @param list|\Closure $source + * A list of candidate strings, or a + * `fn (array $answers): list` closure computing + * candidates from the answers collected so far. + * + * @return $this + * The builder. + */ + public function complete(array|\Closure $source): self { + $this->completion = $source; + + return $this; + } + /** * Add a single option. * @@ -562,6 +589,7 @@ public function build(): Field { $this->pickerStart, $this->pickerExtensions, $this->pickerShowHidden, + $this->completion, ); } diff --git a/src/Config/Field.php b/src/Config/Field.php index 5fec8b8..3f673a4 100644 --- a/src/Config/Field.php +++ b/src/Config/Field.php @@ -81,6 +81,11 @@ * (dot-less, case-insensitive); empty allows every extension. * @param bool $pickerShowHidden * File picker only: whether dot-entries are shown when the browser opens. + * @param list|\Closure $completion + * Text only: the inline ghost-text completion source - a list of candidate + * strings, or a `fn (array $answers): list` closure + * over the answers collected so far. Empty disables ghost-text; ignored by + * other types. */ public function __construct( public string $id, @@ -104,6 +109,7 @@ public function __construct( public string $pickerStart = '', public array $pickerExtensions = [], public bool $pickerShowHidden = FALSE, + public array|\Closure $completion = [], ) { $this->options = Option::list($options); } diff --git a/src/Input/Action.php b/src/Input/Action.php index e92c05d..f5d3f2e 100644 --- a/src/Input/Action.php +++ b/src/Input/Action.php @@ -40,6 +40,7 @@ enum Action { case InsertSpace; case NewLine; case ExternalEdit; + case Complete; // List and multiselect. case Toggle; diff --git a/src/Input/DefaultKeyMap.php b/src/Input/DefaultKeyMap.php index ab34005..dbe6343 100644 --- a/src/Input/DefaultKeyMap.php +++ b/src/Input/DefaultKeyMap.php @@ -46,6 +46,9 @@ public function bindings(): array { new Binding(Scope::navigation(), Action::ScrollUp, KeyName::MouseWheelUp), new Binding(Scope::navigation(), Action::ScrollDown, KeyName::MouseWheelDown), + // The text field accepts its inline ghost-text completion on Tab. + new Binding(Scope::field(FieldType::Text), Action::Complete, KeyName::Tab), + new Binding(Scope::field(FieldType::Textarea), Action::NewLine, KeyName::Enter), new Binding(Scope::field(FieldType::Textarea), Action::Accept, KeyName::Tab), // The textarea hands off to the user's $EDITOR on Ctrl-E. diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php index 96a9bf8..3ea3e88 100644 --- a/src/Render/PanelController.php +++ b/src/Render/PanelController.php @@ -449,7 +449,7 @@ protected function activateButton(int $index): void { */ protected function openEditor(Field $field): void { $this->editing = $field; - $this->editor = $this->widgets->create($field, $this->values[$field->id] ?? $field->default); + $this->editor = $this->widgets->create($field, $this->values[$field->id] ?? $field->default, $this->values); } /** diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php index cec1c07..ace70fe 100644 --- a/src/Theme/DefaultTheme.php +++ b/src/Theme/DefaultTheme.php @@ -475,6 +475,13 @@ public function caret(): string { return $this->paint($this->isDark ? '1;36' : '1;34', $this->unicode ? '█' : '|'); } + /** + * {@inheritdoc} + */ + public function ghost(string $text): string { + return $this->color ? $this->paint('90', $text) : ''; + } + /** * {@inheritdoc} */ diff --git a/src/Theme/ThemeInterface.php b/src/Theme/ThemeInterface.php index 7b2e633..97da920 100644 --- a/src/Theme/ThemeInterface.php +++ b/src/Theme/ThemeInterface.php @@ -227,6 +227,18 @@ public function check(bool $on): string; */ public function caret(): string; + /** + * Inline ghost-text: a dimmed completion suffix, empty without colour. + * + * @param string $text + * The completion suffix shown after the caret. + * + * @return string + * The dimmed suffix, or an empty string in no-colour mode - without ANSI it + * cannot be told apart from typed text, so it is suppressed. + */ + public function ghost(string $text): string; + /** * The masked-character symbol for secret values. */ diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php index 1e444b9..dd259a2 100644 --- a/src/Widget/TextWidget.php +++ b/src/Widget/TextWidget.php @@ -4,12 +4,14 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** - * Single-line text input with a movable cursor. + * Single-line text input with a movable cursor and optional ghost-text. * * @package DrevOps\Tui\Widget */ @@ -29,12 +31,23 @@ class TextWidget extends AbstractWidget { * Optional validator (see AbstractWidget). * @param \Closure|null $transform * Optional transformer (see AbstractWidget). + * @param list $completions + * Inline ghost-text candidates: the buffer is completed to the first + * candidate it is a prefix of. Empty leaves a plain text field. */ - public function __construct(protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL) { + public function __construct(protected string $buffer = '', ?\Closure $validate = NULL, ?\Closure $transform = NULL, protected array $completions = []) { parent::__construct($validate, $transform); $this->cursor = strlen($this->buffer); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Text); + } + /** * {@inheritdoc} */ @@ -51,6 +64,12 @@ public function handle(Key $key): void { return; } + if ($keys->matches($key, Action::Complete)) { + $this->applyCompletion(); + + return; + } + if ($keys->matches($key, Action::DeleteBack)) { $this->backspace(); @@ -64,7 +83,14 @@ public function handle(Key $key): void { } if ($keys->matches($key, Action::MoveRight)) { - $this->cursor = min(strlen($this->buffer), $this->cursor + 1); + // At the end of a completable line Right accepts the ghost-text, mirroring + // Tab; anywhere else it just advances the caret. + if ($this->bestMatch() !== NULL) { + $this->applyCompletion(); + } + else { + $this->cursor = min(strlen($this->buffer), $this->cursor + 1); + } return; } @@ -101,6 +127,58 @@ protected function backspace(): void { } } + /** + * The best completion candidate for the current buffer, if any. + * + * A candidate qualifies only when the caret sits at the end of a non-empty + * buffer and the buffer is a case-insensitive prefix of a strictly longer + * candidate; the first such candidate in declared order wins. Returns NULL + * when nothing completes, so the field behaves as a plain text input. + * + * @return string|null + * The full candidate string, or NULL. + */ + protected function bestMatch(): ?string { + if ($this->buffer === '' || $this->cursor !== strlen($this->buffer)) { + return NULL; + } + + $needle = strtolower($this->buffer); + $length = strlen($this->buffer); + + foreach ($this->completions as $candidate) { + if (strlen($candidate) > $length && str_starts_with(strtolower($candidate), $needle)) { + return $candidate; + } + } + + return NULL; + } + + /** + * The ghost-text suffix shown after the caret, or an empty string when none. + * + * @return string + * The suffix of the best candidate beyond the typed buffer. + */ + protected function ghostSuffix(): string { + $match = $this->bestMatch(); + + return $match === NULL ? '' : substr($match, strlen($this->buffer)); + } + + /** + * Fill the buffer with the current completion candidate, when one applies. + */ + protected function applyCompletion(): void { + $match = $this->bestMatch(); + + if ($match !== NULL) { + $this->buffer = $match; + $this->cursor = strlen($match); + } + } + /** * {@inheritdoc} */ @@ -118,16 +196,19 @@ public function view(ThemeInterface $theme): string { } /** - * Render the input line with the caret at the cursor position. + * Render the input line with the caret and any inline ghost-text. * * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme supplying the caret glyph. + * The theme supplying the caret glyph and the ghost styling. * * @return string * The input line. */ protected function caretLine(ThemeInterface $theme): string { - return substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor); + $suffix = $this->ghostSuffix(); + $ghost = $suffix === '' ? '' : $theme->ghost($suffix); + + return substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor) . $ghost; } } diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php index fb5035c..b885e69 100644 --- a/src/Widget/WidgetFactory.php +++ b/src/Widget/WidgetFactory.php @@ -41,11 +41,13 @@ public function __construct(?KeyMap $keymap = NULL, protected bool $externalEdit * The field. * @param mixed $current * The current value to seed the widget with. + * @param array $answers + * The answers collected so far, passed to a text field's completion closure. * * @return \DrevOps\Tui\Widget\WidgetInterface * The widget. */ - public function create(Field $field, mixed $current): WidgetInterface { + public function create(Field $field, mixed $current, array $answers = []): WidgetInterface { $widget = match ($field->type) { FieldType::Confirm => new ConfirmWidget((bool) $current), FieldType::Toggle => new ToggleWidget($this->labels($field), is_string($current) ? $current : ''), @@ -60,12 +62,40 @@ public function create(Field $field, mixed $current): WidgetInterface { FieldType::Textarea => new TextareaWidget(is_string($current) ? $current : '', externalEdit: $field->externalEditor && $this->externalEditorAvailable), FieldType::Password => new PasswordWidget(is_string($current) ? $current : '', revealable: $field->revealable, confirm: $field->confirm), FieldType::Pause => new PauseWidget(), - default => new TextWidget(is_string($current) ? $current : ''), + default => new TextWidget(is_string($current) ? $current : '', completions: $this->completionsFor($field, $answers)), }; return $widget->setKeys($this->keymap->forField($field->type)); } + /** + * Resolve a text field's completion source to a concrete candidate list. + * + * A closure source is called with the answers collected so far; the result is + * coerced to a list of strings, so a mistyped source degrades to no + * completion rather than erroring. + * + * @param \DrevOps\Tui\Config\Field $field + * The field. + * @param array $answers + * The answers collected so far. + * + * @return list + * The candidate strings; empty when the field declares no completion. + */ + protected function completionsFor(Field $field, array $answers): array { + $source = $field->completion instanceof \Closure ? ($field->completion)($answers) : $field->completion; + + $out = []; + foreach (is_array($source) ? $source : [] as $item) { + if (is_string($item)) { + $out[] = $item; + } + } + + return $out; + } + /** * The selectable value => label map for a field's options. * diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 388802b..3b6e38a 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -222,6 +222,24 @@ public function testValidateAndTransformStored(): void { $this->assertSame($transformer, $field->transform); } + public function testCompletionSourceStored(): void { + $list = ['acme-site', 'acme-app']; + $closure = fn (array $answers): array => []; + + $config = Form::create('T') + ->panel('p', 'P', function (PanelBuilder $panel) use ($list, $closure): void { + $panel->text('name', 'Name')->complete($list); + $panel->text('repo', 'Repo')->complete($closure); + $panel->text('plain', 'Plain'); + }) + ->build(); + + $this->assertSame($list, $config->field('name')?->completion); + $this->assertSame($closure, $config->field('repo')?->completion); + // A field with no completion source defaults to an empty list. + $this->assertSame([], $config->field('plain')?->completion); + } + public function testNumberBoundsAssembled(): void { $config = Form::create('T') ->panel('p', 'P', function (PanelBuilder $panel): void { diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php index ff09a23..b66d518 100644 --- a/tests/phpunit/Unit/Input/KeyMapTest.php +++ b/tests/phpunit/Unit/Input/KeyMapTest.php @@ -53,6 +53,7 @@ public static function dataProviderDefaultBindings(): \Iterator { yield 'text enter accepts' => [Scope::field(FieldType::Text), Key::named(KeyName::Enter), Action::Accept, TRUE]; yield 'text space inserts' => [Scope::field(FieldType::Text), Key::named(KeyName::Space), Action::InsertSpace, TRUE]; yield 'text backspace deletes' => [Scope::field(FieldType::Text), Key::named(KeyName::Backspace), Action::DeleteBack, TRUE]; + yield 'text tab completes' => [Scope::field(FieldType::Text), Key::named(KeyName::Tab), Action::Complete, TRUE]; // Textarea overrides the base. yield 'textarea enter is newline' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Enter), Action::NewLine, TRUE]; yield 'textarea enter is not accept' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Enter), Action::Accept, FALSE]; diff --git a/tests/phpunit/Unit/Theme/ThemeTest.php b/tests/phpunit/Unit/Theme/ThemeTest.php index 0872e72..2c96bbc 100644 --- a/tests/phpunit/Unit/Theme/ThemeTest.php +++ b/tests/phpunit/Unit/Theme/ThemeTest.php @@ -40,6 +40,15 @@ public static function dataProviderStyler(): \Iterator { // Option-list roles: a bold-gray heading and a gray disabled option. yield 'heading' => [static fn(): string => (new DefaultTheme())->heading('X'), '1;90']; yield 'disabled' => [static fn(): string => (new DefaultTheme())->disabled('X'), '90']; + // Inline ghost-text is dimmed gray, the same as the other dimmed chrome. + yield 'ghost' => [static fn(): string => (new DefaultTheme())->ghost('X'), '90']; + } + + public function testGhostSuppressedWithoutColour(): void { + // Ghost-text cannot be dimmed without ANSI, so it is suppressed entirely + // rather than rendered as indistinguishable plain text. + $this->assertSame('', (new DefaultTheme(76, ['color' => FALSE]))->ghost('X')); + $this->assertStringContainsString("\033[90m", (new DefaultTheme())->ghost('X')); } public function testDivider(): void { diff --git a/tests/phpunit/Unit/Widget/TextWidgetTest.php b/tests/phpunit/Unit/Widget/TextWidgetTest.php index a95d0d5..f91acdd 100644 --- a/tests/phpunit/Unit/Widget/TextWidgetTest.php +++ b/tests/phpunit/Unit/Widget/TextWidgetTest.php @@ -95,4 +95,102 @@ public function testDoesNotRenderOwnHint(): void { $this->assertFalse((new TextWidget())->rendersHint()); } + public function testGhostTextRendersDimmedSuffix(): void { + // The first candidate is skipped (no prefix match); the second completes. + $widget = new TextWidget('', NULL, NULL, ['other', 'acme-site']); + + $widget->handle(Key::char('a')); + $widget->handle(Key::char('c')); + + // The typed prefix stays put and the remaining suffix is dimmed (SGR 90). + $view = $widget->view(new DefaultTheme()); + $this->assertStringContainsString('me-site', $view); + $this->assertStringContainsString("\033[90m", $view); + + // The ghost is a preview only: the value stays the typed text until accepted. + $this->assertSame('ac', $widget->value()); + } + + public function testTabAcceptsCompletion(): void { + $widget = new TextWidget('', NULL, NULL, ['acme-site']); + + $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Tab), Key::named(KeyName::Enter))); + + $this->assertSame('acme-site', $value); + } + + public function testRightAtEndAcceptsCompletion(): void { + $widget = new TextWidget('', NULL, NULL, ['acme-site']); + + $value = WidgetRunner::run($widget, ArrayKeyStream::of(Key::char('a'), Key::named(KeyName::Right), Key::named(KeyName::Enter))); + + $this->assertSame('acme-site', $value); + } + + public function testRightMidBufferMovesCaretWithoutCompleting(): void { + $widget = new TextWidget('ab', NULL, NULL, ['abcdef']); + + // With the caret off the end there is no ghost, so Right advances the caret + // rather than accepting a completion. + $widget->handle(Key::named(KeyName::Left)); + $widget->handle(Key::named(KeyName::Right)); + + $this->assertSame('ab', $widget->value()); + } + + public function testCaseInsensitiveMatchCanonicalisesOnAccept(): void { + $widget = new TextWidget('', NULL, NULL, ['GitHub']); + + $widget->handle(Key::char('g')); + $widget->handle(Key::char('i')); + $widget->handle(Key::named(KeyName::Tab)); + + // A lower-case prefix matches and accepting adopts the candidate's case. + $this->assertSame('GitHub', $widget->value()); + } + + public function testNoMatchLeavesPlainField(): void { + $widget = new TextWidget('', NULL, NULL, ['acme-site']); + + $widget->handle(Key::char('z')); + + // No candidate starts with "z": no dimmed ghost, and Tab is inert. + $view = $widget->view(new DefaultTheme()); + $this->assertStringNotContainsString("\033[90m", $view); + + $widget->handle(Key::named(KeyName::Tab)); + $this->assertSame('z', $widget->value()); + } + + public function testFullyTypedCandidateHasNoGhost(): void { + $widget = new TextWidget('', NULL, NULL, ['php']); + + $widget->handle(Key::char('p')); + $widget->handle(Key::char('h')); + $widget->handle(Key::char('p')); + + // The buffer already equals the only candidate, so nothing is left to ghost. + $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); + } + + public function testEmptyBufferShowsNoGhost(): void { + // With nothing typed there is no prefix to complete, so no ghost renders. + $widget = new TextWidget('', NULL, NULL, ['acme-site']); + + $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); + } + + public function testGhostSuppressedInNoAnsiMode(): void { + $widget = new TextWidget('', NULL, NULL, ['acme-site']); + + $widget->handle(Key::char('a')); + $widget->handle(Key::char('c')); + + // Without colour the ghost cannot be dimmed, so it is suppressed entirely and + // no escape sequences leak into the plain-text line. + $view = $widget->view(new DefaultTheme(76, ['color' => FALSE])); + $this->assertStringNotContainsString('me-site', $view); + $this->assertStringNotContainsString("\033", $view); + } + } diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index 5a992a0..966290a 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -170,6 +170,41 @@ public function testSuggestReceivesSelectableValuesOnly(): void { $this->assertStringNotContainsString('gmt', $view); } + public function testTextCompletionStaticListReachesWidget(): void { + $field = new Field('name', 'Name', '', FieldType::Text, '', completion: ['acme-site']); + + $view = (new WidgetFactory())->create($field, 'ac')->view(new DefaultTheme()); + + // The matching candidate's remaining suffix shows as dimmed ghost-text. + $this->assertStringContainsString('me-site', $view); + } + + public function testTextCompletionClosureReceivesAnswers(): void { + $seen = []; + $field = new Field('repo', 'Repo', '', FieldType::Text, '', completion: function (array $answers) use (&$seen): array { + $seen = $answers; + + return ['acme-site']; + }); + + $view = (new WidgetFactory())->create($field, 'ac', ['owner' => 'acme'])->view(new DefaultTheme()); + + // The closure is handed the answers collected so far and its result reaches + // the widget as ghost-text. + $this->assertSame(['owner' => 'acme'], $seen); + $this->assertStringContainsString('me-site', $view); + } + + public function testTextCompletionCoercesInvalidResult(): void { + // A mistyped source degrades to no completion rather than erroring: a list + // with non-strings is filtered, and a non-list result is ignored. + $items = new Field('a', 'A', '', FieldType::Text, '', completion: fn (array $answers): array => [123, NULL]); + $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($items, 'ac')->view(new DefaultTheme())); + + $scalar = new Field('b', 'B', '', FieldType::Text, '', completion: fn (array $answers) => 'oops'); + $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($scalar, 'ac')->view(new DefaultTheme())); + } + /** * A field of the given type. * From c871f97ccb8f410e09126ec3029868b740d999c5 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:23:27 +1000 Subject: [PATCH 2/3] [#19] Made ghost-text completion matching Unicode-aware. --- src/Widget/TextWidget.php | 10 ++++++---- tests/phpunit/Unit/Widget/TextWidgetTest.php | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php index dd259a2..8b5750c 100644 --- a/src/Widget/TextWidget.php +++ b/src/Widget/TextWidget.php @@ -143,11 +143,13 @@ protected function bestMatch(): ?string { return NULL; } - $needle = strtolower($this->buffer); - $length = strlen($this->buffer); + // Fold and measure by character, not byte, so non-ASCII candidates match + // case-insensitively and the suffix never splits mid-character. + $needle = mb_strtolower($this->buffer); + $length = mb_strlen($this->buffer); foreach ($this->completions as $candidate) { - if (strlen($candidate) > $length && str_starts_with(strtolower($candidate), $needle)) { + if (mb_strlen($candidate) > $length && str_starts_with(mb_strtolower($candidate), $needle)) { return $candidate; } } @@ -164,7 +166,7 @@ protected function bestMatch(): ?string { protected function ghostSuffix(): string { $match = $this->bestMatch(); - return $match === NULL ? '' : substr($match, strlen($this->buffer)); + return $match === NULL ? '' : mb_substr($match, mb_strlen($this->buffer)); } /** diff --git a/tests/phpunit/Unit/Widget/TextWidgetTest.php b/tests/phpunit/Unit/Widget/TextWidgetTest.php index f91acdd..f44c3eb 100644 --- a/tests/phpunit/Unit/Widget/TextWidgetTest.php +++ b/tests/phpunit/Unit/Widget/TextWidgetTest.php @@ -149,6 +149,18 @@ public function testCaseInsensitiveMatchCanonicalisesOnAccept(): void { $this->assertSame('GitHub', $widget->value()); } + public function testGhostTextIsUnicodeAware(): void { + // strtolower() folds only ASCII, so a non-ASCII prefix must fold with + // mbstring; the multibyte suffix must also render whole, not split mid-byte. + $widget = new TextWidget('', NULL, NULL, ['Éclair']); + + $widget->handle(Key::char('é')); + $this->assertStringContainsString('clair', $widget->view(new DefaultTheme())); + + $widget->handle(Key::named(KeyName::Tab)); + $this->assertSame('Éclair', $widget->value()); + } + public function testNoMatchLeavesPlainField(): void { $widget = new TextWidget('', NULL, NULL, ['acme-site']); From b487b6fc3fed7a0a69e0f3b5ca8f945c4306da4e Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:31:05 +1000 Subject: [PATCH 3/3] [#19] Wrapped long comment lines and applied Rector style fixes. --- src/Widget/TextWidget.php | 10 +++++----- src/Widget/WidgetFactory.php | 2 +- tests/phpunit/Unit/Widget/TextWidgetTest.php | 10 +++++----- tests/phpunit/Unit/Widget/WidgetFactoryTest.php | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php index 8b5750c..10be102 100644 --- a/src/Widget/TextWidget.php +++ b/src/Widget/TextWidget.php @@ -83,8 +83,8 @@ public function handle(Key $key): void { } if ($keys->matches($key, Action::MoveRight)) { - // At the end of a completable line Right accepts the ghost-text, mirroring - // Tab; anywhere else it just advances the caret. + // At the line's end, Right accepts the ghost-text like Tab; elsewhere + // it just advances the caret. if ($this->bestMatch() !== NULL) { $this->applyCompletion(); } @@ -148,9 +148,9 @@ protected function bestMatch(): ?string { $needle = mb_strtolower($this->buffer); $length = mb_strlen($this->buffer); - foreach ($this->completions as $candidate) { - if (mb_strlen($candidate) > $length && str_starts_with(mb_strtolower($candidate), $needle)) { - return $candidate; + foreach ($this->completions as $completion) { + if (mb_strlen($completion) > $length && str_starts_with(mb_strtolower($completion), $needle)) { + return $completion; } } diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php index b885e69..54e3497 100644 --- a/src/Widget/WidgetFactory.php +++ b/src/Widget/WidgetFactory.php @@ -42,7 +42,7 @@ public function __construct(?KeyMap $keymap = NULL, protected bool $externalEdit * @param mixed $current * The current value to seed the widget with. * @param array $answers - * The answers collected so far, passed to a text field's completion closure. + * The answers collected so far, passed to a text completion closure. * * @return \DrevOps\Tui\Widget\WidgetInterface * The widget. diff --git a/tests/phpunit/Unit/Widget/TextWidgetTest.php b/tests/phpunit/Unit/Widget/TextWidgetTest.php index f44c3eb..3b6b907 100644 --- a/tests/phpunit/Unit/Widget/TextWidgetTest.php +++ b/tests/phpunit/Unit/Widget/TextWidgetTest.php @@ -107,7 +107,7 @@ public function testGhostTextRendersDimmedSuffix(): void { $this->assertStringContainsString('me-site', $view); $this->assertStringContainsString("\033[90m", $view); - // The ghost is a preview only: the value stays the typed text until accepted. + // The ghost is a preview: the value stays the typed text until accepted. $this->assertSame('ac', $widget->value()); } @@ -151,7 +151,7 @@ public function testCaseInsensitiveMatchCanonicalisesOnAccept(): void { public function testGhostTextIsUnicodeAware(): void { // strtolower() folds only ASCII, so a non-ASCII prefix must fold with - // mbstring; the multibyte suffix must also render whole, not split mid-byte. + // mbstring; the multibyte suffix must render whole, not split mid-byte. $widget = new TextWidget('', NULL, NULL, ['Éclair']); $widget->handle(Key::char('é')); @@ -181,7 +181,7 @@ public function testFullyTypedCandidateHasNoGhost(): void { $widget->handle(Key::char('h')); $widget->handle(Key::char('p')); - // The buffer already equals the only candidate, so nothing is left to ghost. + // The buffer already equals the only candidate; nothing is left to ghost. $this->assertStringNotContainsString("\033[90m", $widget->view(new DefaultTheme())); } @@ -198,8 +198,8 @@ public function testGhostSuppressedInNoAnsiMode(): void { $widget->handle(Key::char('a')); $widget->handle(Key::char('c')); - // Without colour the ghost cannot be dimmed, so it is suppressed entirely and - // no escape sequences leak into the plain-text line. + // Without colour the ghost cannot be dimmed, so it is suppressed and no + // escape sequences leak into the plain-text line. $view = $widget->view(new DefaultTheme(76, ['color' => FALSE])); $this->assertStringNotContainsString('me-site', $view); $this->assertStringNotContainsString("\033", $view); diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index 966290a..2a282af 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -201,7 +201,7 @@ public function testTextCompletionCoercesInvalidResult(): void { $items = new Field('a', 'A', '', FieldType::Text, '', completion: fn (array $answers): array => [123, NULL]); $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($items, 'ac')->view(new DefaultTheme())); - $scalar = new Field('b', 'B', '', FieldType::Text, '', completion: fn (array $answers) => 'oops'); + $scalar = new Field('b', 'B', '', FieldType::Text, '', completion: fn (array $answers): string => 'oops'); $this->assertStringNotContainsString("\033[90m", (new WidgetFactory())->create($scalar, 'ac')->view(new DefaultTheme())); }