diff --git a/.github/ISSUE_TEMPLATE/2_Feature_request.md b/.github/ISSUE_TEMPLATE/2_Feature_request.md deleted file mode 100644 index e530d769..00000000 --- a/.github/ISSUE_TEMPLATE/2_Feature_request.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -name: "Feature request" -about: 'For ideas or feature requests, please make a pull request, or open an issue' ---- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 6253bb2d..1f28d3ff 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Feature request + url: https://github.com/laravel/prompts/pulls + about: 'For ideas or feature requests, send in a pull request' - name: Support Questions & Other url: https://laravel.com/docs/contributions#support-questions about: 'This repository is only for reporting bugs. If you have a question or need help using the library, click:' diff --git a/.github/SECURITY.md b/.github/SECURITY.md index dd673d42..800b8aff 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -15,7 +15,7 @@ If you discover a security vulnerability within Laravel, please send an email to ``` -----BEGIN PGP PUBLIC KEY BLOCK----- Version: OpenPGP v2.0.8 -Comment: https://sela.io/pgp/ +Comment: Report Security Vulnerabilities to taylor@laravel.com xsFNBFugFSQBEACxEKhIY9IoJzcouVTIYKJfWFGvwFgbRjQWBiH3QdHId5vCrbWo s2l+4Rv03gMG+yHLJ3rWElnNdRaNdQv59+lShrZF7Bvu7Zvc0mMNmFOM/mQ/K2Lt diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e79fdd..feedc4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,51 @@ # Release Notes -## [Unreleased](https://github.com/laravel/prompts/compare/v0.1.18...main) +## [Unreleased](https://github.com/laravel/prompts/compare/v0.2.1...main) + +## [v0.2.1](https://github.com/laravel/prompts/compare/v0.2.0...v0.2.1) - 2024-09-19 + +* [ BugFix ] Handle Failed Terminal Read Gracefully by [@ProjektGopher](https://github.com/ProjektGopher) in https://github.com/laravel/prompts/pull/164 +* Update `dev-main` branch alias to `0.2.x-dev` by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/prompts/pull/166 + +## [v0.2.0](https://github.com/laravel/prompts/compare/v0.1.25...v0.2.0) - 2024-09-11 + +* Adding "Clear" Function For Cleaning The Terminal by [@TarsisioXavier](https://github.com/TarsisioXavier) in https://github.com/laravel/prompts/pull/160 +* Extract Looping Mechanisms by [@ProjektGopher](https://github.com/ProjektGopher) in https://github.com/laravel/prompts/pull/162 + +## [v0.1.25](https://github.com/laravel/prompts/compare/v0.1.24...v0.1.25) - 2024-08-12 + +* Add transformation support by [@emenkens](https://github.com/emenkens) in https://github.com/laravel/prompts/pull/156 +* Fix textarea helper method signature. by [@samrap](https://github.com/samrap) in https://github.com/laravel/prompts/pull/159 + +## [v0.1.24](https://github.com/laravel/prompts/compare/v0.1.23...v0.1.24) - 2024-06-17 + +* Allow re-rendering during progress callback by [@jessarcher](https://github.com/jessarcher) in https://github.com/laravel/prompts/pull/155 + +## [v0.1.23](https://github.com/laravel/prompts/compare/v0.1.22...v0.1.23) - 2024-05-27 + +* Ignore PHPstan error by [@jessarcher](https://github.com/jessarcher) in https://github.com/laravel/prompts/pull/149 +* Allow selecting all options in a `multiselect` by [@duncanmcclean](https://github.com/duncanmcclean) in https://github.com/laravel/prompts/pull/147 + +## [v0.1.22](https://github.com/laravel/prompts/compare/v0.1.21...v0.1.22) - 2024-05-10 + +* fix(helper): ensure helpers can't be redeclared by [@NickSdot](https://github.com/NickSdot) in https://github.com/laravel/prompts/pull/146 + +## [v0.1.21](https://github.com/laravel/prompts/compare/v0.1.20...v0.1.21) - 2024-04-30 + +* Add description to composer.json by [@edwinvdpol](https://github.com/edwinvdpol) in https://github.com/laravel/prompts/pull/139 +* Add ability to specify character in pad function by [@ProjektGopher](https://github.com/ProjektGopher) in https://github.com/laravel/prompts/pull/141 +* Adds support for additional keys by [@ProjektGopher](https://github.com/ProjektGopher) in https://github.com/laravel/prompts/pull/140 +* Extract string handling methods from DrawsBoxes trait by [@ProjektGopher](https://github.com/ProjektGopher) in https://github.com/laravel/prompts/pull/142 + +## [v0.1.20](https://github.com/laravel/prompts/compare/v0.1.19...v0.1.20) - 2024-04-18 + +* Fix for up/down arrows + cursor position when textarea content contains multi-byte strings by [@joetannenbaum](https://github.com/joetannenbaum) in https://github.com/laravel/prompts/pull/137 + +## [v0.1.19](https://github.com/laravel/prompts/compare/v0.1.18...v0.1.19) - 2024-04-16 + +* Fix `multisearch` array handling by [@jessarcher](https://github.com/jessarcher) in https://github.com/laravel/prompts/pull/132 +* Adds Reversible Forms to Prompts by [@lukeraymonddowning](https://github.com/lukeraymonddowning) in https://github.com/laravel/prompts/pull/118 +* Fix type error in suggest with collection by [@macocci7](https://github.com/macocci7) in https://github.com/laravel/prompts/pull/134 ## [v0.1.18](https://github.com/laravel/prompts/compare/v0.1.17...v0.1.18) - 2024-04-04 diff --git a/composer.json b/composer.json index e3629883..e638394c 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "laravel/prompts", "type": "library", + "description": "Add beautiful and user-friendly forms to your command-line applications.", "license": "MIT", "autoload": { "psr-4": { @@ -41,7 +42,7 @@ }, "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.2.x-dev" } }, "prefer-stable": true, diff --git a/phpunit.xml b/phpunit.xml index cc33dea5..9520ed4e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + ./tests diff --git a/playground/clear.php b/playground/clear.php new file mode 100644 index 00000000..858e5999 --- /dev/null +++ b/playground/clear.php @@ -0,0 +1,19 @@ + null, }, hint: 'We will never share your email address with anyone else.', + transform: fn ($value) => strtolower($value), ); var_dump($email); diff --git a/playground/textarea.php b/playground/textarea.php index 070305f2..9e498cef 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -4,11 +4,11 @@ require __DIR__.'/../vendor/autoload.php'; -$email = textarea( +$story = textarea( label: 'Tell me a story', placeholder: 'Weave me a tale', ); -var_dump($email); +var_dump($story); echo str_repeat(PHP_EOL, 5); diff --git a/src/Clear.php b/src/Clear.php new file mode 100644 index 00000000..ebc57906 --- /dev/null +++ b/src/Clear.php @@ -0,0 +1,35 @@ +write(PHP_EOL.PHP_EOL); + + $this->writeDirectly($this->renderTheme()); + + return true; + } + + /** + * Clear the terminal. + */ + public function display(): void + { + $this->prompt(); + } + + /** + * Get the value of the prompt. + */ + public function value(): bool + { + return true; + } +} diff --git a/src/Concerns/FakesInputOutput.php b/src/Concerns/FakesInputOutput.php index e136db1d..2f4dd74b 100644 --- a/src/Concerns/FakesInputOutput.php +++ b/src/Concerns/FakesInputOutput.php @@ -29,13 +29,30 @@ public static function fake(array $keys = []): void $mock->shouldReceive('lines')->byDefault()->andReturn(24); $mock->shouldReceive('initDimensions')->byDefault(); - foreach ($keys as $key) { + static::fakeKeyPresses($keys, function (string $key) use ($mock): void { $mock->shouldReceive('read')->once()->andReturn($key); - } + }); static::$terminal = $mock; - self::setOutput(new BufferedConsoleOutput()); + self::setOutput(new BufferedConsoleOutput); + } + + /** + * Implementation of the looping mechanism for simulating key presses. + * + * By ignoring the `$callable` parameter which contains the default logic + * for simulating fake key presses, we can use a custom implementation + * to emit key presses instead, allowing us to use different inputs. + * + * @param array $keys + * @param callable(string $key): void $callable + */ + public static function fakeKeyPresses(array $keys, callable $callable): void + { + foreach ($keys as $key) { + $callable($key); + } } /** diff --git a/src/Concerns/Termwind.php b/src/Concerns/Termwind.php index 798348c6..301776b1 100644 --- a/src/Concerns/Termwind.php +++ b/src/Concerns/Termwind.php @@ -11,7 +11,7 @@ trait Termwind { protected function termwind(string $html) { - renderUsing($output = new BufferedConsoleOutput()); + renderUsing($output = new BufferedConsoleOutput); render($html); diff --git a/src/Concerns/Themes.php b/src/Concerns/Themes.php index bc8afd88..93f2874c 100644 --- a/src/Concerns/Themes.php +++ b/src/Concerns/Themes.php @@ -3,6 +3,7 @@ namespace Laravel\Prompts\Concerns; use InvalidArgumentException; +use Laravel\Prompts\Clear; use Laravel\Prompts\ConfirmPrompt; use Laravel\Prompts\MultiSearchPrompt; use Laravel\Prompts\MultiSelectPrompt; @@ -17,6 +18,7 @@ use Laravel\Prompts\Table; use Laravel\Prompts\TextareaPrompt; use Laravel\Prompts\TextPrompt; +use Laravel\Prompts\Themes\Default\ClearRenderer; use Laravel\Prompts\Themes\Default\ConfirmPromptRenderer; use Laravel\Prompts\Themes\Default\MultiSearchPromptRenderer; use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer; @@ -60,6 +62,7 @@ trait Themes Note::class => NoteRenderer::class, Table::class => TableRenderer::class, Progress::class => ProgressRenderer::class, + Clear::class => ClearRenderer::class, ], ]; diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 56d356ad..c4cddbd6 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -27,8 +27,10 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ?c $this->cursorPosition = mb_strlen($this->typedValue); } - $this->on('key', function ($key) use ($submit, $ignore, $allowNewLine) { - if ($key[0] === "\e" || in_array($key, [Key::CTRL_B, Key::CTRL_F, Key::CTRL_A, Key::CTRL_E])) { + $this->on('key', function (string $key) use ($submit, $ignore, $allowNewLine): void { + if ($key !== '' && + ($key[0] === "\e" || in_array($key, [Key::CTRL_B, Key::CTRL_F, Key::CTRL_A, Key::CTRL_E])) + ) { if ($ignore !== null && $ignore($key)) { return; } diff --git a/src/ConfirmPrompt.php b/src/ConfirmPrompt.php index 23b8a97f..3abccf07 100644 --- a/src/ConfirmPrompt.php +++ b/src/ConfirmPrompt.php @@ -2,6 +2,8 @@ namespace Laravel\Prompts; +use Closure; + class ConfirmPrompt extends Prompt { /** @@ -20,6 +22,7 @@ public function __construct( public bool|string $required = false, public mixed $validate = null, public string $hint = '', + public ?Closure $transform = null, ) { $this->confirmed = $default; diff --git a/src/FormBuilder.php b/src/FormBuilder.php index 621c19b8..4e5bd5d2 100644 --- a/src/FormBuilder.php +++ b/src/FormBuilder.php @@ -78,7 +78,7 @@ public function submit(): array /** * Prompt the user for text input. */ - public function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null): self + public function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(text(...), get_defined_vars()); } @@ -86,7 +86,7 @@ public function text(string $label, string $placeholder = '', string $default = /** * Prompt the user for multiline text input. */ - public function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = '', int $rows = 5, ?string $name = null): self + public function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = '', int $rows = 5, ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(textarea(...), get_defined_vars()); } @@ -94,7 +94,7 @@ public function textarea(string $label, string $placeholder = '', string $defaul /** * Prompt the user for input, hiding the value. */ - public function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null): self + public function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(password(...), get_defined_vars()); } @@ -105,7 +105,7 @@ public function password(string $label, string $placeholder = '', bool|string $r * @param array|Collection $options * @param true|string $required */ - public function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?string $name = null): self + public function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(select(...), get_defined_vars()); } @@ -116,7 +116,7 @@ public function select(string $label, array|Collection $options, int|string|null * @param array|Collection $options * @param array|Collection $default */ - public function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?string $name = null): self + public function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(multiselect(...), get_defined_vars()); } @@ -124,7 +124,7 @@ public function multiselect(string $label, array|Collection $options, array|Coll /** * Prompt the user to confirm an action. */ - public function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null): self + public function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(confirm(...), get_defined_vars()); } @@ -142,7 +142,7 @@ public function pause(string $message = 'Press enter to continue...', ?string $n * * @param array|Collection|Closure(string): array $options */ - public function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null): self + public function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(suggest(...), get_defined_vars()); } @@ -153,7 +153,7 @@ public function suggest(string $label, array|Collection|Closure $options, string * @param Closure(string): array $options * @param true|string $required */ - public function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?string $name = null): self + public function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(search(...), get_defined_vars()); } @@ -163,7 +163,7 @@ public function search(string $label, Closure $options, string $placeholder = '' * * @param Closure(string): array $options */ - public function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?string $name = null): self + public function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?string $name = null, ?Closure $transform = null): self { return $this->runPrompt(multisearch(...), get_defined_vars()); } diff --git a/src/Key.php b/src/Key.php index 9d6a0735..28d0ddc0 100644 --- a/src/Key.php +++ b/src/Key.php @@ -6,8 +6,12 @@ class Key { const UP = "\e[A"; + const SHIFT_UP = "\e[1;2A"; + const DOWN = "\e[B"; + const SHIFT_DOWN = "\e[1;2B"; + const RIGHT = "\e[C"; const LEFT = "\e[D"; @@ -20,6 +24,8 @@ class Key const LEFT_ARROW = "\eOD"; + const ESCAPE = "\e"; + const DELETE = "\e[3~"; const BACKSPACE = "\177"; diff --git a/src/MultiSearchPrompt.php b/src/MultiSearchPrompt.php index 86ea336c..083f56b9 100644 --- a/src/MultiSearchPrompt.php +++ b/src/MultiSearchPrompt.php @@ -42,6 +42,7 @@ public function __construct( public bool|string $required = false, public mixed $validate = null, public string $hint = '', + public ?Closure $transform = null, ) { $this->trackTypedValue(submit: false, ignore: fn ($key) => Key::oneOf([Key::SPACE, Key::HOME, Key::END, Key::CTRL_A, Key::CTRL_E], $key) && $this->highlighted !== null); @@ -50,9 +51,11 @@ public function __construct( $this->on('key', fn ($key) => match ($key) { Key::UP, Key::UP_ARROW, Key::SHIFT_TAB => $this->highlightPrevious(count($this->matches), true), Key::DOWN, Key::DOWN_ARROW, Key::TAB => $this->highlightNext(count($this->matches), true), - Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlighted !== null ? $this->highlight(0) : null, - Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlighted !== null ? $this->highlight(count($this->matches()) - 1) : null, + Key::oneOf(Key::HOME, $key) => $this->highlighted !== null ? $this->highlight(0) : null, + Key::oneOf(Key::END, $key) => $this->highlighted !== null ? $this->highlight(count($this->matches()) - 1) : null, Key::SPACE => $this->highlighted !== null ? $this->toggleHighlighted() : null, + Key::CTRL_A => $this->highlighted !== null ? $this->toggleAll() : null, + Key::CTRL_E => null, Key::ENTER => $this->submit(), Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW => $this->highlighted = null, default => $this->search(), @@ -132,6 +135,27 @@ public function visible(): array return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true); } + /** + * Toggle all options. + */ + protected function toggleAll(): void + { + $allMatchesSelected = collect($this->matches)->every(fn ($label, $key) => $this->isList() + ? array_key_exists($label, $this->values) + : array_key_exists($key, $this->values)); + + if ($allMatchesSelected) { + $this->values = array_filter($this->values, fn ($value) => $this->isList() + ? ! in_array($value, $this->matches) + : ! array_key_exists(array_search($value, $this->matches), $this->matches) + ); + } else { + $this->values = $this->isList() + ? array_merge($this->values, array_combine(array_values($this->matches), array_values($this->matches))) + : array_merge($this->values, array_combine(array_keys($this->matches), array_values($this->matches))); + } + } + /** * Toggle the highlighted entry. */ diff --git a/src/MultiSelectPrompt.php b/src/MultiSelectPrompt.php index b2f0c704..c28bedf8 100644 --- a/src/MultiSelectPrompt.php +++ b/src/MultiSelectPrompt.php @@ -2,6 +2,7 @@ namespace Laravel\Prompts; +use Closure; use Illuminate\Support\Collection; class MultiSelectPrompt extends Prompt @@ -43,6 +44,7 @@ public function __construct( public bool|string $required = false, public mixed $validate = null, public string $hint = '', + public ?Closure $transform = null, ) { $this->options = $options instanceof Collection ? $options->all() : $options; $this->default = $default instanceof Collection ? $default->all() : $default; @@ -53,9 +55,10 @@ public function __construct( $this->on('key', fn ($key) => match ($key) { Key::UP, Key::UP_ARROW, Key::LEFT, Key::LEFT_ARROW, Key::SHIFT_TAB, Key::CTRL_P, Key::CTRL_B, 'k', 'h' => $this->highlightPrevious(count($this->options)), Key::DOWN, Key::DOWN_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::TAB, Key::CTRL_N, Key::CTRL_F, 'j', 'l' => $this->highlightNext(count($this->options)), - Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlight(0), - Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlight(count($this->options) - 1), + Key::oneOf(Key::HOME, $key) => $this->highlight(0), + Key::oneOf(Key::END, $key) => $this->highlight(count($this->options) - 1), Key::SPACE => $this->toggleHighlighted(), + Key::CTRL_A => $this->toggleAll(), Key::ENTER => $this->submit(), default => null, }); @@ -115,6 +118,20 @@ public function isSelected(string $value): bool return in_array($value, $this->values); } + /** + * Toggle all options. + */ + protected function toggleAll(): void + { + if (count($this->values) === count($this->options)) { + $this->values = []; + } else { + $this->values = array_is_list($this->options) + ? array_values($this->options) + : array_keys($this->options); + } + } + /** * Toggle the highlighted entry. */ diff --git a/src/PasswordPrompt.php b/src/PasswordPrompt.php index 31802c1e..41b755a6 100644 --- a/src/PasswordPrompt.php +++ b/src/PasswordPrompt.php @@ -2,6 +2,8 @@ namespace Laravel\Prompts; +use Closure; + class PasswordPrompt extends Prompt { use Concerns\TypedValue; @@ -15,6 +17,7 @@ public function __construct( public bool|string $required = false, public mixed $validate = null, public string $hint = '', + public ?Closure $transform = null, ) { $this->trackTypedValue(); } diff --git a/src/Progress.php b/src/Progress.php index b713dffb..3d2a345f 100644 --- a/src/Progress.php +++ b/src/Progress.php @@ -138,6 +138,14 @@ public function finish(): void $this->resetSignals(); } + /** + * Force the progress bar to re-render. + */ + public function render(): void + { + parent::render(); + } + /** * Update the label. */ diff --git a/src/Prompt.php b/src/Prompt.php index 1d0709c9..324d7899 100644 --- a/src/Prompt.php +++ b/src/Prompt.php @@ -5,6 +5,7 @@ use Closure; use Laravel\Prompts\Exceptions\FormRevertedException; use Laravel\Prompts\Output\ConsoleOutput; +use Laravel\Prompts\Support\Result; use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -50,6 +51,11 @@ abstract class Prompt */ public bool|string $required; + /** + * The transformation callback. + */ + public ?Closure $transform = null; + /** * The validator callback or rules. */ @@ -122,7 +128,7 @@ public function prompt(): mixed $this->hideCursor(); $this->render(); - while (($key = static::terminal()->read()) !== null) { + $result = $this->runLoop(function (string $key): ?Result { $continue = $this->handleKeyPress($key); $this->render(); @@ -130,24 +136,54 @@ public function prompt(): mixed if ($continue === false || $key === Key::CTRL_C) { if ($key === Key::CTRL_C) { if (isset(static::$cancelUsing)) { - return (static::$cancelUsing)(); + return Result::from((static::$cancelUsing)()); } else { static::terminal()->exit(); } } if ($key === Key::CTRL_U && self::$revertUsing) { - throw new FormRevertedException(); + throw new FormRevertedException; } - return $this->value(); + return Result::from($this->transformedValue()); } - } + + // Continue looping. + return null; + }); + + return $result; } finally { $this->clearListeners(); } } + /** + * Implementation of the prompt looping mechanism. + * + * @param callable(string $key): ?Result $callable + */ + public function runLoop(callable $callable): mixed + { + while (($key = static::terminal()->read()) !== null) { + /** + * If $key is an empty string, Terminal::read + * has failed. We can continue to the next + * iteration of the loop, and try again. + */ + if ($key === '') { + continue; + } + + $result = $callable($key); + + if ($result instanceof Result) { + return $result->value; + } + } + } + /** * Register a callback to be invoked when a user cancels a prompt. */ @@ -187,7 +223,7 @@ public static function setOutput(OutputInterface $output): void */ protected static function output(): OutputInterface { - return self::$output ??= new ConsoleOutput(); + return self::$output ??= new ConsoleOutput; } /** @@ -207,7 +243,7 @@ protected static function writeDirectly(string $message): void */ public static function terminal(): Terminal { - return static::$terminal ??= new Terminal(); + return static::$terminal ??= new Terminal; } /** @@ -277,7 +313,7 @@ protected function render(): void */ protected function submit(): void { - $this->validate($this->value()); + $this->validate($this->transformedValue()); if ($this->state !== 'error') { $this->state = 'submit'; @@ -322,12 +358,32 @@ private function handleKeyPress(string $key): bool } if ($this->validated) { - $this->validate($this->value()); + $this->validate($this->transformedValue()); } return true; } + /** + * Transform the input. + */ + private function transform(mixed $value): mixed + { + if (is_null($this->transform)) { + return $value; + } + + return call_user_func($this->transform, $value); + } + + /** + * Get the transformed value of the prompt. + */ + protected function transformedValue(): mixed + { + return $this->transform($this->value()); + } + /** * Validate the input. */ diff --git a/src/SearchPrompt.php b/src/SearchPrompt.php index b9b820ce..259b4299 100644 --- a/src/SearchPrompt.php +++ b/src/SearchPrompt.php @@ -31,6 +31,7 @@ public function __construct( public mixed $validate = null, public string $hint = '', public bool|string $required = true, + public ?Closure $transform = null, ) { if ($this->required === false) { throw new InvalidArgumentException('Argument [required] must be true or a string.'); diff --git a/src/SelectPrompt.php b/src/SelectPrompt.php index 081f09c7..8d48a730 100644 --- a/src/SelectPrompt.php +++ b/src/SelectPrompt.php @@ -2,6 +2,7 @@ namespace Laravel\Prompts; +use Closure; use Illuminate\Support\Collection; use InvalidArgumentException; @@ -29,6 +30,7 @@ public function __construct( public mixed $validate = null, public string $hint = '', public bool|string $required = true, + public ?Closure $transform = null, ) { if ($this->required === false) { throw new InvalidArgumentException('Argument [required] must be true or a string.'); diff --git a/src/SuggestPrompt.php b/src/SuggestPrompt.php index dd8d2553..73efbdca 100644 --- a/src/SuggestPrompt.php +++ b/src/SuggestPrompt.php @@ -39,6 +39,7 @@ public function __construct( public bool|string $required = false, public mixed $validate = null, public string $hint = '', + public ?Closure $transform = null, ) { $this->options = $options instanceof Collection ? $options->all() : $options; diff --git a/src/Support/Result.php b/src/Support/Result.php new file mode 100644 index 00000000..42a30be7 --- /dev/null +++ b/src/Support/Result.php @@ -0,0 +1,22 @@ +terminal = new SymfonyTerminal(); + $this->terminal = new SymfonyTerminal; } /** diff --git a/src/TextPrompt.php b/src/TextPrompt.php index 74a41a5e..db63f81b 100644 --- a/src/TextPrompt.php +++ b/src/TextPrompt.php @@ -2,6 +2,8 @@ namespace Laravel\Prompts; +use Closure; + class TextPrompt extends Prompt { use Concerns\TypedValue; @@ -16,6 +18,7 @@ public function __construct( public bool|string $required = false, public mixed $validate = null, public string $hint = '', + public ?Closure $transform = null, ) { $this->trackTypedValue($default); } diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 19a94e6b..e5a33666 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -2,6 +2,8 @@ namespace Laravel\Prompts; +use Closure; + class TextareaPrompt extends Prompt { use Concerns\Scrolling; @@ -24,6 +26,7 @@ public function __construct( public mixed $validate = null, public string $hint = '', int $rows = 5, + public ?Closure $transform = null, ) { $this->scroll = $rows; @@ -113,7 +116,7 @@ protected function handleUpKey(): void $lines = collect($this->lines()); // Line length + 1 for the newline character - $lineLengths = $lines->map(fn ($line, $index) => mb_strwidth($line) + ($index === $lines->count() - 1 ? 0 : 1)); + $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); $currentLineIndex = $this->currentLineIndex(); @@ -145,13 +148,13 @@ protected function handleDownKey(): void $lines = collect($this->lines()); // Line length + 1 for the newline character - $lineLengths = $lines->map(fn ($line, $index) => mb_strwidth($line) + ($index === $lines->count() - 1 ? 0 : 1)); + $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); $currentLineIndex = $this->currentLineIndex(); if ($currentLineIndex === $lines->count() - 1) { // They're already at the last line, jump them to the last position - $this->cursorPosition = mb_strwidth($lines->implode(PHP_EOL)); + $this->cursorPosition = mb_strlen($lines->implode(PHP_EOL)); return; } @@ -205,7 +208,7 @@ protected function currentLineIndex(): int $totalLineLength = 0; return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) { - $totalLineLength += mb_strwidth($line) + 1; + $totalLineLength += mb_strlen($line) + 1; return $totalLineLength > $this->cursorPosition; }) ?: 0; diff --git a/src/Themes/Default/ClearRenderer.php b/src/Themes/Default/ClearRenderer.php new file mode 100644 index 00000000..418cc2b3 --- /dev/null +++ b/src/Themes/Default/ClearRenderer.php @@ -0,0 +1,14 @@ + $lines - */ - protected function longest(array $lines, int $padding = 0): int - { - return max( - $this->minWidth, - collect($lines) - ->map(fn ($line) => mb_strwidth($this->stripEscapeSequences($line)) + $padding) - ->max() - ); - } - - /** - * Pad text ignoring ANSI escape sequences. - */ - protected function pad(string $text, int $length): string - { - $rightPadding = str_repeat(' ', max(0, $length - mb_strwidth($this->stripEscapeSequences($text)))); - - return "{$text}{$rightPadding}"; - } - - /** - * Strip ANSI escape sequences from the given text. - */ - protected function stripEscapeSequences(string $text): string - { - // Strip ANSI escape sequences. - $text = preg_replace("/\e[^m]*m/", '', $text); - - // Strip Symfony named style tags. - $text = preg_replace("/<(info|comment|question|error)>(.*?)<\/\\1>/", '$2', $text); - - // Strip Symfony inline style tags. - return preg_replace("/<(?:(?:[fb]g|options)=[a-z,;]+)+>(.*?)<\/>/i", '$1', $text); - } } diff --git a/src/Themes/Default/Concerns/DrawsScrollbars.php b/src/Themes/Default/Concerns/DrawsScrollbars.php index 13836994..bb32f00c 100644 --- a/src/Themes/Default/Concerns/DrawsScrollbars.php +++ b/src/Themes/Default/Concerns/DrawsScrollbars.php @@ -20,7 +20,7 @@ protected function scrollbar(Collection $visible, int $firstVisible, int $height $scrollPosition = $this->scrollPosition($firstVisible, $height, $total); - return $visible + return $visible // @phpstan-ignore return.type ->values() ->map(fn ($line) => $this->pad($line, $width)) ->map(fn ($line, $index) => match ($index) { diff --git a/src/Themes/Default/Concerns/InteractsWithStrings.php b/src/Themes/Default/Concerns/InteractsWithStrings.php new file mode 100644 index 00000000..25a2363f --- /dev/null +++ b/src/Themes/Default/Concerns/InteractsWithStrings.php @@ -0,0 +1,46 @@ + $lines + */ + protected function longest(array $lines, int $padding = 0): int + { + return max( + $this->minWidth, + collect($lines) + ->map(fn ($line) => mb_strwidth($this->stripEscapeSequences($line)) + $padding) + ->max() + ); + } + + /** + * Pad text ignoring ANSI escape sequences. + */ + protected function pad(string $text, int $length, string $char = ' '): string + { + $rightPadding = str_repeat($char, max(0, $length - mb_strwidth($this->stripEscapeSequences($text)))); + + return "{$text}{$rightPadding}"; + } + + /** + * Strip ANSI escape sequences from the given text. + */ + protected function stripEscapeSequences(string $text): string + { + // Strip ANSI escape sequences. + $text = preg_replace("/\e[^m]*m/", '', $text); + + // Strip Symfony named style tags. + $text = preg_replace("/<(info|comment|question|error)>(.*?)<\/\\1>/", '$2', $text); + + // Strip Symfony inline style tags. + return preg_replace("/<(?:(?:[fb]g|options)=[a-z,;]+)+>(.*?)<\/>/i", '$1', $text); + } +} diff --git a/src/Themes/Default/TableRenderer.php b/src/Themes/Default/TableRenderer.php index 185f4500..c2d17bb9 100644 --- a/src/Themes/Default/TableRenderer.php +++ b/src/Themes/Default/TableRenderer.php @@ -14,7 +14,7 @@ class TableRenderer extends Renderer */ public function __invoke(Table $table): string { - $tableStyle = (new TableStyle()) + $tableStyle = (new TableStyle) ->setHorizontalBorderChars('─') ->setVerticalBorderChars('│', '│') ->setCellHeaderFormat($this->dim('%s')) @@ -26,7 +26,7 @@ public function __invoke(Table $table): string $tableStyle->setCrossingChars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├'); } - $buffered = new BufferedConsoleOutput(); + $buffered = new BufferedConsoleOutput; (new SymfonyTable($buffered)) ->setHeaders($table->headers) diff --git a/src/helpers.php b/src/helpers.php index d1454ad2..8965cb8d 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -5,203 +5,255 @@ use Closure; use Illuminate\Support\Collection; -/** - * Prompt the user for text input. - */ -function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = ''): string -{ - return (new TextPrompt(...func_get_args()))->prompt(); -} - -/** - * Prompt the user for multiline text input. - */ -function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = '', int $rows = 5): string -{ - return (new TextareaPrompt($label, $placeholder, $default, $required, $validate, $hint, $rows))->prompt(); -} - -/** - * Prompt the user for input, hiding the value. - */ -function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = ''): string -{ - return (new PasswordPrompt(...func_get_args()))->prompt(); -} - -/** - * Prompt the user to select an option. - * - * @param array|Collection $options - * @param true|string $required - */ -function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true): int|string -{ - return (new SelectPrompt(...func_get_args()))->prompt(); -} - -/** - * Prompt the user to select multiple options. - * - * @param array|Collection $options - * @param array|Collection $default - * @return array - */ -function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array -{ - return (new MultiSelectPrompt(...func_get_args()))->prompt(); -} - -/** - * Prompt the user to confirm an action. - */ -function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = ''): bool -{ - return (new ConfirmPrompt(...func_get_args()))->prompt(); -} - -/** - * Prompt the user to continue or cancel after pausing. - */ -function pause(string $message = 'Press enter to continue...'): bool -{ - return (new PausePrompt(...func_get_args()))->prompt(); -} - -/** - * Prompt the user for text input with auto-completion. - * - * @param array|Collection|Closure(string): array $options - */ -function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = ''): string -{ - return (new SuggestPrompt(...func_get_args()))->prompt(); -} - -/** - * Allow the user to search for an option. - * - * @param Closure(string): array $options - * @param true|string $required - */ -function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true): int|string -{ - return (new SearchPrompt(...func_get_args()))->prompt(); -} - -/** - * Allow the user to search for multiple option. - * - * @param Closure(string): array $options - * @return array - */ -function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array -{ - return (new MultiSearchPrompt(...func_get_args()))->prompt(); -} - -/** - * Render a spinner while the given callback is executing. - * - * @template TReturn of mixed - * - * @param \Closure(): TReturn $callback - * @return TReturn - */ -function spin(Closure $callback, string $message = ''): mixed -{ - return (new Spinner($message))->spin($callback); -} - -/** - * Display a note. - */ -function note(string $message, ?string $type = null): void -{ - (new Note($message, $type))->display(); -} - -/** - * Display an error. - */ -function error(string $message): void -{ - (new Note($message, 'error'))->display(); -} - -/** - * Display a warning. - */ -function warning(string $message): void -{ - (new Note($message, 'warning'))->display(); -} - -/** - * Display an alert. - */ -function alert(string $message): void -{ - (new Note($message, 'alert'))->display(); -} - -/** - * Display an informational message. - */ -function info(string $message): void -{ - (new Note($message, 'info'))->display(); -} - -/** - * Display an introduction. - */ -function intro(string $message): void -{ - (new Note($message, 'intro'))->display(); -} - -/** - * Display a closing message. - */ -function outro(string $message): void -{ - (new Note($message, 'outro'))->display(); -} - -/** - * Display a table. - * - * @param array>|Collection> $headers - * @param array>|Collection> $rows - */ -function table(array|Collection $headers = [], array|Collection|null $rows = null): void -{ - (new Table($headers, $rows))->display(); -} - -/** - * Display a progress bar. - * - * @template TSteps of iterable|int - * @template TReturn - * - * @param TSteps $steps - * @param ?Closure((TSteps is int ? int : value-of), Progress): TReturn $callback - * @return ($callback is null ? Progress : array) - */ -function progress(string $label, iterable|int $steps, ?Closure $callback = null, string $hint = ''): array|Progress -{ - $progress = new Progress($label, $steps, $hint); - - if ($callback !== null) { - return $progress->map($callback); - } - - return $progress; -} - -function form(): FormBuilder -{ - return new FormBuilder(); +if (! function_exists('\Laravel\Prompts\text')) { + /** + * Prompt the user for text input. + */ + function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): string + { + return (new TextPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\textarea')) { + /** + * Prompt the user for multiline text input. + */ + function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', int $rows = 5, ?Closure $transform = null): string + { + return (new TextareaPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\password')) { + /** + * Prompt the user for input, hiding the value. + */ + function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): string + { + return (new PasswordPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\select')) { + /** + * Prompt the user to select an option. + * + * @param array|Collection $options + * @param true|string $required + */ + function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?Closure $transform = null): int|string + { + return (new SelectPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\multiselect')) { + /** + * Prompt the user to select multiple options. + * + * @param array|Collection $options + * @param array|Collection $default + * @return array + */ + function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?Closure $transform = null): array + { + return (new MultiSelectPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\confirm')) { + /** + * Prompt the user to confirm an action. + */ + function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): bool + { + return (new ConfirmPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\pause')) { + /** + * Prompt the user to continue or cancel after pausing. + */ + function pause(string $message = 'Press enter to continue...'): bool + { + return (new PausePrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\clear')) { + /** + * Clear the terminal. + */ + function clear(): void + { + (new Clear)->display(); + } +} + +if (! function_exists('\Laravel\Prompts\suggest')) { + /** + * Prompt the user for text input with auto-completion. + * + * @param array|Collection|Closure(string): array $options + */ + function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): string + { + return (new SuggestPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\search')) { + /** + * Allow the user to search for an option. + * + * @param Closure(string): array $options + * @param true|string $required + */ + function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?Closure $transform = null): int|string + { + return (new SearchPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\multisearch')) { + /** + * Allow the user to search for multiple option. + * + * @param Closure(string): array $options + * @return array + */ + function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?Closure $transform = null): array + { + return (new MultiSearchPrompt(...func_get_args()))->prompt(); + } +} + +if (! function_exists('\Laravel\Prompts\spin')) { + /** + * Render a spinner while the given callback is executing. + * + * @template TReturn of mixed + * + * @param \Closure(): TReturn $callback + * @return TReturn + */ + function spin(Closure $callback, string $message = ''): mixed + { + return (new Spinner($message))->spin($callback); + } +} + +if (! function_exists('\Laravel\Prompts\note')) { + /** + * Display a note. + */ + function note(string $message, ?string $type = null): void + { + (new Note($message, $type))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\error')) { + /** + * Display an error. + */ + function error(string $message): void + { + (new Note($message, 'error'))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\warning')) { + /** + * Display a warning. + */ + function warning(string $message): void + { + (new Note($message, 'warning'))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\alert')) { + /** + * Display an alert. + */ + function alert(string $message): void + { + (new Note($message, 'alert'))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\info')) { + /** + * Display an informational message. + */ + function info(string $message): void + { + (new Note($message, 'info'))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\intro')) { + /** + * Display an introduction. + */ + function intro(string $message): void + { + (new Note($message, 'intro'))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\outro')) { + /** + * Display a closing message. + */ + function outro(string $message): void + { + (new Note($message, 'outro'))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\table')) { + /** + * Display a table. + * + * @param array>|Collection> $headers + * @param array>|Collection> $rows + */ + function table(array|Collection $headers = [], array|Collection|null $rows = null): void + { + (new Table($headers, $rows))->display(); + } +} + +if (! function_exists('\Laravel\Prompts\progress')) { + /** + * Display a progress bar. + * + * @template TSteps of iterable|int + * @template TReturn + * + * @param TSteps $steps + * @param ?Closure((TSteps is int ? int : value-of), Progress): TReturn $callback + * @return ($callback is null ? Progress : array) + */ + function progress(string $label, iterable|int $steps, ?Closure $callback = null, string $hint = ''): array|Progress + { + $progress = new Progress($label, $steps, $hint); + + if ($callback !== null) { + return $progress->map($callback); + } + + return $progress; + } +} + +if (! function_exists('\Laravel\Prompts\form')) { + function form(): FormBuilder + { + return new FormBuilder; + } } diff --git a/tests/Feature/ClearPromptTest.php b/tests/Feature/ClearPromptTest.php new file mode 100644 index 00000000..78e196ad --- /dev/null +++ b/tests/Feature/ClearPromptTest.php @@ -0,0 +1,13 @@ + ! $value, + ); + + expect($result)->toBeFalse(); +}); + it('validates', function () { Prompt::fake([Key::ENTER, 'y', Key::ENTER]); diff --git a/tests/Feature/MultiSearchPromptTest.php b/tests/Feature/MultiSearchPromptTest.php index d23019e2..b5ce5dc3 100644 --- a/tests/Feature/MultiSearchPromptTest.php +++ b/tests/Feature/MultiSearchPromptTest.php @@ -252,6 +252,22 @@ ], ]); +it('transforms values', function () { + Prompt::fake([Key::DOWN, Key::CTRL_A, Key::ENTER]); + + $result = multisearch( + label: 'What are your favorite colors?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + transform: fn ($value) => array_map('strtoupper', $value), + ); + + expect($result)->toBe(['RED', 'GREEN', 'BLUE']); +}); + it('validates', function () { Prompt::fake(['a', Key::DOWN, Key::SPACE, Key::ENTER, Key::DOWN, Key::SPACE, Key::ENTER]); @@ -333,3 +349,31 @@ Prompt::validateUsing(fn () => null); }); + +it('supports selecting all options', function () { + Prompt::fake([Key::DOWN, Key::CTRL_A, Key::ENTER]); + + $result = multisearch( + label: 'What are your favorite colors?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + ); + + expect($result)->toBe(['red', 'green', 'blue']); + + Prompt::fake([Key::DOWN, Key::CTRL_A, Key::CTRL_A, Key::ENTER]); + + $result = multisearch( + label: 'What are your favorite colors?', + options: fn () => [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + ); + + expect($result)->toBe([]); +}); diff --git a/tests/Feature/MultiSelectPromptTest.php b/tests/Feature/MultiSelectPromptTest.php index 53b9cbc8..1aa49e10 100644 --- a/tests/Feature/MultiSelectPromptTest.php +++ b/tests/Feature/MultiSelectPromptTest.php @@ -104,6 +104,22 @@ expect($result)->toBe(['Green']); }); +it('transforms values', function () { + Prompt::fake([Key::DOWN, Key::SPACE, Key::DOWN, Key::SPACE, Key::ENTER]); + + $result = multiselect( + label: 'What are your favorite colors?', + options: [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + transform: fn ($value) => array_map('strtoupper', $value), + ); + + expect($result)->toBe(['GREEN', 'BLUE']); +}); + it('validates', function () { Prompt::fake([Key::ENTER, Key::SPACE, Key::ENTER]); @@ -170,6 +186,34 @@ expect($result)->toBe(['blue', 'red']); }); +it('supports selecting all options', function () { + Prompt::fake([Key::CTRL_A, Key::ENTER]); + + $result = multiselect( + label: 'What are your favorite colors?', + options: [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ] + ); + + expect($result)->toBe(['red', 'green', 'blue']); + + Prompt::fake([Key::CTRL_A, Key::CTRL_A, Key::ENTER]); + + $result = multiselect( + label: 'What are your favorite colors?', + options: [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ] + ); + + expect($result)->toBe([]); +}); + it('returns an empty array when non-interactive', function () { Prompt::interactive(false); diff --git a/tests/Feature/PasswordPromptTest.php b/tests/Feature/PasswordPromptTest.php index 795e9e77..277149c3 100644 --- a/tests/Feature/PasswordPromptTest.php +++ b/tests/Feature/PasswordPromptTest.php @@ -15,6 +15,19 @@ expect($result)->toBe('pass'); }); +it('transforms values', function () { + Prompt::fake(['p', 'a', 's', 's', 'w', 'o', 'r', 'd', Key::ENTER]); + + $dontUseInProduction = md5('password'); + + $result = password( + label: 'What is the password?', + transform: fn ($value) => md5($value) + ); + + expect($result)->toBe($dontUseInProduction); +}); + it('validates', function () { Prompt::fake(['p', 'a', 's', Key::ENTER, 's', Key::ENTER]); diff --git a/tests/Feature/SearchPromptTest.php b/tests/Feature/SearchPromptTest.php index cf196a9c..0a060cac 100644 --- a/tests/Feature/SearchPromptTest.php +++ b/tests/Feature/SearchPromptTest.php @@ -97,6 +97,25 @@ expect($result)->toBe(3); }); +it('transforms values', function () { + Prompt::fake(['u', 'e', Key::DOWN, Key::ENTER]); + + $result = search( + label: 'What is your favorite color?', + options: fn (string $value) => array_filter( + [ + 'red' => 'Red', + 'green' => 'Green', + 'blue' => 'Blue', + ], + fn ($option) => str_contains(strtolower($option), $value), + ), + transform: fn ($value) => strtoupper($value), + ); + + expect($result)->toBe('BLUE'); +}); + it('validates', function () { Prompt::fake([Key::DOWN, Key::ENTER, Key::DOWN, Key::ENTER]); diff --git a/tests/Feature/SelectPromptTest.php b/tests/Feature/SelectPromptTest.php index 32b4deaa..8ea4689d 100644 --- a/tests/Feature/SelectPromptTest.php +++ b/tests/Feature/SelectPromptTest.php @@ -99,6 +99,22 @@ expect($result)->toBe('green'); }); +it('transforms values', function () { + Prompt::fake([Key::DOWN, Key::ENTER]); + + $result = select( + label: 'What is your favorite color?', + options: [ + 'Red', + 'Green', + 'Blue', + ], + transform: fn ($value) => strtolower($value), + ); + + expect($result)->toBe('green'); +}); + it('validates', function () { Prompt::fake([Key::ENTER, Key::DOWN, Key::ENTER]); diff --git a/tests/Feature/SuggestPromptTest.php b/tests/Feature/SuggestPromptTest.php index dd23d9c7..384a7a6f 100644 --- a/tests/Feature/SuggestPromptTest.php +++ b/tests/Feature/SuggestPromptTest.php @@ -118,6 +118,18 @@ expect($result)->toBe('Blue'); }); +it('transforms values', function () { + Prompt::fake([Key::SPACE, 'J', 'e', 's', 's', Key::TAB, Key::ENTER]); + + $result = suggest( + label: 'What is your name?', + options: ['Jess'], + transform: fn ($value) => trim($value), + ); + + expect($result)->toBe('Jess'); +}); + it('validates', function () { Prompt::fake([Key::ENTER, 'X', Key::ENTER]); diff --git a/tests/Feature/TextPromptTest.php b/tests/Feature/TextPromptTest.php index 541628ad..dcd7ef18 100644 --- a/tests/Feature/TextPromptTest.php +++ b/tests/Feature/TextPromptTest.php @@ -30,6 +30,17 @@ expect($result)->toBe('Jess'); }); +it('transforms values', function () { + Prompt::fake([Key::SPACE, 'J', 'e', 's', 's', Key::TAB, Key::ENTER]); + + $result = text( + label: 'What is your name?', + transform: fn ($value) => trim($value), + ); + + expect($result)->toBe('Jess'); +}); + it('validates', function () { Prompt::fake(['J', 'e', 's', Key::ENTER, 's', Key::ENTER]); @@ -148,3 +159,11 @@ text('What is your name?'); })->throws(Exception::class, 'Cancelled.'); + +it('handles a failed terminal read gracefully', function () { + Prompt::fake(['', Key::ENTER]); + + $result = text('What is your name?'); + + expect($result)->toBe(''); +}); diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php index 42d87ba5..f5e21b17 100644 --- a/tests/Feature/TextareaPromptTest.php +++ b/tests/Feature/TextareaPromptTest.php @@ -26,6 +26,17 @@ expect($result)->toBe("Jess\nJoe"); }); +it('transforms values', function () { + Prompt::fake([Key::SPACE, 'J', 'e', 's', 's', Key::SPACE, Key::CTRL_D]); + + $result = textarea( + label: 'What is your name?', + transform: fn ($value) => trim($value), + ); + + expect($result)->toBe('Jess'); +}); + it('validates', function () { Prompt::fake(['J', 'e', 's', Key::CTRL_D, 's', Key::CTRL_D]); @@ -198,3 +209,50 @@ expect($result)->toBe("abc\ndeg\nf"); }); + +it('correctly handles multi-byte strings for the down arrow', function () { + Prompt::fake([ + 'a', 'b', Key::ENTER, + 'c', 'd', 'e', 'f', Key::ENTER, + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'n', 'o', 'p', 'q', 'r', 's', Key::ENTER, + 't', 'u', 'v', 'w', 'x', 'y', 'z', + Key::UP, + Key::UP, + Key::UP, + Key::UP, + Key::RIGHT, + Key::DOWN, + 'y', 'o', + Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe( + "ab\ncyodef\nghijklmnnopqrs\ntuvwxyz" + ); +}); + +it('correctly handles multi-byte strings for the up arrow', function () { + Prompt::fake([ + 'a', 'b', Key::ENTER, + 'c', 'd', 'e', 'f', Key::ENTER, + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'n', 'o', 'p', 'q', 'r', 's', Key::ENTER, + 't', 'u', 'v', 'w', 'x', 'y', 'z', + Key::UP, + Key::UP, + Key::UP, + Key::UP, + Key::RIGHT, + Key::DOWN, + Key::UP, + 'y', 'o', + Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe( + "ayob\ncdef\nghijklmnnopqrs\ntuvwxyz" + ); +});