From d1553605f83c6ee03e418f257562a547f4881f14 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Sun, 16 Jul 2023 12:40:09 +1000 Subject: [PATCH 1/3] Truncate long validation messages --- src/Themes/Default/ConfirmPromptRenderer.php | 2 +- src/Themes/Default/MultiSelectPromptRenderer.php | 2 +- src/Themes/Default/PasswordPromptRenderer.php | 2 +- src/Themes/Default/SelectPromptRenderer.php | 2 +- src/Themes/Default/SuggestPromptRenderer.php | 2 +- src/Themes/Default/TextPromptRenderer.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Themes/Default/ConfirmPromptRenderer.php b/src/Themes/Default/ConfirmPromptRenderer.php index 58f0a969..4b28e79a 100644 --- a/src/Themes/Default/ConfirmPromptRenderer.php +++ b/src/Themes/Default/ConfirmPromptRenderer.php @@ -34,7 +34,7 @@ public function __invoke(ConfirmPrompt $prompt): string $this->renderOptions($prompt), color: 'yellow', ) - ->warning($prompt->error), + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), default => $this ->box( diff --git a/src/Themes/Default/MultiSelectPromptRenderer.php b/src/Themes/Default/MultiSelectPromptRenderer.php index 992644e6..fe065d4e 100644 --- a/src/Themes/Default/MultiSelectPromptRenderer.php +++ b/src/Themes/Default/MultiSelectPromptRenderer.php @@ -35,7 +35,7 @@ public function __invoke(MultiSelectPrompt $prompt): string $this->renderOptions($prompt), color: 'yellow', ) - ->warning($prompt->error), + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), default => $this ->box( diff --git a/src/Themes/Default/PasswordPromptRenderer.php b/src/Themes/Default/PasswordPromptRenderer.php index 8db85ae5..38898a6e 100644 --- a/src/Themes/Default/PasswordPromptRenderer.php +++ b/src/Themes/Default/PasswordPromptRenderer.php @@ -36,7 +36,7 @@ public function __invoke(PasswordPrompt $prompt): string $prompt->maskedWithCursor($maxWidth), color: 'yellow', ) - ->warning($prompt->error), + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), default => $this ->box( diff --git a/src/Themes/Default/SelectPromptRenderer.php b/src/Themes/Default/SelectPromptRenderer.php index 5ed5ec0f..74ea1fa9 100644 --- a/src/Themes/Default/SelectPromptRenderer.php +++ b/src/Themes/Default/SelectPromptRenderer.php @@ -37,7 +37,7 @@ public function __invoke(SelectPrompt $prompt): string $this->renderOptions($prompt), color: 'yellow', ) - ->warning($prompt->error), + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), default => $this ->box( diff --git a/src/Themes/Default/SuggestPromptRenderer.php b/src/Themes/Default/SuggestPromptRenderer.php index 081efd4c..1cec91c5 100644 --- a/src/Themes/Default/SuggestPromptRenderer.php +++ b/src/Themes/Default/SuggestPromptRenderer.php @@ -38,7 +38,7 @@ public function __invoke(SuggestPrompt $prompt): string $this->renderOptions($prompt), color: 'yellow', ) - ->warning($prompt->error), + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), default => $this ->box( diff --git a/src/Themes/Default/TextPromptRenderer.php b/src/Themes/Default/TextPromptRenderer.php index 4a7a0382..0e3b146c 100644 --- a/src/Themes/Default/TextPromptRenderer.php +++ b/src/Themes/Default/TextPromptRenderer.php @@ -36,7 +36,7 @@ public function __invoke(TextPrompt $prompt): string $prompt->valueWithCursor($maxWidth), color: 'yellow', ) - ->warning($prompt->error), + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), default => $this ->box( From 0d18a05bc4a264809f8ef0315b090491b1a5a96c Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Wed, 12 Jul 2023 09:31:55 +1000 Subject: [PATCH 2/3] Add search component --- playground/search.php | 37 +++++ src/Concerns/Themes.php | 3 + src/SearchPrompt.php | 151 ++++++++++++++++++++ src/Themes/Default/SearchPromptRenderer.php | 116 +++++++++++++++ src/helpers.php | 10 ++ tests/Feature/SearchPromptTest.php | 81 +++++++++++ 6 files changed, 398 insertions(+) create mode 100644 playground/search.php create mode 100644 src/SearchPrompt.php create mode 100644 src/Themes/Default/SearchPromptRenderer.php create mode 100644 tests/Feature/SearchPromptTest.php diff --git a/playground/search.php b/playground/search.php new file mode 100644 index 00000000..4a212afb --- /dev/null +++ b/playground/search.php @@ -0,0 +1,37 @@ + "User $id", + range(0, $count) + ); + }, + validate: function ($value) { + if ($value === '0') { + return 'User 0 is not allowed to receive emails.'; + } + } +); + +var_dump($model); + +echo str_repeat(PHP_EOL, 6); diff --git a/src/Concerns/Themes.php b/src/Concerns/Themes.php index 0fab18e0..cfdbd5eb 100644 --- a/src/Concerns/Themes.php +++ b/src/Concerns/Themes.php @@ -7,6 +7,7 @@ use Laravel\Prompts\MultiSelectPrompt; use Laravel\Prompts\Note; use Laravel\Prompts\PasswordPrompt; +use Laravel\Prompts\SearchPrompt; use Laravel\Prompts\SelectPrompt; use Laravel\Prompts\Spinner; use Laravel\Prompts\SuggestPrompt; @@ -15,6 +16,7 @@ use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer; use Laravel\Prompts\Themes\Default\NoteRenderer; use Laravel\Prompts\Themes\Default\PasswordPromptRenderer; +use Laravel\Prompts\Themes\Default\SearchPromptRenderer; use Laravel\Prompts\Themes\Default\SelectPromptRenderer; use Laravel\Prompts\Themes\Default\SpinnerRenderer; use Laravel\Prompts\Themes\Default\SuggestPromptRenderer; @@ -39,6 +41,7 @@ trait Themes SelectPrompt::class => SelectPromptRenderer::class, MultiSelectPrompt::class => MultiSelectPromptRenderer::class, ConfirmPrompt::class => ConfirmPromptRenderer::class, + SearchPrompt::class => SearchPromptRenderer::class, SuggestPrompt::class => SuggestPromptRenderer::class, Spinner::class => SpinnerRenderer::class, Note::class => NoteRenderer::class, diff --git a/src/SearchPrompt.php b/src/SearchPrompt.php new file mode 100644 index 00000000..56889156 --- /dev/null +++ b/src/SearchPrompt.php @@ -0,0 +1,151 @@ +|null + */ + protected array|null $matches = null; + + /** + * Create a new SuggestPrompt instance. + * + * @param Closure(string): array $options + */ + public function __construct( + public string $label, + public Closure $options, + public string $placeholder = '', + public int $scroll = 5, + public ?Closure $validate = null, + ) { + $this->trackTypedValue(submit: false); + + $this->on('key', fn ($key) => match ($key) { + Key::UP, Key::SHIFT_TAB => $this->highlightPrevious(), + Key::DOWN, Key::TAB => $this->highlightNext(), + Key::ENTER => $this->highlighted !== null ? $this->submit() : $this->search(), + Key::LEFT, Key::RIGHT => $this->highlighted = null, + default => $this->search(), + }); + } + + protected function search(): void + { + $this->state = 'searching'; + $this->highlighted = null; + $this->render(); + $this->matches = null; + $this->state = 'active'; + } + + /** + * Get the entered value with a virtual cursor. + */ + public function valueWithCursor(int $maxWidth): string + { + if ($this->highlighted !== null) { + return $this->typedValue === '' + ? $this->dim($this->truncate($this->placeholder, $maxWidth)) + : $this->truncate($this->typedValue, $maxWidth); + } + + if ($this->typedValue === '') { + return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth)); + } + + return $this->addCursor($this->typedValue, $this->cursorPosition, $maxWidth); + } + + /** + * Get options that match the input. + * + * @return array + */ + public function matches(): array + { + if (is_array($this->matches)) { + return $this->matches; + } + + return $this->matches = ($this->options)($this->typedValue); + } + + /** + * Highlight the previous entry, or wrap around to the last entry. + */ + protected function highlightPrevious(): void + { + if ($this->matches === []) { + $this->highlighted = null; + } elseif ($this->highlighted === null) { + $this->highlighted = count($this->matches) - 1; + } elseif ($this->highlighted === 0) { + $this->highlighted = null; + } else { + $this->highlighted = $this->highlighted - 1; + } + } + + /** + * Highlight the next entry, or wrap around to the first entry. + */ + protected function highlightNext(): void + { + if ($this->matches === []) { + $this->highlighted = null; + } elseif ($this->highlighted === null) { + $this->highlighted = 0; + } else { + $this->highlighted = $this->highlighted === count($this->matches) - 1 ? null : $this->highlighted + 1; + } + } + + public function searchValue(): string + { + return $this->typedValue; + } + + public function value(): int|string|null + { + if ($this->matches === null || $this->highlighted === null) { + return null; + } + + return array_keys($this->matches)[$this->highlighted]; + } + + /** + * Get the selected label. + */ + public function label(): string + { + return $this->matches[array_keys($this->matches)[$this->highlighted]] ?? null; + } + + /** + * Truncate a value with an ellipsis if it exceeds the given length. + */ + protected function truncate(string $value, int $length): string + { + if ($length <= 0) { + throw new InvalidArgumentException("Length [{$length}] must be greater than zero."); + } + + return mb_strlen($value) <= $length ? $value : (mb_substr($value, 0, $length - 1).'…'); + } +} diff --git a/src/Themes/Default/SearchPromptRenderer.php b/src/Themes/Default/SearchPromptRenderer.php new file mode 100644 index 00000000..de14b495 --- /dev/null +++ b/src/Themes/Default/SearchPromptRenderer.php @@ -0,0 +1,116 @@ +terminal()->cols() - 6; + + return match ($prompt->state) { + 'submit' => $this + ->box( + $this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $this->truncate($prompt->label(), $maxWidth), + ), + + 'cancel' => $this + ->box( + $this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $this->strikethrough($this->dim($this->truncate($prompt->searchValue() ?: $prompt->placeholder, $maxWidth))), + color: 'red', + ) + ->error('Cancelled'), + + 'error' => $this + ->box( + $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), + $prompt->valueWithCursor($maxWidth), + $this->renderOptions($prompt), + color: 'yellow', + ) + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), + + 'searching' => $this + ->box( + $this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $this->valueWithCursorAndSearchIcon($prompt, $maxWidth), + $this->renderOptions($prompt), + ), + + default => $this + ->box( + $this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $prompt->valueWithCursor($maxWidth), + $this->renderOptions($prompt), + ) + ->spaceForDropdown($prompt) + ->newLine(), // Space for errors + }; + } + + /** + * Render the value with the cursor and a search icon. + */ + protected function valueWithCursorAndSearchIcon(SearchPrompt $prompt, int $maxWidth): string + { + return preg_replace( + '/\s$/', + $this->cyan('…'), + $this->pad($prompt->valueWithCursor($maxWidth - 1).' ', min($this->longest($prompt->matches(), padding: 2), $maxWidth)) + ); + } + + /** + * Render a spacer to prevent jumping when the suggestions are displayed. + */ + protected function spaceForDropdown(SearchPrompt $prompt): self + { + if ($prompt->searchValue() !== '') { + return $this; + } + + $this->newLine(max( + 0, + min($prompt->scroll, $prompt->terminal()->lines() - 7) - count($prompt->matches()), + )); + + if ($prompt->matches() === []) { + $this->newLine(); + } + + return $this; + } + + /** + * Render the options. + */ + protected function renderOptions(SearchPrompt $prompt): string + { + if ($prompt->searchValue() !== '' && empty($prompt->matches())) { + return $this->gray(' '.($prompt->state === 'searching' ? 'Searching...' : 'No results.')); + } + + return $this->scroll( + collect($prompt->matches()) + ->values() + ->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10)) + ->map(fn ($label, $i) => $prompt->highlighted === $i + ? "{$this->cyan('›')} {$label} " + : " {$this->dim($label)} " + ), + $prompt->highlighted, + min($prompt->scroll, $prompt->terminal()->lines() - 7), + min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6) + )->implode(PHP_EOL); + } +} diff --git a/src/helpers.php b/src/helpers.php index 1ceb1693..4fd6771d 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -60,6 +60,16 @@ function suggest(string $label, array|Closure $options, string $placeholder = '' return (new SuggestPrompt($label, $options, $placeholder, $default, $scroll, $required, $validate))->prompt(); } +/** + * Allow the user to search for an option. + * + * @param Closure(string): array $options + */ +function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, ?Closure $validate = null): int|string +{ + return (new SearchPrompt($label, $options, $placeholder, $scroll, $validate))->prompt(); +} + /** * Render a spinner while the given callback is executing. * diff --git a/tests/Feature/SearchPromptTest.php b/tests/Feature/SearchPromptTest.php new file mode 100644 index 00000000..65123aa7 --- /dev/null +++ b/tests/Feature/SearchPromptTest.php @@ -0,0 +1,81 @@ + array_filter( + [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + fn ($option) => str_contains(strtolower($option), strtolower($value)), + ), + ); + + expect($result)->toBe('blue'); +}); + +it('can return integer keys', function () { + Prompt::fake(['u', 'e', Key::DOWN, Key::ENTER]); + + $result = search( + label: 'What is your favorite color?', + options: fn (string $value) => array_filter( + [ + 1 => 'Red', + 2 => 'Green', + 3 => 'Blue', + ], + fn ($option) => str_contains(strtolower($option), strtolower($value)), + ), + ); + + expect($result)->toBe(3); +}); + +it('validates', function () { + Prompt::fake([Key::DOWN, Key::ENTER, Key::DOWN, Key::ENTER]); + + $result = search( + label: 'What is your favorite color?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + validate: fn ($value) => $value === 'red' ? 'Please choose green.' : null + ); + + expect($result)->toBe('green'); + + Prompt::assertOutputContains('Please choose green.'); +}); + +it('can fall back', function () { + Prompt::fallbackWhen(true); + + SearchPrompt::fallbackUsing(function (SearchPrompt $prompt) { + expect($prompt->label)->toBe('What is your favorite color?'); + + return 'result'; + }); + + $result = search( + label: 'What is your favorite color?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + ); + + expect($result)->toBe('result'); +}); From 745bf8b90f311cea784b8beca294901d1ea661a1 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Sat, 22 Jul 2023 22:38:22 -0500 Subject: [PATCH 3/3] Allow returning values from the search component --- src/SearchPrompt.php | 4 +++- tests/Feature/SearchPromptTest.php | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/SearchPrompt.php b/src/SearchPrompt.php index 56889156..d5b59f70 100644 --- a/src/SearchPrompt.php +++ b/src/SearchPrompt.php @@ -126,7 +126,9 @@ public function value(): int|string|null return null; } - return array_keys($this->matches)[$this->highlighted]; + return array_is_list($this->matches) + ? $this->matches[$this->highlighted] + : array_keys($this->matches)[$this->highlighted]; } /** diff --git a/tests/Feature/SearchPromptTest.php b/tests/Feature/SearchPromptTest.php index 65123aa7..52f50a47 100644 --- a/tests/Feature/SearchPromptTest.php +++ b/tests/Feature/SearchPromptTest.php @@ -23,7 +23,25 @@ expect($result)->toBe('blue'); }); -it('can return integer keys', function () { +it('returns the value when a list is passed', function () { + Prompt::fake(['u', 'e', Key::DOWN, Key::ENTER]); + + $result = search( + label: 'What is your favorite color?', + options: fn (string $value) => array_values(array_filter( + [ + 'Red', + 'Green', + 'Blue', + ], + fn ($option) => str_contains(strtolower($option), strtolower($value)), + )), + ); + + expect($result)->toBe('Blue'); +}); + +it('returns the key when an associative array is passed', function () { Prompt::fake(['u', 'e', Key::DOWN, Key::ENTER]); $result = search(