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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
```

<table>
<tr>
<td></td>
Expand Down
28 changes: 28 additions & 0 deletions src/Builder/FieldBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ final class FieldBuilder {
*/
protected ?\Closure $transform = NULL;

/**
* The inline ghost-text completion source (a list or a closure).
*
* @var list<string>|\Closure
*/
protected array|\Closure $completion = [];

/**
* The processing weight.
*/
Expand Down Expand Up @@ -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<string>|\Closure $source
* A list of candidate strings, or a
* `fn (array<string,mixed> $answers): list<string>` 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.
*
Expand Down Expand Up @@ -562,6 +589,7 @@ public function build(): Field {
$this->pickerStart,
$this->pickerExtensions,
$this->pickerShowHidden,
$this->completion,
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/Config/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|\Closure $completion
* Text only: the inline ghost-text completion source - a list of candidate
* strings, or a `fn (array<string,mixed> $answers): list<string>` closure
* over the answers collected so far. Empty disables ghost-text; ignored by
* other types.
*/
public function __construct(
public string $id,
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/Input/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ enum Action {
case InsertSpace;
case NewLine;
case ExternalEdit;
case Complete;

// List and multiselect.
case Toggle;
Expand Down
3 changes: 3 additions & 0 deletions src/Input/DefaultKeyMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/Render/PanelController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/Theme/DefaultTheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down
12 changes: 12 additions & 0 deletions src/Theme/ThemeInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
95 changes: 89 additions & 6 deletions src/Widget/TextWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -29,12 +31,23 @@ class TextWidget extends AbstractWidget {
* Optional validator (see AbstractWidget).
* @param \Closure|null $transform
* Optional transformer (see AbstractWidget).
* @param list<string> $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}
*/
Expand All @@ -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();

Expand All @@ -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 line's end, Right accepts the ghost-text like Tab; elsewhere
// it just advances the caret.
if ($this->bestMatch() !== NULL) {
$this->applyCompletion();
}
else {
$this->cursor = min(strlen($this->buffer), $this->cursor + 1);
}

return;
}
Expand Down Expand Up @@ -101,6 +127,60 @@ 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;
}

// 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 $completion) {
if (mb_strlen($completion) > $length && str_starts_with(mb_strtolower($completion), $needle)) {
return $completion;
}
}

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 ? '' : mb_substr($match, mb_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}
*/
Expand All @@ -118,16 +198,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;
}

}
34 changes: 32 additions & 2 deletions src/Widget/WidgetFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed> $answers
* The answers collected so far, passed to a text 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 : ''),
Expand All @@ -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<string,mixed> $answers
* The answers collected so far.
*
* @return list<string>
* 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.
*
Expand Down
18 changes: 18 additions & 0 deletions tests/phpunit/Unit/Builder/FormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions tests/phpunit/Unit/Input/KeyMapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading
Loading