diff --git a/playground/textarea.php b/playground/textarea.php new file mode 100644 index 00000000..070305f2 --- /dev/null +++ b/playground/textarea.php @@ -0,0 +1,14 @@ + [ TextPrompt::class => TextPromptRenderer::class, + TextareaPrompt::class => TextareaPromptRenderer::class, PasswordPrompt::class => PasswordPromptRenderer::class, SelectPrompt::class => SelectPromptRenderer::class, MultiSelectPrompt::class => MultiSelectPromptRenderer::class, diff --git a/src/Concerns/Truncation.php b/src/Concerns/Truncation.php index ec40a02b..84cf60e7 100644 --- a/src/Concerns/Truncation.php +++ b/src/Concerns/Truncation.php @@ -17,4 +17,90 @@ protected function truncate(string $string, int $width): string return mb_strwidth($string) <= $width ? $string : (mb_strimwidth($string, 0, $width - 1).'…'); } + + /** + * Multi-byte version of wordwrap. + * + * @param non-empty-string $break + */ + protected function mbWordwrap( + string $string, + int $width = 75, + string $break = "\n", + bool $cut_long_words = false + ): string { + $lines = explode($break, $string); + $result = []; + + foreach ($lines as $originalLine) { + if (mb_strwidth($originalLine) <= $width) { + $result[] = $originalLine; + + continue; + } + + $words = explode(' ', $originalLine); + $line = null; + $lineWidth = 0; + + if ($cut_long_words) { + foreach ($words as $index => $word) { + $characters = mb_str_split($word); + $strings = []; + $str = ''; + + foreach ($characters as $character) { + $tmp = $str.$character; + + if (mb_strwidth($tmp) > $width) { + $strings[] = $str; + $str = $character; + } else { + $str = $tmp; + } + } + + if ($str !== '') { + $strings[] = $str; + } + + $words[$index] = implode(' ', $strings); + } + + $words = explode(' ', implode(' ', $words)); + } + + foreach ($words as $word) { + $tmp = ($line === null) ? $word : $line.' '.$word; + + // Look for zero-width joiner characters (combined emojis) + preg_match('/\p{Cf}/u', $word, $joinerMatches); + + $wordWidth = count($joinerMatches) > 0 ? 2 : mb_strwidth($word); + + $lineWidth += $wordWidth; + + if ($line !== null) { + // Space between words + $lineWidth += 1; + } + + if ($lineWidth <= $width) { + $line = $tmp; + } else { + $result[] = $line; + $line = $word; + $lineWidth = $wordWidth; + } + } + + if ($line !== '') { + $result[] = $line; + } + + $line = null; + } + + return implode($break, $result); + } } diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 343e77b8..56d356ad 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -19,7 +19,7 @@ trait TypedValue /** * Track the value as the user types. */ - protected function trackTypedValue(string $default = '', bool $submit = true, ?callable $ignore = null): void + protected function trackTypedValue(string $default = '', bool $submit = true, ?callable $ignore = null, bool $allowNewLine = false): void { $this->typedValue = $default; @@ -27,7 +27,7 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ?c $this->cursorPosition = mb_strlen($this->typedValue); } - $this->on('key', function ($key) use ($submit, $ignore) { + $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])) { if ($ignore !== null && $ignore($key)) { return; @@ -51,10 +51,17 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ?c return; } - if ($key === Key::ENTER && $submit) { - $this->submit(); + if ($key === Key::ENTER) { + if ($submit) { + $this->submit(); - return; + return; + } + + if ($allowNewLine) { + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).PHP_EOL.mb_substr($this->typedValue, $this->cursorPosition); + $this->cursorPosition++; + } } elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) { if ($this->cursorPosition === 0) { return; @@ -81,20 +88,20 @@ public function value(): string /** * Add a virtual cursor to the value and truncate if necessary. */ - protected function addCursor(string $value, int $cursorPosition, int $maxWidth): string + protected function addCursor(string $value, int $cursorPosition, ?int $maxWidth = null): string { $before = mb_substr($value, 0, $cursorPosition); $current = mb_substr($value, $cursorPosition, 1); $after = mb_substr($value, $cursorPosition + 1); - $cursor = mb_strlen($current) ? $current : ' '; + $cursor = mb_strlen($current) && $current !== PHP_EOL ? $current : ' '; - $spaceBefore = $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0); + $spaceBefore = $maxWidth < 0 || $maxWidth === null ? mb_strwidth($before) : $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0); [$truncatedBefore, $wasTruncatedBefore] = mb_strwidth($before) > $spaceBefore ? [$this->trimWidthBackwards($before, 0, $spaceBefore - 1), true] : [$before, false]; - $spaceAfter = $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor); + $spaceAfter = $maxWidth < 0 || $maxWidth === null ? mb_strwidth($after) : $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor); [$truncatedAfter, $wasTruncatedAfter] = mb_strwidth($after) > $spaceAfter ? [mb_strimwidth($after, 0, $spaceAfter - 1), true] : [$after, false]; @@ -102,6 +109,7 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): return ($wasTruncatedBefore ? $this->dim('…') : '') .$truncatedBefore .$this->inverse($cursor) + .($current === PHP_EOL ? PHP_EOL : '') .$truncatedAfter .($wasTruncatedAfter ? $this->dim('…') : ''); } diff --git a/src/Key.php b/src/Key.php index bfbe2c9a..48827d6b 100644 --- a/src/Key.php +++ b/src/Key.php @@ -71,6 +71,11 @@ class Key */ const CTRL_A = "\x01"; + /** + * EOF + */ + const CTRL_D = "\x04"; + /** * End */ diff --git a/src/Prompt.php b/src/Prompt.php index 1f50b102..dfa793c1 100644 --- a/src/Prompt.php +++ b/src/Prompt.php @@ -52,7 +52,7 @@ abstract class Prompt /** * The cancellation callback. */ - protected static Closure $cancelUsing; + protected static ?Closure $cancelUsing; /** * Indicates if the prompt has been validated. @@ -136,7 +136,7 @@ public function prompt(): mixed /** * Register a callback to be invoked when a user cancels a prompt. */ - public static function cancelUsing(Closure $callback): void + public static function cancelUsing(?Closure $callback): void { static::$cancelUsing = $callback; } diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php new file mode 100644 index 00000000..19a94e6b --- /dev/null +++ b/src/TextareaPrompt.php @@ -0,0 +1,245 @@ +scroll = $rows; + + $this->initializeScrolling(); + + $this->trackTypedValue( + default: $default, + submit: false, + allowNewLine: true, + ); + + $this->on('key', function ($key) { + if ($key[0] === "\e") { + match ($key) { + Key::UP, Key::UP_ARROW, Key::CTRL_P => $this->handleUpKey(), + Key::DOWN, Key::DOWN_ARROW, Key::CTRL_N => $this->handleDownKey(), + default => null, + }; + + return; + } + + // Keys may be buffered. + foreach (mb_str_split($key) as $key) { + if ($key === Key::CTRL_D) { + $this->submit(); + + return; + } + } + }); + } + + /** + * Get the formatted value with a virtual cursor. + */ + public function valueWithCursor(): string + { + if ($this->value() === '') { + return $this->wrappedPlaceholderWithCursor(); + } + + return $this->addCursor($this->wrappedValue(), $this->cursorPosition + $this->cursorOffset(), -1); + } + + /** + * The word-wrapped version of the typed value. + */ + public function wrappedValue(): string + { + return $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true); + } + + /** + * The formatted lines. + * + * @return array + */ + public function lines(): array + { + return explode(PHP_EOL, $this->wrappedValue()); + } + + /** + * The currently visible lines. + * + * @return array + */ + public function visible(): array + { + $this->adjustVisibleWindow(); + + $withCursor = $this->valueWithCursor(); + + return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); + } + + /** + * Handle the up key press. + */ + protected function handleUpKey(): void + { + if ($this->cursorPosition === 0) { + return; + } + + $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)); + + $currentLineIndex = $this->currentLineIndex(); + + if ($currentLineIndex === 0) { + // They're already at the first line, jump them to the first position + $this->cursorPosition = 0; + + return; + } + + $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); + + $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); + + $destinationLineLength = ($lineLengths->get($currentLineIndex - 1) ?? $currentLines->first()) - 1; + + $newColumn = min($destinationLineLength, $currentColumn); + + $fullLines = $currentLines->slice(0, -2); + + $this->cursorPosition = $fullLines->sum() + $newColumn; + } + + /** + * Handle the down key press. + */ + 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)); + + $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)); + + return; + } + + // Lines up to and including the current line + $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); + + $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); + + $destinationLineLength = $lineLengths->get($currentLineIndex + 1) ?? $currentLines->last(); + + if ($currentLineIndex + 1 !== $lines->count() - 1) { + $destinationLineLength--; + } + + $newColumn = min(max(0, $destinationLineLength), $currentColumn); + + $this->cursorPosition = $currentLines->sum() + $newColumn; + } + + /** + * Adjust the visible window to ensure the cursor is always visible. + */ + protected function adjustVisibleWindow(): void + { + if (count($this->lines()) < $this->scroll) { + return; + } + + $currentLineIndex = $this->currentLineIndex(); + + while ($this->firstVisible + $this->scroll <= $currentLineIndex) { + $this->firstVisible++; + } + + if ($currentLineIndex === $this->firstVisible - 1) { + $this->firstVisible = max(0, $this->firstVisible - 1); + } + + // Make sure there are always the scroll amount visible + if ($this->firstVisible + $this->scroll > count($this->lines())) { + $this->firstVisible = count($this->lines()) - $this->scroll; + } + } + + /** + * Get the index of the current line that the cursor is on. + */ + protected function currentLineIndex(): int + { + $totalLineLength = 0; + + return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) { + $totalLineLength += mb_strwidth($line) + 1; + + return $totalLineLength > $this->cursorPosition; + }) ?: 0; + } + + /** + * Calculate the cursor offset considering wrapped words. + */ + protected function cursorOffset(): int + { + $cursorOffset = 0; + + preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + + foreach ($matches[0] as $match) { + if ($this->cursorPosition + $cursorOffset >= $match[1] + mb_strwidth($match[0])) { + $cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->width); + } + } + + return $cursorOffset; + } + + /** + * A wrapped version of the placeholder with the virtual cursor. + */ + protected function wrappedPlaceholderWithCursor(): string + { + return implode(PHP_EOL, array_map( + $this->dim(...), + explode(PHP_EOL, $this->addCursor( + $this->mbWordwrap($this->placeholder, $this->width, PHP_EOL, true), + cursorPosition: 0, + )) + )); + } +} diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php new file mode 100644 index 00000000..7a1e6da7 --- /dev/null +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -0,0 +1,87 @@ +width = $prompt->terminal()->cols() - 8; + + return match ($prompt->state) { + 'submit' => $this + ->box( + $this->dim($this->truncate($prompt->label, $prompt->width)), + collect($prompt->lines())->implode(PHP_EOL), + ), + + 'cancel' => $this + ->box( + $this->truncate($prompt->label, $prompt->width), + collect($prompt->lines())->map(fn ($line) => $this->strikethrough($this->dim($line)))->implode(PHP_EOL), + color: 'red', + ) + ->error('Cancelled.'), + + 'error' => $this + ->box( + $this->truncate($prompt->label, $prompt->width), + $this->renderText($prompt), + color: 'yellow', + info: 'Ctrl+D to submit' + ) + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), + + default => $this + ->box( + $this->cyan($this->truncate($prompt->label, $prompt->width)), + $this->renderText($prompt), + info: 'Ctrl+D to submit' + ) + ->when( + $prompt->hint, + fn () => $this->hint($prompt->hint), + fn () => $this->newLine() // Space for errors + ) + }; + } + + /** + * Render the text in the prompt. + */ + protected function renderText(TextareaPrompt $prompt): string + { + $visible = collect($prompt->visible()); + + while ($visible->count() < $prompt->scroll) { + $visible->push(''); + } + + $longest = $this->longest($prompt->lines()) + 2; + + return $this->scrollbar( + $visible, + $prompt->firstVisible, + $prompt->scroll, + count($prompt->lines()), + min($longest, $prompt->width + 2), + )->implode(PHP_EOL); + } + + /** + * The number of lines to reserve outside of the scrollable area. + */ + public function reservedLines(): int + { + return 5; + } +} diff --git a/src/helpers.php b/src/helpers.php index 8c05b784..b1b9aabe 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -13,6 +13,14 @@ function text(string $label, string $placeholder = '', string $default = '', boo 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. */ diff --git a/tests/Feature/MultiByteWordWrapTest.php b/tests/Feature/MultiByteWordWrapTest.php new file mode 100644 index 00000000..84595f53 --- /dev/null +++ b/tests/Feature/MultiByteWordWrapTest.php @@ -0,0 +1,199 @@ +mbWordwrap(...$args); + } +}; + +test('will match wordwrap', function () use ($instance) { + $str = "This is a story all about how my life got flipped turned upside down and I'd like to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $result = wordwrap($str); + + $mbResult = $instance->wordwrap($str); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap on shorter strings', function () use ($instance) { + $str = "This is a story all\nabout how my life got\nflipped turned upside down and I'd like to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $result = wordwrap($str); + + $mbResult = $instance->wordwrap($str); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap on blank lines strings', function () use ($instance) { + $str = "This is a story all about how my life got flipped turned upside down and I'd\n\nlike to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $result = wordwrap($str); + + $mbResult = $instance->wordwrap($str); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap with cut long words enabled', function () use ($instance) { + $str = "This is a story all about how my life got flippppppppppppppppppppppppped turned upside down and I'd like to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $result = wordwrap($str, 25, "\n", true); + + $mbResult = $instance->wordwrap($str, 25, "\n", true); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap with random multiple spaces', function () use ($instance) { + $str = " This is a story all about how my life got flipped turned upside down and I'd like to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $result = wordwrap($str, 25, "\n", true); + + $mbResult = $instance->wordwrap($str, 25, "\n", true); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap with cut long words disabled', function () use ($instance) { + $str = "This is a story all about how my life got flippppppppppppppppppppppppped turned upside down and I'd like to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $result = wordwrap($str, 25, "\n", false); + + $mbResult = $instance->wordwrap($str, 25, "\n", false); + + expect($mbResult)->toBe($result); +}); + +test('will wrap strings with multi-byte characters', function () use ($instance) { + $str = "This is a story all about how my life got flippêd turnêd upsidê down and I'd likê to takê a minutê just sit right thêrê I'll têll you how I bêcamê thê princê of a town callêd Bêl-Air"; + + $mbResult = $instance->wordwrap($str, 18, "\n", false); + + $expectedResult = <<<'RESULT' + This is a story + all about how my + life got flippêd + turnêd upsidê down + and I'd likê to + takê a minutê just + sit right thêrê + I'll têll you how + I bêcamê thê + princê of a town + callêd Bêl-Air + RESULT; + + expect($mbResult)->toBe($expectedResult); +}); + +test('will wrap strings with emojis', function () use ($instance) { + $str = "This is a 📖 all about how my life got 🌀 turned upside ⬇️ and I'd like to take a minute just sit right there I'll tell you how I became the prince of a town called Bel-Air"; + + $mbResult = $instance->wordwrap($str, 13, "\n", false); + + $expectedResult = <<<'RESULT' + This is a 📖 + all about how + my life got + 🌀 turned + upside ⬇️ and + I'd like to + take a minute + just sit + right there + I'll tell you + how I became + the prince of + a town called + Bel-Air + RESULT; + + expect($mbResult)->toBe($expectedResult); +}); + +test('will wrap strings with emojis and multi-byte characters', function () use ($instance) { + $str = "This is a 📖 all about how my lifê got 🌀 turnêd upsidê ⬇️ and I'd likê to takê a minutê just sit right thêrê I'll têll you how I bêcamê thê princê of a town callêd Bêl-Air"; + + $mbResult = $instance->wordwrap($str, 11, "\n", false); + + $expectedResult = <<<'RESULT' + This is a + 📖 all + about how + my lifê got + 🌀 turnêd + upsidê ⬇️ + and I'd + likê to + takê a + minutê just + sit right + thêrê I'll + têll you + how I + bêcamê thê + princê of a + town callêd + Bêl-Air + RESULT; + + expect($mbResult)->toBe($expectedResult); +}); + +test('will wrap strings with combined emojis', function () use ($instance) { + $str = "This is a 📖 all about how my life got 🌀 turned upside ⬇️ and I'd like to take a minute just sit right there I'll tell you how I became the prince of a 👨‍👩‍👧‍👦 called Bel-Air"; + + $mbResult = $instance->wordwrap($str, 13, "\n", false); + + $expectedResult = <<<'RESULT' + This is a 📖 + all about how + my life got + 🌀 turned + upside ⬇️ and + I'd like to + take a minute + just sit + right there + I'll tell you + how I became + the prince of + a 👨‍👩‍👧‍👦 called + Bel-Air + RESULT; + + expect($mbResult)->toBe($expectedResult); +}); + +test('will handle long strings with multi-byte characters and emojis with cut long words enabled', function () use ($instance) { + $str = "This is a 📖 all about how my life got 🌀 turned upside ⬇️ and I'd like to take a minute just sit right there I'll tell you how I became the prince of a 👨‍👩‍👧‍👦 called Bel-Air"; + + $mbResult = $instance->wordwrap($str, 13, "\n", false); + + $expectedResult = <<<'RESULT' + This is a 📖 + all about how + my life got + 🌀 turned + upside ⬇️ and + I'd like to + take a minute + just sit + right there + I'll tell you + how I became + the prince of + a 👨‍👩‍👧‍👦 called + Bel-Air + RESULT; + + expect($mbResult)->toBe($expectedResult); +}); diff --git a/tests/Feature/TextPromptTest.php b/tests/Feature/TextPromptTest.php index 2f47b46d..541628ad 100644 --- a/tests/Feature/TextPromptTest.php +++ b/tests/Feature/TextPromptTest.php @@ -7,6 +7,10 @@ use function Laravel\Prompts\text; +afterEach(function () { + Prompt::cancelUsing(null); +}); + it('returns the input', function () { Prompt::fake(['J', 'e', 's', 's', Key::ENTER]); diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php new file mode 100644 index 00000000..42d87ba5 --- /dev/null +++ b/tests/Feature/TextareaPromptTest.php @@ -0,0 +1,200 @@ +toBe("Jess\nJoe"); +}); + +it('accepts a default value', function () { + Prompt::fake([Key::CTRL_D]); + + $result = textarea( + label: 'What is your name?', + default: "Jess\nJoe" + ); + + expect($result)->toBe("Jess\nJoe"); +}); + +it('validates', function () { + Prompt::fake(['J', 'e', 's', Key::CTRL_D, 's', Key::CTRL_D]); + + $result = textarea( + label: 'What is your name?', + validate: fn ($value) => $value !== 'Jess' ? 'Invalid name.' : '', + ); + + expect($result)->toBe('Jess'); + + Prompt::assertOutputContains('Invalid name.'); +}); + +it('cancels', function () { + Prompt::fake([Key::CTRL_C]); + + textarea(label: 'What is your name?'); + + Prompt::assertOutputContains('Cancelled.'); +}); + +test('the backspace key removes a character', function () { + Prompt::fake(['J', 'e', 'z', Key::BACKSPACE, 's', 's', Key::CTRL_D]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe('Jess'); +}); + +test('the delete key removes a character', function () { + Prompt::fake(['J', 'e', 'z', Key::LEFT, Key::DELETE, 's', 's', Key::CTRL_D]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe('Jess'); +}); + +it('can fall back', function () { + Prompt::fallbackWhen(true); + + TextareaPrompt::fallbackUsing(function (TextareaPrompt $prompt) { + expect($prompt->label)->toBe('What is your name?'); + + return 'result'; + }); + + $result = textarea('What is your name?'); + + expect($result)->toBe('result'); +}); + +it('supports emacs style key bindings', function () { + Prompt::fake(['J', 'z', 'e', Key::CTRL_B, Key::CTRL_H, key::CTRL_F, 's', 's', Key::CTRL_D]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe('Jess'); +}); + +it('moves to the beginning and end of line', function () { + Prompt::fake(['e', 's', Key::HOME[0], 'J', KEY::END[0], 's', Key::CTRL_D]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe('Jess'); +}); + +it('moves up and down lines', function () { + Prompt::fake([ + 'e', 's', 's', Key::ENTER, 'o', 'e', + KEY::UP_ARROW, KEY::LEFT_ARROW, Key::LEFT_ARROW, + 'J', KEY::DOWN_ARROW, KEY::LEFT_ARROW, 'J', Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe("Jess\nJoe"); +}); + +it('moves to the start of the line if up is pressed twice on the first line', function () { + Prompt::fake([ + 'e', 's', 's', Key::ENTER, 'J', 'o', 'e', + KEY::UP_ARROW, KEY::UP_ARROW, 'J', Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe("Jess\nJoe"); +}); + +it('moves to the end of the line if down is pressed twice on the last line', function () { + Prompt::fake([ + 'J', 'e', 's', 's', Key::ENTER, 'J', 'o', + KEY::UP_ARROW, KEY::UP_ARROW, Key::DOWN_ARROW, + Key::DOWN_ARROW, 'e', Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe("Jess\nJoe"); +}); + +it('can move back to the last line when it is empty', function () { + Prompt::fake([ + 'J', 'e', 's', 's', Key::ENTER, + Key::UP, Key::DOWN, + 'J', 'o', 'e', + Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe("Jess\nJoe"); +}); + +it('returns an empty string when non-interactive', function () { + Prompt::interactive(false); + + $result = textarea('What is your name?'); + + expect($result)->toBe(''); +}); + +it('returns the default value when non-interactive', function () { + Prompt::interactive(false); + + $result = textarea('What is your name?', default: 'Taylor'); + + expect($result)->toBe('Taylor'); +}); + +it('validates the default value when non-interactive', function () { + Prompt::interactive(false); + + textarea('What is your name?', required: true); +})->throws(NonInteractiveValidationException::class, 'Required.'); + +it('correctly handles ascending line lengths', function () { + Prompt::fake([ + 'a', Key::ENTER, + 'b', 'c', Key::ENTER, + 'd', 'e', 'f', + Key::UP, + Key::UP, + Key::DOWN, + 'g', + Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe("a\nbgc\ndef"); +}); + +it('correctly handles descending line lengths', function () { + Prompt::fake([ + 'a', 'b', 'c', Key::ENTER, + 'd', 'e', Key::ENTER, + 'f', + Key::UP, + Key::UP, + Key::RIGHT, + Key::RIGHT, + Key::DOWN, + 'g', + Key::CTRL_D, + ]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe("abc\ndeg\nf"); +});