From df1175fe520f1ddcc62800dfb03daaeb8d7362c9 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 25 Oct 2026 23:11:28 +0800 Subject: [PATCH] feat: add arrow-key `select()` and `multiselect()` to `Prompt` --- system/CLI/KeyReader.php | 90 +++++ system/CLI/Prompt.php | 381 +++++++++++++++++- tests/system/CLI/PromptTest.php | 313 +++++++++++++- user_guide_src/source/changelogs/v4.8.0.rst | 2 + user_guide_src/source/cli/cli_library.rst | 48 ++- user_guide_src/source/cli/cli_library/026.php | 26 ++ 6 files changed, 855 insertions(+), 5 deletions(-) create mode 100644 system/CLI/KeyReader.php create mode 100644 user_guide_src/source/cli/cli_library/026.php diff --git a/system/CLI/KeyReader.php b/system/CLI/KeyReader.php new file mode 100644 index 000000000000..ee6d0f72f7e0 --- /dev/null +++ b/system/CLI/KeyReader.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * Reader of single keypresses from a stream. + * + * @internal + */ +final class KeyReader +{ + private const KEY_PATTERN = '/\e\[[0-9;]*[A-Za-z~]|\eO[A-Z]|./s'; + + private string $savedMode = ''; + + /** + * @param resource $stream + * @param bool $raw Whether to switch the terminal to raw mode while open + */ + public function __construct( + private readonly mixed $stream, + private readonly bool $raw, + ) { + } + + /** + * Returns `false` when raw mode was wanted but the terminal settings could not be read. + */ + public function open(): bool + { + if (! $this->raw) { + return true; + } + + // @codeCoverageIgnoreStart + $this->savedMode = trim((string) shell_exec('stty -g < /dev/tty 2>/dev/null')); + + if ($this->savedMode === '') { + return false; + } + + // Signals stay off so Ctrl+C arrives as a key, letting the terminal be restored before the process ends. + shell_exec('stty -icanon -isig -echo < /dev/tty 2>/dev/null'); + + return true; + // @codeCoverageIgnoreEnd + } + + public function close(): void + { + if ($this->savedMode === '') { + return; + } + + // @codeCoverageIgnoreStart + shell_exec(sprintf('stty %s < /dev/tty 2>/dev/null', escapeshellarg($this->savedMode))); + + $this->savedMode = ''; + // @codeCoverageIgnoreEnd + } + + /** + * Returns the keys of the next read, or an empty list at the end of the stream. + * + * @return list + */ + public function read(): array + { + $bytes = fread($this->stream, 16); + + if ($bytes === false || $bytes === '') { + return []; + } + + preg_match_all(self::KEY_PATTERN, $bytes, $matches); + + return $matches[0]; + } +} diff --git a/system/CLI/Prompt.php b/system/CLI/Prompt.php index 2453afa45bc9..50dc317868c5 100644 --- a/system/CLI/Prompt.php +++ b/system/CLI/Prompt.php @@ -13,6 +13,7 @@ namespace CodeIgniter\CLI; +use CodeIgniter\CLI\Exceptions\CLIException; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Validation\ValidationInterface; @@ -22,10 +23,15 @@ class Prompt { private const MAX_LINE_WIDTH = 125; + private const VISIBLE_ROWS = 5; + /** + * @param resource|null $keys The stream of keypresses read by `select()` and `multiselect()`, or `null` for STDIN + */ public function __construct( private readonly Output $output, private ?ValidationInterface $validation = null, + private readonly mixed $keys = null, ) { } @@ -61,9 +67,7 @@ public function ask(string $field, array|string|null $options = null, array|stri $prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $hint); do { - $this->output->setCursorAtLineStart(true); - - $input = trim($this->output->getInputOutput()->input($prompt)); + $input = $this->readLine($prompt); $input = $input === '' ? $default : $input; } while ($rules !== [] && ! $this->validate(sprintf('"%s"', trim($field)), $input, $rules)); @@ -144,6 +148,97 @@ public function askByMultipleKeys(string $text, array $options): array } } + /** + * Lets the user pick one option with the arrow keys and returns its key, asking for its number where keys cannot be read. + * + * @param array $options + * @param int|string|null $default The key highlighted at the start, or `null` for the first one + * + * @throws InvalidArgumentException + */ + public function select(string $question, array $options, int|string|null $default = null): int|string + { + $this->assertHasOptions($options); + + $keys = array_keys($options); + $cursor = $this->getPositions($options, [$default ?? $keys[0]])[0]; + $default = $keys[$cursor]; + + if ($this->isSilenced()) { + return $default; + } + + $reader = $this->createKeyReader(); + + if ($reader?->open() !== true) { + return $this->selectByNumber($question, $options, $cursor); + } + + try { + $answer = $this->navigate($reader, $question, $options, $cursor, null, false); + } finally { + $reader->close(); + } + + // @codeCoverageIgnoreStart + if ($answer === null) { + $this->interrupt(); + + return $default; + } + // @codeCoverageIgnoreEnd + + return $keys[$answer[0]]; + } + + /** + * Lets the user tick options with the arrow keys and the space bar, asking for their numbers where keys cannot be read. + * + * @param array $options + * @param list $default The keys ticked at the start + * + * @return array The ticked options, in the order of `$options` + * + * @throws CLIException When a required answer has no default on an output that cannot ask + * @throws InvalidArgumentException + */ + public function multiselect(string $question, array $options, array $default = [], bool $required = false): array + { + $this->assertHasOptions($options); + + $ticked = $this->getPositions($options, $default); + + if ($this->isSilenced()) { + if ($required && $ticked === []) { + throw new CLIException(sprintf('"%s" needs at least one option, but none is ticked by default and the output cannot ask.', $question)); + } + + return $this->pickPositions($options, $ticked); + } + + $reader = $this->createKeyReader(); + + if ($reader?->open() !== true) { + return $this->multiselectByNumber($question, $options, $ticked, $required); + } + + try { + $answer = $this->navigate($reader, $question, $options, 0, $ticked, $required); + } finally { + $reader->close(); + } + + // @codeCoverageIgnoreStart + if ($answer === null) { + $this->interrupt(); + + return $this->pickPositions($options, $ticked); + } + // @codeCoverageIgnoreEnd + + return $this->pickPositions($options, $answer[1]); + } + /** * Checks the answer against the rules, writing the first error when it fails. * @@ -179,6 +274,286 @@ private function assertHasOptions(array $options): void } } + /** + * Returns the positions of the keys in `$options`, in the order of `$options`. + * + * @param array $options + * @param list $keys + * + * @return list + * + * @throws InvalidArgumentException + */ + private function getPositions(array $options, array $keys): array + { + $positions = array_flip(array_map(strval(...), array_keys($options))); + $found = []; + + foreach ($keys as $key) { + if (! array_key_exists((string) $key, $positions)) { + throw new InvalidArgumentException(sprintf('"%s" is not one of the options.', $key)); + } + + $found[] = $positions[(string) $key]; + } + + $found = array_values(array_unique($found)); + sort($found); + + return $found; + } + + /** + * @param array $options + * @param list $positions + * + * @return array + */ + private function pickPositions(array $options, array $positions): array + { + return array_intersect_key($options, array_flip(array_intersect_key(array_keys($options), array_flip($positions)))); + } + + private function isSilenced(): bool + { + return $this->output->isQuiet() || $this->output->getInputOutput() instanceof NullInputOutput; + } + + private function createKeyReader(): ?KeyReader + { + if ($this->keys !== null && ! stream_isatty($this->keys)) { + return new KeyReader($this->keys, false); + } + + if (! is_cli() || service('environment')->isTesting() || is_windows()) { + return null; + } + + // @codeCoverageIgnoreStart + $stream = $this->keys ?? STDIN; + + if (! stream_isatty($stream) || ! stream_isatty(STDOUT) || ! function_exists('shell_exec')) { + return null; + } + + return new KeyReader($stream, true); + // @codeCoverageIgnoreEnd + } + + /** + * Runs the arrow-key menu until the user confirms. + * + * @param array $options + * @param list|null $ticked The ticked positions, or `null` for a single choice + * + * @return array{int, list}|null The highlighted and ticked positions, or `null` on Ctrl+C + */ + private function navigate(KeyReader $reader, string $question, array $options, int $cursor, ?array $ticked, bool $required): ?array + { + $labels = array_values($options); + $count = count($labels); + $rows = max(1, min($count, self::VISIBLE_ROWS, $this->output->getTerminal()->getHeight() - 3)); + $first = max(0, $cursor - $rows + 1); + $keys = $ticked === null + ? ['↑↓' => 'navigate', '⏎' => 'submit'] + : ['↑↓' => 'navigate', 'space' => 'select', 'a' => 'all', '⏎' => 'submit']; + $hint = implode($this->output->color(' • ', 'dark_gray'), array_map( + fn (string $key, string $action): string => sprintf('%s %s', $key, $this->output->color($action, 'dark_gray')), + array_keys($keys), + $keys, + )); + $error = ''; + $drawn = 0; + $confirmed = false; + $before = [$cursor, $ticked]; + + $this->output->write($question); + $this->output->writeRaw("\e[?25l"); + + try { + while (! $confirmed) { + $status = $error === '' ? $hint : $this->output->color($error, 'red'); + $drawn = $this->drawMenu($labels, $cursor, $ticked, $first, $rows, $status, $drawn); + $error = ''; + + $before = [$cursor, $ticked]; + $pressed = $reader->read(); + + if ($pressed === []) { + break; + } + + foreach ($pressed as $key) { + if ($key === "\x03") { + return null; + } + + if ($key === "\n" || $key === "\r") { + if ($required && $ticked === []) { + $error = 'Please select at least one option.'; + + continue; + } + + $confirmed = true; + + break; + } + + $error = ''; + $cursor = match ($key) { + "\e[A", "\eOA", 'k' => ($cursor + $count - 1) % $count, + "\e[B", "\eOB", 'j' => ($cursor + 1) % $count, + "\e[H", "\eOH", "\e[1~" => 0, + "\e[F", "\eOF", "\e[4~" => $count - 1, + default => $cursor, + }; + + if ($ticked !== null && $key === ' ') { + $ticked = in_array($cursor, $ticked, true) + ? array_values(array_diff($ticked, [$cursor])) + : [...$ticked, $cursor]; + } elseif ($ticked !== null && $key === 'a') { + $ticked = count($ticked) === $count ? [] : range(0, $count - 1); + } + } + + if ($cursor < $first) { + $first = $cursor; + } elseif ($cursor >= $first + $rows) { + $first = $cursor - $rows + 1; + } + } + + if ($before !== [$cursor, $ticked]) { + $this->drawMenu($labels, $cursor, $ticked, $first, $rows, $hint, $drawn); + } + + $this->output->newLine(); + + return [$cursor, $ticked ?? []]; + } finally { + $this->output->writeRaw("\e[?25h"); + } + } + + /** + * Draws the status line and the visible rows over the previous drawing. + * + * @param list $labels + * @param list|null $ticked + * + * @return int The number of lines drawn + */ + private function drawMenu(array $labels, int $cursor, ?array $ticked, int $first, int $rows, string $status, int $drawn): int + { + $width = $this->output->getTerminal()->getWidth(); + $lines = [$status]; + + foreach (array_slice($labels, $first, $rows, true) as $position => $label) { + $box = $ticked === null ? '' : (in_array($position, $ticked, true) ? '◉ ' : '◯ '); + $line = mb_strimwidth(sprintf('%s %s%s', $position === $cursor ? '❯' : ' ', $box, $label), 0, $width - 1, '...'); + + $lines[] = $position === $cursor ? $this->output->color($line, 'green') : $line; + } + + if ($drawn > 0) { + $this->output->writeRaw(sprintf("\e[%dA", $drawn)); + } + + foreach ($lines as $line) { + $this->output->print(sprintf("\r\e[2K%s%s", $line, PHP_EOL)); + } + + return count($lines); + } + + /** + * @param array $options + */ + private function selectByNumber(string $question, array $options, int $cursor): int|string + { + $keys = array_keys($options); + + $this->output->write($question); + $this->writeKeysAndValues(array_combine(range(1, count($keys)), array_values($options))); + + $default = (string) ($cursor + 1); + $prompt = sprintf('Number [%s]: ', $this->output->color($default, 'green')); + + while (true) { + $answer = $this->readLine($prompt); + $answer = $answer === '' ? $default : $answer; + + if (preg_match('/^\d+$/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) { + $this->output->newLine(); + + return $keys[(int) $answer - 1]; + } + + $this->output->error('Please select correctly.'); + } + } + + /** + * @param array $options + * @param list $ticked + * + * @return array + */ + private function multiselectByNumber(string $question, array $options, array $ticked, bool $required): array + { + $count = count($options); + $default = implode(',', array_map(static fn (int $position): int => $position + 1, $ticked)); + $default = $default === '' && ! $required ? '0' : $default; + $prompt = $required ? 'Numbers, separated by commas' : 'Numbers, separated by commas (0 for none)'; + $prompt = $default === '' ? sprintf('%s: ', $prompt) : sprintf('%s [%s]: ', $prompt, $this->output->color($default, 'green')); + + $this->output->write($question); + $this->writeKeysAndValues(array_combine(range(1, $count), array_values($options))); + + while (true) { + $answer = $this->readLine($prompt); + $answer = $answer === '' ? $default : $answer; + + $numbers = array_map(intval(...), explode(',', $answer)); + $none = $answer === '0' && ! $required; + + if ($none || (preg_match('/^\d+(\s*,\s*\d+)*$/', $answer) === 1 && min($numbers) >= 1 && max($numbers) <= $count)) { + $this->output->newLine(); + + return $none ? [] : $this->pickPositions($options, array_map(static fn (int $number): int => $number - 1, $numbers)); + } + + $this->output->error($answer === '' || $answer === '0' ? 'Please select at least one option.' : 'Please select correctly.'); + } + } + + private function readLine(string $prompt): string + { + $this->output->setCursorAtLineStart(true); + + return trim($this->output->getInputOutput()->input($prompt)); + } + + /** + * Re-raises Ctrl+C as SIGINT, or exits with 130 where signals cannot be sent. + * + * @codeCoverageIgnore + */ + private function interrupt(): void + { + $this->output->newLine(); + + if (function_exists('posix_kill') && function_exists('posix_getpid')) { + posix_kill(posix_getpid(), defined('SIGINT') ? SIGINT : 2); + + return; + } + + exit(130); + } + /** * @param array $options */ diff --git a/tests/system/CLI/PromptTest.php b/tests/system/CLI/PromptTest.php index 50251ecda867..a70685e9ac43 100644 --- a/tests/system/CLI/PromptTest.php +++ b/tests/system/CLI/PromptTest.php @@ -13,16 +13,19 @@ namespace CodeIgniter\CLI; +use CodeIgniter\CLI\Exceptions\CLIException; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Validation\ValidationInterface; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[CoversClass(KeyReader::class)] #[CoversClass(Prompt::class)] #[Group('Others')] final class PromptTest extends CIUnitTestCase @@ -57,7 +60,7 @@ private function createPrompt(array $inputs, ?ValidationInterface $validation = private function getUndecoratedOutput(): string { - return preg_replace('/\e\[[^m]+m/', '', $this->io->getOutput()) ?? ''; + return preg_replace('/\e\[[0-9;]*m/', '', $this->io->getOutput()) ?? ''; } public function testAskReturnsTheAnswer(): void @@ -223,4 +226,312 @@ public function testAskByMultipleKeysRejectsAnEmptyList(): void $this->createPrompt([])->askByMultipleKeys('Hobbies:', []); } + + /** + * @param int|string|null $default + */ + #[DataProvider('provideSelectFollowsTheKeys')] + public function testSelectFollowsTheKeys(string $keys, $default, int|string $expected): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo', 'c' => 'Charlie']; + + $this->assertSame($expected, $this->createKeyPrompt($keys)->select('Pick one', $options, $default)); + } + + /** + * @return iterable + */ + public static function provideSelectFollowsTheKeys(): iterable + { + yield 'enter takes the first option' => ["\n", null, 'a']; + + yield 'enter takes the default' => ["\r", 'b', 'b']; + + yield 'down arrow' => ["\e[B\n", null, 'b']; + + yield 'application-mode down arrow' => ["\eOB\n", null, 'b']; + + yield 'j and k' => ["jjk\n", null, 'b']; + + yield 'up arrow wraps to the last' => ["\e[A\n", null, 'c']; + + yield 'down arrow wraps to the first' => ["\e[B\e[B\e[B\n", null, 'a']; + + yield 'end and home' => ["\e[F\n", null, 'c']; + + yield 'home after end' => ["\e[4~\e[H\n", null, 'a']; + + yield 'the end of the keys confirms' => ["\e[B", null, 'b']; + + yield 'unknown keys change nothing' => ["xyz \n", 'b', 'b']; + } + + public function testSelectReturnsIntegerKeys(): void + { + $this->assertSame(5, $this->createKeyPrompt("\e[B\n")->select('Pick one', [2 => 'Two', 5 => 'Five'])); + } + + public function testSelectDrawsTheHighlightedRow(): void + { + $this->createKeyPrompt("\n")->select('Pick one', ['a' => 'Alpha', 'b' => 'Bravo'], 'b'); + + $this->assertSame( + "\nPick one\n\e[?25l\r\e[2K↑↓ navigate • ⏎ submit\n\r\e[2K Alpha\n\r\e[2K❯ Bravo\n\n\e[?25h", + $this->getUndecoratedOutput(), + ); + } + + public function testSelectRedrawsOverThePreviousDrawing(): void + { + $this->createKeyPrompt("\e[B\n")->select('Pick one', ['a' => 'Alpha', 'b' => 'Bravo']); + + $this->assertStringContainsString("\e[3A\r\e[2K↑↓ navigate • ⏎ submit\n\r\e[2K Alpha\n\r\e[2K❯ Bravo\n", $this->getUndecoratedOutput()); + } + + public function testSelectRejectsAnUnknownDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"z" is not one of the options.'); + + $this->createKeyPrompt("\n")->select('Pick one', ['a' => 'Alpha'], 'z'); + } + + public function testSelectRejectsAnEmptyList(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No options to select from were provided'); + + $this->createKeyPrompt("\n")->select('Pick one', []); + } + + public function testSelectScrollsALongList(): void + { + $options = array_combine(range(1, 7), ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven']); + + $this->assertSame(6, $this->createKeyPrompt("\e[B\e[B\e[B\e[B\e[B\n")->select('Pick one', $options)); + + $lastDrawing = substr($this->getUndecoratedOutput(), (int) strrpos($this->getUndecoratedOutput(), "\e[6A")); + + $this->assertStringNotContainsString('One', $lastDrawing); + $this->assertStringContainsString("❯ Six\n", $lastDrawing); + } + + public function testMultiselectRequiredOnAQuietOutputNeedsADefault(): void + { + $output = new Output($this->io); + $output->setVerbosity(Verbosity::Quiet); + + $this->expectException(CLIException::class); + $this->expectExceptionMessage('"Pick some" needs at least one option, but none is ticked by default and the output cannot ask.'); + + (new Prompt($output))->multiselect('Pick some', ['a' => 'Alpha'], required: true); + } + + public function testMultiselectRequiredOnAQuietOutputReturnsTheDefaults(): void + { + $output = new Output($this->io); + $output->setVerbosity(Verbosity::Quiet); + + $this->assertSame(['a' => 'Alpha'], (new Prompt($output))->multiselect('Pick some', ['a' => 'Alpha'], ['a'], true)); + } + + public function testSelectScrollsBackUp(): void + { + $options = array_combine(range(1, 7), ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven']); + + $this->assertSame(1, $this->createKeyPrompt("\e[H\n")->select('Pick one', $options, 7)); + + $lastDrawing = substr($this->getUndecoratedOutput(), (int) strrpos($this->getUndecoratedOutput(), "\e[6A")); + + $this->assertStringContainsString("❯ One\n", $lastDrawing); + $this->assertStringNotContainsString('Seven', $lastDrawing); + } + + public function testMenuStopsOnCtrlC(): void + { + $navigate = $this->getPrivateMethodInvoker(new Prompt(new Output($this->io)), 'navigate'); + + $this->assertNull($navigate(new KeyReader($this->createKeyStream("\e[B\x03\n"), false), 'Pick one', ['a' => 'Alpha', 'b' => 'Bravo'], 0, null, false)); + $this->assertStringEndsWith("\e[?25h", $this->io->getOutput()); + } + + public function testSelectTruncatesLabelsWiderThanTheTerminal(): void + { + $terminal = $this->createStub(Terminal::class); + $terminal->method('getWidth')->willReturn(16); + $terminal->method('getHeight')->willReturn(24); + + $this->createKeyPrompt("\n", $terminal)->select('Pick one', ['a' => 'A very long label indeed']); + + $this->assertStringContainsString("❯ A very lon...\n", $this->getUndecoratedOutput()); + } + + public function testMultiselectTicksWithSpace(): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo', 'c' => 'Charlie']; + + $this->assertSame( + ['a' => 'Alpha', 'c' => 'Charlie'], + $this->createKeyPrompt(" \e[B\e[B \n")->multiselect('Pick some', $options), + ); + $this->assertStringContainsString("↑↓ navigate • space select • a all • ⏎ submit\n", $this->getUndecoratedOutput()); + $this->assertStringContainsString("❯ ◉ Charlie\n", $this->getUndecoratedOutput()); + } + + public function testMultiselectStartsWithTheDefaultsTicked(): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo', 'c' => 'Charlie']; + + $this->assertSame( + ['a' => 'Alpha', 'c' => 'Charlie'], + $this->createKeyPrompt("\n")->multiselect('Pick some', $options, ['c', 'a']), + ); + $this->assertStringContainsString("❯ ◉ Alpha\n\r\e[2K ◯ Bravo\n\r\e[2K ◉ Charlie\n", $this->getUndecoratedOutput()); + } + + public function testMultiselectUnticksATickedOption(): void + { + $this->assertSame([], $this->createKeyPrompt(" \n")->multiselect('Pick some', ['a' => 'Alpha'], ['a'])); + } + + public function testMultiselectTogglesAll(): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo']; + + $this->assertSame($options, $this->createKeyPrompt("a\n")->multiselect('Pick some', $options, ['a'])); + $this->assertSame([], $this->createKeyPrompt("aa\n")->multiselect('Pick some', $options)); + } + + public function testMultiselectReturnsTheTicksAtTheEndOfTheKeys(): void + { + $this->assertSame([2 => 'Two'], $this->createKeyPrompt("\e[B ")->multiselect('Pick some', [1 => 'One', 2 => 'Two'])); + } + + public function testMultiselectAllowsAnEmptySelection(): void + { + $this->assertSame([], $this->createKeyPrompt("\n")->multiselect('Pick some', ['a' => 'Alpha'])); + } + + public function testMultiselectShowsTheErrorForEnterWithoutATickWhenRequired(): void + { + $this->createKeyPrompt("\n")->multiselect('Pick some', ['a' => 'Alpha'], required: true); + + $this->assertStringContainsString("\r\e[2KPlease select at least one option.\n", $this->getUndecoratedOutput()); + } + + public function testMultiselectClearsTheErrorOnTheNextKey(): void + { + $this->assertSame(['a' => 'Alpha'], $this->createKeyPrompt("\n \n")->multiselect('Pick some', ['a' => 'Alpha'], required: true)); + $this->assertStringNotContainsString('Please select at least one option.', $this->getUndecoratedOutput()); + } + + public function testMultiselectRejectsAnUnknownDefault(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"z" is not one of the options.'); + + $this->createKeyPrompt("\n")->multiselect('Pick some', ['a' => 'Alpha'], ['a', 'z']); + } + + public function testPromptsReturnTheDefaultsOnAQuietOutput(): void + { + $output = new Output($this->io); + $output->setVerbosity(Verbosity::Quiet); + + $keys = $this->createKeyStream("\e[B\n"); + $prompt = new Prompt($output, null, $keys); + + $this->assertSame('b', $prompt->select('Pick one', ['a' => 'Alpha', 'b' => 'Bravo'], 'b')); + $this->assertSame(['a' => 'Alpha'], $prompt->multiselect('Pick some', ['a' => 'Alpha', 'b' => 'Bravo'], ['a'])); + $this->assertSame(0, ftell($keys)); + $this->assertSame('', $this->io->getOutput()); + } + + public function testPromptsReturnTheDefaultsOnANullInputOutput(): void + { + $prompt = new Prompt(new Output(new NullInputOutput()), null, $this->createKeyStream("\e[B\n")); + + $this->assertSame('a', $prompt->select('Pick one', ['a' => 'Alpha', 'b' => 'Bravo'])); + $this->assertSame([], $prompt->multiselect('Pick some', ['a' => 'Alpha', 'b' => 'Bravo'])); + } + + public function testSelectFallsBackToANumber(): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo', 'c' => 'Charlie']; + + $this->assertSame('c', $this->createPrompt(['4', 'x', '3'])->select('Pick one', $options, 'b')); + $this->assertSame( + <<<'EOT' + + Pick one + [1] Alpha + [2] Bravo + [3] Charlie + Number [2]: 4 + Please select correctly. + Number [2]: x + Please select correctly. + Number [2]: 3 + + + EOT, + $this->getUndecoratedOutput(), + ); + } + + public function testSelectFallbackTakesTheDefaultForAnEmptyAnswer(): void + { + $this->assertSame(5, $this->createPrompt([''])->select('Pick one', [2 => 'Two', 5 => 'Five'], 5)); + } + + public function testMultiselectFallsBackToNumbers(): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo', 'c' => 'Charlie']; + + $this->assertSame(['a' => 'Alpha', 'c' => 'Charlie'], $this->createPrompt(['3, 1'])->multiselect('Pick some', $options)); + $this->assertStringContainsString("Numbers, separated by commas (0 for none) [0]: 3, 1\n", $this->getUndecoratedOutput()); + } + + public function testMultiselectFallbackTakesTheDefaultsForAnEmptyAnswer(): void + { + $options = ['a' => 'Alpha', 'b' => 'Bravo', 'c' => 'Charlie']; + + $this->assertSame(['b' => 'Bravo', 'c' => 'Charlie'], $this->createPrompt([''])->multiselect('Pick some', $options, ['c', 'b'])); + $this->assertStringContainsString('(0 for none) [2,3]: ', $this->getUndecoratedOutput()); + } + + public function testMultiselectFallbackSelectsNoneForZero(): void + { + $this->assertSame([], $this->createPrompt(['0'])->multiselect('Pick some', ['a' => 'Alpha'], ['a'])); + } + + public function testMultiselectFallbackAsksAgainForAnInvalidAnswer(): void + { + $this->assertSame(['a' => 'Alpha'], $this->createPrompt(['4', '1,x', '1'])->multiselect('Pick some', ['a' => 'Alpha', 'b' => 'Bravo'])); + $this->assertSame(2, substr_count($this->getUndecoratedOutput(), "Please select correctly.\n")); + } + + public function testMultiselectFallbackRequiresANumberWhenRequired(): void + { + $this->assertSame(['b' => 'Bravo'], $this->createPrompt(['', '0', '2'])->multiselect('Pick some', ['a' => 'Alpha', 'b' => 'Bravo'], required: true)); + $this->assertStringContainsString("Numbers, separated by commas: \n", $this->getUndecoratedOutput()); + $this->assertSame(2, substr_count($this->getUndecoratedOutput(), "Please select at least one option.\n")); + } + + /** + * @return resource + */ + private function createKeyStream(string $keys) + { + $stream = fopen('php://memory', 'r+b'); + fwrite($stream, $keys); + rewind($stream); + + return $stream; + } + + private function createKeyPrompt(string $keys, ?Terminal $terminal = null): Prompt + { + return new Prompt(new Output($this->io, $terminal ?? new Terminal()), null, $this->createKeyStream($keys)); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 9cd70a053494..1102de3ae99b 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -278,6 +278,8 @@ Commands ``askByMultipleKeys()``), which reads through an ``Output`` and accepts the validation that checks the answers. ``CLI::getPrompt()`` and ``AbstractCommand::getPrompt()`` return one on the shared output and on the output of the run. See :ref:`cli-library-classes`. +- Added ``Prompt::select()`` and ``Prompt::multiselect()``, which pick options with the arrow keys and the space bar, and ask for + numbers where keys cannot be read. See :ref:`cli-library-select`. - Added ``--silent``, ``--quiet`` / ``-q``, and ``--verbose`` / ``-v``, ``-vv``, ``-vvv`` to every command, with ``SHELL_VERBOSITY`` as the fallback. Modern commands can write at a :php:class:`Verbosity ` through ``$this->getOutput()``. ``lang:find`` uses the global ``--verbose`` in place of its own. See :ref:`spark-verbosity` and :ref:`modern-commands-verbosity`. - Colour detection now honours ``FORCE_COLOR`` (see https://force-color.org/), which turns colour on even when the output is piped, and ``COLORTERM``. diff --git a/user_guide_src/source/cli/cli_library.rst b/user_guide_src/source/cli/cli_library.rst index 7c4cbde797f6..4de8b65ffb40 100644 --- a/user_guide_src/source/cli/cli_library.rst +++ b/user_guide_src/source/cli/cli_library.rst @@ -88,6 +88,39 @@ This method is the same as ``promptByKey()``, but it supports multiple value. .. important:: The method ``promptByMultipleKeys()``, unlike ``promptByKey()``, does not support named keys or validation. +.. _cli-library-select: + +select() and multiselect() +========================== + +.. versionadded:: 4.8.0 + +``select()`` and ``multiselect()`` on :php:class:`Prompt` let the user pick with the arrow keys +(or ``j`` and ``k``), tick with the space bar, tick everything or nothing with ``a``, and confirm +with enter. ``select()`` returns the chosen key, and ``multiselect()`` returns the ticked options, +keyed as in ``$options``. Both take any keys, and long lists scroll: + +.. literalinclude:: cli_library/026.php + +Where keys cannot be read one by one, such as on Windows or when the input is piped, both prompts +list the options by number and ask for the number, or numbers separated by commas. The filters +question above then reads: + +.. code-block:: console + + Which filters should run on every request? + [1] CSRF + [2] Honeypot + [3] Secure Headers + Numbers, separated by commas (0 for none) [1]: + +Under WSL, PHP runs as on Linux and reads the keys. + +On a quiet output (see :ref:`spark-verbosity`) both prompts return the defaults without asking, +and a required ``multiselect()`` without a default throws a ``CLIException``. + +Pressing Ctrl+C restores the terminal and then interrupts the process as usual. + Providing Feedback ****************** @@ -348,11 +381,13 @@ output of their run through ``$this->getPrompt()``: Asks questions on the console, reading the answers through an ``Output``. - .. php:method:: __construct(Output $output[, ?ValidationInterface $validation = null]) + .. php:method:: __construct(Output $output[, ?ValidationInterface $validation = null[, $keys = null]]) :param Output $output: The output the questions are written and read through. :param ValidationInterface|null $validation: The validation that checks the answers, or ``null`` for an unshared instance of the ``validation`` service. + :param resource|null $keys: The stream of keypresses read by ``select()`` and + ``multiselect()``, or ``null`` for STDIN. .. php:method:: ask(string $field[, array|string|null $options = null[, array|string|null $validation = null]]): string .. php:method:: askByKey(array|string $text, array $options[, array|string|null $validation = null]): string @@ -360,6 +395,17 @@ output of their run through ``$this->getPrompt()``: The same as ``CLI::prompt()``, ``CLI::promptByKey()``, and ``CLI::promptByMultipleKeys()``. + .. php:method:: select(string $question, array $options[, int|string|null $default = null]): int|string + + :param int|string|null $default: The key highlighted at the start, or ``null`` for the first one. + + .. php:method:: multiselect(string $question, array $options[, array $default = [][, bool $required = false]]): array + + :param array $default: The keys ticked at the start. + :param bool $required: Whether at least one option must be ticked. + + See :ref:`cli-library-select`. + .. php:class:: Verbosity The level of the output, as an ``int``-backed enum valued as ``SHELL_VERBOSITY``: diff --git a/user_guide_src/source/cli/cli_library/026.php b/user_guide_src/source/cli/cli_library/026.php new file mode 100644 index 000000000000..01e146ef0e12 --- /dev/null +++ b/user_guide_src/source/cli/cli_library/026.php @@ -0,0 +1,26 @@ +select('Which database driver?', [ + 'MySQLi' => 'MySQL / MariaDB', + 'Postgre' => 'PostgreSQL', + 'SQLite3' => 'SQLite', +], 'MySQLi'); + +// Returns key => label for each ticked option, possibly none. +$filters = $prompt->multiselect('Which filters should run on every request?', [ + 'csrf' => 'CSRF', + 'honeypot' => 'Honeypot', + 'secureheaders' => 'Secure Headers', +], ['csrf']); + +// Refuses to confirm until at least one option is ticked. +$environments = $prompt->multiselect('Which environments get the new key?', [ + 'development' => 'Development', + 'testing' => 'Testing', + 'production' => 'Production', +], required: true);