From 88ca717d8be925522ed50e51efa35b4f46f0323e Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 24 Sep 2023 20:47:56 -0400 Subject: [PATCH 01/59] committing this insanity before i probably destroy it --- playground/textarea.php | 22 ++ src/Concerns/Themes.php | 5 +- src/Concerns/TypedValue.php | 14 +- src/Key.php | 5 + src/TextareaPrompt.php | 288 ++++++++++++++++++ src/Themes/Default/TextareaPromptRenderer.php | 76 +++++ src/helpers.php | 8 + 7 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 playground/textarea.php create mode 100644 src/TextareaPrompt.php create mode 100644 src/Themes/Default/TextareaPromptRenderer.php diff --git a/playground/textarea.php b/playground/textarea.php new file mode 100644 index 00000000..7a90eda5 --- /dev/null +++ b/playground/textarea.php @@ -0,0 +1,22 @@ +join(PHP_EOL), +); + +var_dump($email); + +echo str_repeat(PHP_EOL, 5); diff --git a/src/Concerns/Themes.php b/src/Concerns/Themes.php index a2c82f3d..7584a270 100644 --- a/src/Concerns/Themes.php +++ b/src/Concerns/Themes.php @@ -13,6 +13,7 @@ use Laravel\Prompts\Spinner; use Laravel\Prompts\SuggestPrompt; use Laravel\Prompts\Table; +use Laravel\Prompts\TextareaPrompt; use Laravel\Prompts\TextPrompt; use Laravel\Prompts\Themes\Default\ConfirmPromptRenderer; use Laravel\Prompts\Themes\Default\MultiSearchPromptRenderer; @@ -24,6 +25,7 @@ use Laravel\Prompts\Themes\Default\SpinnerRenderer; use Laravel\Prompts\Themes\Default\SuggestPromptRenderer; use Laravel\Prompts\Themes\Default\TableRenderer; +use Laravel\Prompts\Themes\Default\TextareaPromptRenderer; use Laravel\Prompts\Themes\Default\TextPromptRenderer; trait Themes @@ -41,6 +43,7 @@ trait Themes protected static array $themes = [ 'default' => [ TextPrompt::class => TextPromptRenderer::class, + TextareaPrompt::class => TextareaPromptRenderer::class, PasswordPrompt::class => PasswordPromptRenderer::class, SelectPrompt::class => SelectPromptRenderer::class, MultiSelectPrompt::class => MultiSelectPromptRenderer::class, @@ -65,7 +68,7 @@ public static function theme(string $name = null): string return static::$theme; } - if (! isset(static::$themes[$name])) { + if (!isset(static::$themes[$name])) { throw new InvalidArgumentException("Prompt theme [{$name}] not found."); } diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 1cb07bad..6b0bdede 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -38,7 +38,7 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_F => $this->cursorPosition = min(mb_strlen($this->typedValue), $this->cursorPosition + 1), Key::HOME, Key::CTRL_A => $this->cursorPosition = 0, Key::END, Key::CTRL_E => $this->cursorPosition = mb_strlen($this->typedValue), - Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).mb_substr($this->typedValue, $this->cursorPosition + 1), + Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . mb_substr($this->typedValue, $this->cursorPosition + 1), default => null, }; @@ -60,10 +60,10 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca return; } - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1).mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1) . mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition--; } elseif (ord($key) >= 32) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; } } @@ -100,10 +100,10 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): : [$after, false]; return ($wasTruncatedBefore ? $this->dim('…') : '') - .$truncatedBefore - .$this->inverse($cursor) - .$truncatedAfter - .($wasTruncatedAfter ? $this->dim('…') : ''); + . $truncatedBefore + . $this->inverse($cursor) + . $truncatedAfter + . ($wasTruncatedAfter ? $this->dim('…') : ''); } /** diff --git a/src/Key.php b/src/Key.php index 7869fa5e..d77851e2 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/TextareaPrompt.php b/src/TextareaPrompt.php new file mode 100644 index 00000000..9d5cd549 --- /dev/null +++ b/src/TextareaPrompt.php @@ -0,0 +1,288 @@ +trackTypedValue( + default: $default, + submit: false, + ignore: fn ($key) => $key === Key::ENTER, + ); + + $this->reduceScrollingToFitTerminal(); + + $this->cursorPosition = 0; + + $this->on( + 'key', + function ($key) { + if ($key === Key::ENTER) { + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); + $this->cursorPosition++; + // $this->checkScrollPosition(); + } + + 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(), + Key::DELETE => $this->checkScrollPosition(), + default => null, + }; + + return; + } + + // Keys may be buffered. + foreach (mb_str_split($key) as $key) { + if ($key === Key::CTRL_D) { + $this->submit(); + } + + // elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) { + // if ($this->cursorPosition === 0) { + // return; + // } + + // // $this->checkScrollPosition(); + // } elseif (ord($key) >= 32) { + // // $this->checkScrollPosition(); + // } + } + + // $this->checkScrollPosition(); + + // ray($this->firstVisible, count($this->lines())); + // ray($this->firstVisible + $this->scroll < count($this->lines())); + // ray($this->firstVisible - $this->scroll > count($this->lines())); + + // if ($this->firstVisible - $this->scroll > count($this->lines())) { + // $this->firstVisible--; + // } + + // if ($this->firstVisible + $this->scroll < count($this->lines())) { + // $this->firstVisible++; + // } + } + ); + } + + protected function checkScrollPosition() + { + $totalLineLength = 0; + + $currentLineIndex = collect($this->lines())->search(function ($line) use (&$totalLineLength) { + $totalLineLength += mb_strlen($line); + + return $totalLineLength >= $this->cursorPosition; + }); + + ray($this->firstVisible + $this->scroll, $currentLineIndex,); + + if ($this->firstVisible + $this->scroll <= $currentLineIndex) { + $this->firstVisible++; + } + + + // if ($currentLineIndex < $this->firstVisible) { + // $this->firstVisible--; + // } + + // if ($currentLineIndex > $this->firstVisible + $this->scroll) { + // $this->firstVisible++; + // } + + + + + + // if ($this->firstVisible + $this->scroll < count($this->lines())) { + // ray('adding'); + // $this->firstVisible++; + // } + + // if ($this->firstVisible - $this->scroll > count($this->lines())) { + // ray('subtracting'); + // $this->firstVisible--; + // } + } + + 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_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); + + $totalLineLength = 0; + + $currentLineIndex = $lineLengths->search(function ($lineLength) use (&$totalLineLength) { + $totalLineLength += $lineLength; + + return $totalLineLength >= $this->cursorPosition; + }); + + if ($currentLineIndex === 0) { + // They're already at the first line, jump them to the first position + $this->cursorPosition = 0; + return; + } + + if ($currentLineIndex + $this->firstVisible < $this->scroll && $this->firstVisible > 0) { + $this->firstVisible--; + } + + $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); + + $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); + + $destinationLineLength = $lineLengths->get($currentLineIndex - 1) ?? $currentLines->first(); + + $newColumn = min($destinationLineLength, $currentColumn); + + // ray($lineLengths->get($currentLineIndex - 1), compact( + // 'currentLineIndex', + // 'currentColumn', + // 'destinationLineLength', + // 'newColumn', + // 'currentLines', + // 'lineLengths', + // 'lines', + // 'totalLineLength' + // )); + + if ($newColumn < $currentColumn) { + $newColumn--; + } + + $fullLines = $currentLines->slice(0, -2); + + $this->cursorPosition = $fullLines->sum() + $newColumn; + } + + protected function handleDownKey(): void + { + $lines = collect($this->lines()); + + // $this->firstVisible = min($lines->count() - $this->scroll, $this->firstVisible + 1); + + // if ($this->cursorPosition === mb_strlen($lines->implode(PHP_EOL))) { + // return; + // } + + // Line length + 1 for the newline character + $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); + + $totalLineLengths = 0; + + $currentLineIndex = $lineLengths->search(function ($lineLength) use (&$totalLineLengths) { + $totalLineLengths += $lineLength; + + return $totalLineLengths >= $this->cursorPosition; + }); + + + if ($currentLineIndex === $lines->count() - 1) { + // They're already at the last line, jump them to the last position + // TODO: Fix this number, it's not using $lines + $this->cursorPosition = mb_strlen($this->typedValue); + return; + } + + if ($currentLineIndex + 1 >= $this->firstVisible + $this->scroll) { + $this->firstVisible++; + } + + $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); + + $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); + + $newLineLength = $lineLengths->get($currentLineIndex + 1) ?? $currentLines->last(); + + $newColumn = min($newLineLength, $currentColumn); + + $fullLines = $lineLengths->slice(0, $currentLines->count()); + + $this->cursorPosition = $fullLines->sum() + $newColumn; + } + + /** + * The currently visible options. + * + * @return array + */ + public function visible(): array + { + $totalLineLength = 0; + + $currentLineIndex = collect($this->lines())->search(function ($line) use (&$totalLineLength) { + $totalLineLength += mb_strlen($line); + + return $totalLineLength >= $this->cursorPosition; + }); + + ray($this->firstVisible, $this->firstVisible + $this->scroll, $currentLineIndex,); + + if ($this->firstVisible + $this->scroll <= $currentLineIndex) { + $this->firstVisible++; + } + + //Make sure there are always 5 visible + // if ($this->firstVisible + $this->scroll < ) { + // $this->firstVisible--; + // } + + return array_slice($this->lines(), $this->firstVisible, $this->scroll, preserve_keys: true); + } + + public function lines(): array + { + $value = $this->valueWithCursor(10_000); + + return explode(PHP_EOL, $value); + } + + /** + * Get the entered value with a virtual cursor. + */ + public function valueWithCursor(int $maxWidth): string + { + if ($this->value() === '') { + return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth)); + } + + // TODO: Figure out the real number here, this comes from the renderer + // so maybe we just max it out. + $value = wordwrap($this->value(), 59, PHP_EOL, true); + // TODO: Deal with max width properly + return $this->addCursor($value, $this->cursorPosition, 10_000); + } +} diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php new file mode 100644 index 00000000..19560c40 --- /dev/null +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -0,0 +1,76 @@ +terminal()->cols() - 6; + + return match ($prompt->state) { + 'submit' => $this + ->box( + $this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $this->truncate($prompt->value(), $maxWidth), + ), + + 'cancel' => $this + ->box( + $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), + $this->strikethrough($this->dim($this->truncate($prompt->value() ?: $prompt->placeholder, $maxWidth))), + color: 'red', + ) + ->error('Cancelled.'), + + 'error' => $this + ->box( + $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), + $prompt->valueWithCursor($maxWidth), + color: 'yellow', + ) + ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), + + default => $this + ->box( + $this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + // $prompt->valueWithCursor($maxWidth), + $this->renderText($prompt), + info: 'Ctrl+D to submit' + ) + ->when( + $prompt->hint, + fn () => $this->hint($prompt->hint), + fn () => $this->newLine() // Space for errors + ) + }; + } + + protected function renderText(TextareaPrompt $prompt): string + { + return $this->scrollbar( + collect($prompt->visible()), + $prompt->firstVisible, + $prompt->scroll, + count($prompt->lines()), + $prompt->terminal()->cols() - 6, + )->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 5ffb94ae..a6db9a50 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($label, $placeholder, $default, $required, $validate, $hint))->prompt(); } +/** + * Prompt the user for text input. + */ +function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, Closure $validate = null, string $hint = ''): string +{ + return (new TextareaPrompt($label, $placeholder, $default, $required, $validate, $hint))->prompt(); +} + /** * Prompt the user for input, hiding the value. */ From 48ed22775a2e7db31f906b3ee6a6b5394d70e361 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 24 Sep 2023 20:59:29 -0400 Subject: [PATCH 02/59] ok we're getting closer --- playground/textarea.php | 18 +++++++++--------- src/TextareaPrompt.php | 37 +++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/playground/textarea.php b/playground/textarea.php index 7a90eda5..3cd7c2bd 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -6,15 +6,15 @@ $email = textarea( label: 'Tell me a story', - default: collect([ - 'first line', - 'second line', - 'third line though', - 'fourth line wow', - 'fifth line are you kidding me', - 'sixth line here we go', - 'sevent line ok sure', - ])->join(PHP_EOL), + // default: collect([ + // 'first line', + // 'second line', + // 'third line though', + // 'fourth line wow', + // 'fifth line are you kidding me', + // 'sixth line here we go', + // 'seventh line ok sure', + // ])->join(PHP_EOL), ); var_dump($email); diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 9d5cd549..6c08fd81 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -241,33 +241,42 @@ protected function handleDownKey(): void */ public function visible(): array { - $totalLineLength = 0; - - $currentLineIndex = collect($this->lines())->search(function ($line) use (&$totalLineLength) { - $totalLineLength += mb_strlen($line); - - return $totalLineLength >= $this->cursorPosition; - }); - - ray($this->firstVisible, $this->firstVisible + $this->scroll, $currentLineIndex,); + $currentLineIndex = $this->currentLineIndex(); if ($this->firstVisible + $this->scroll <= $currentLineIndex) { $this->firstVisible++; } - //Make sure there are always 5 visible - // if ($this->firstVisible + $this->scroll < ) { - // $this->firstVisible--; - // } + // Make sure there are always the scroll amount visible + if ($this->firstVisible + $this->scroll > count($this->lines())) { + $this->firstVisible = count($this->lines()) - $this->scroll; + } return array_slice($this->lines(), $this->firstVisible, $this->scroll, preserve_keys: true); } + protected function currentLineIndex(): int + { + $totalLineLength = 0; + + return collect($this->lines())->search(function ($line) use (&$totalLineLength) { + $totalLineLength += mb_strlen($line); + + return $totalLineLength >= $this->cursorPosition; + }); + } + public function lines(): array { $value = $this->valueWithCursor(10_000); - return explode(PHP_EOL, $value); + $lines = explode(PHP_EOL, $value); + + while (count($lines) < $this->scroll) { + $lines[] = ''; + } + + return $lines; } /** From 40eb5515965943e11060a8079ae25372594573b4 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 24 Sep 2023 21:02:39 -0400 Subject: [PATCH 03/59] width is more stable --- playground/textarea.php | 18 +++++++++--------- src/TextareaPrompt.php | 5 ++--- src/Themes/Default/TextareaPromptRenderer.php | 3 +-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/playground/textarea.php b/playground/textarea.php index 3cd7c2bd..62d31d02 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -6,15 +6,15 @@ $email = textarea( label: 'Tell me a story', - // default: collect([ - // 'first line', - // 'second line', - // 'third line though', - // 'fourth line wow', - // 'fifth line are you kidding me', - // 'sixth line here we go', - // 'seventh line ok sure', - // ])->join(PHP_EOL), + default: collect([ + 'first line', + 'second line', + 'third line though', + 'fourth line wow', + 'fifth line are you kidding me', + 'sixth line here we go', + 'seventh line ok sure', + ])->join(PHP_EOL), ); var_dump($email); diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 6c08fd81..ee10768b 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -288,10 +288,9 @@ public function valueWithCursor(int $maxWidth): string return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth)); } - // TODO: Figure out the real number here, this comes from the renderer - // so maybe we just max it out. + // TODO: Figure out the real number here, this comes from the renderer? $value = wordwrap($this->value(), 59, PHP_EOL, true); // TODO: Deal with max width properly - return $this->addCursor($value, $this->cursorPosition, 10_000); + return $this->addCursor($value, $this->cursorPosition, $maxWidth); } } diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index 19560c40..e7c2da60 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -43,7 +43,6 @@ public function __invoke(TextareaPrompt $prompt): string default => $this ->box( $this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), - // $prompt->valueWithCursor($maxWidth), $this->renderText($prompt), info: 'Ctrl+D to submit' ) @@ -62,7 +61,7 @@ protected function renderText(TextareaPrompt $prompt): string $prompt->firstVisible, $prompt->scroll, count($prompt->lines()), - $prompt->terminal()->cols() - 6, + $this->minWidth, )->implode(PHP_EOL); } From 79dd87e9d7e7e311b42cc7e984cddc6728264122 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Mon, 25 Sep 2023 01:03:10 +0000 Subject: [PATCH 04/59] Fix code styling --- playground/textarea.php | 2 +- src/Concerns/Themes.php | 2 +- src/Concerns/TypedValue.php | 14 +++++++------- src/TextareaPrompt.php | 15 ++++++--------- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/playground/textarea.php b/playground/textarea.php index 62d31d02..4c32a010 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -2,7 +2,7 @@ use function Laravel\Prompts\textarea; -require __DIR__ . '/../vendor/autoload.php'; +require __DIR__.'/../vendor/autoload.php'; $email = textarea( label: 'Tell me a story', diff --git a/src/Concerns/Themes.php b/src/Concerns/Themes.php index 7584a270..8d9e2a26 100644 --- a/src/Concerns/Themes.php +++ b/src/Concerns/Themes.php @@ -68,7 +68,7 @@ public static function theme(string $name = null): string return static::$theme; } - if (!isset(static::$themes[$name])) { + if (! isset(static::$themes[$name])) { throw new InvalidArgumentException("Prompt theme [{$name}] not found."); } diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 6b0bdede..1cb07bad 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -38,7 +38,7 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_F => $this->cursorPosition = min(mb_strlen($this->typedValue), $this->cursorPosition + 1), Key::HOME, Key::CTRL_A => $this->cursorPosition = 0, Key::END, Key::CTRL_E => $this->cursorPosition = mb_strlen($this->typedValue), - Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . mb_substr($this->typedValue, $this->cursorPosition + 1), + Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).mb_substr($this->typedValue, $this->cursorPosition + 1), default => null, }; @@ -60,10 +60,10 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca return; } - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1) . mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1).mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition--; } elseif (ord($key) >= 32) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; } } @@ -100,10 +100,10 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): : [$after, false]; return ($wasTruncatedBefore ? $this->dim('…') : '') - . $truncatedBefore - . $this->inverse($cursor) - . $truncatedAfter - . ($wasTruncatedAfter ? $this->dim('…') : ''); + .$truncatedBefore + .$this->inverse($cursor) + .$truncatedAfter + .($wasTruncatedAfter ? $this->dim('…') : ''); } /** diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index ee10768b..8f49e866 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -6,8 +6,8 @@ class TextareaPrompt extends Prompt { - use Concerns\TypedValue; use Concerns\ReducesScrollingToFitTerminal; + use Concerns\TypedValue; /** * The index of the first visible option. @@ -41,7 +41,7 @@ public function __construct( 'key', function ($key) { if ($key === Key::ENTER) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; // $this->checkScrollPosition(); } @@ -101,13 +101,12 @@ protected function checkScrollPosition() return $totalLineLength >= $this->cursorPosition; }); - ray($this->firstVisible + $this->scroll, $currentLineIndex,); + ray($this->firstVisible + $this->scroll, $currentLineIndex); if ($this->firstVisible + $this->scroll <= $currentLineIndex) { $this->firstVisible++; } - // if ($currentLineIndex < $this->firstVisible) { // $this->firstVisible--; // } @@ -116,10 +115,6 @@ protected function checkScrollPosition() // $this->firstVisible++; // } - - - - // if ($this->firstVisible + $this->scroll < count($this->lines())) { // ray('adding'); // $this->firstVisible++; @@ -153,6 +148,7 @@ protected function handleUpKey(): void if ($currentLineIndex === 0) { // They're already at the first line, jump them to the first position $this->cursorPosition = 0; + return; } @@ -209,11 +205,11 @@ protected function handleDownKey(): void return $totalLineLengths >= $this->cursorPosition; }); - if ($currentLineIndex === $lines->count() - 1) { // They're already at the last line, jump them to the last position // TODO: Fix this number, it's not using $lines $this->cursorPosition = mb_strlen($this->typedValue); + return; } @@ -290,6 +286,7 @@ public function valueWithCursor(int $maxWidth): string // TODO: Figure out the real number here, this comes from the renderer? $value = wordwrap($this->value(), 59, PHP_EOL, true); + // TODO: Deal with max width properly return $this->addCursor($value, $this->cursorPosition, $maxWidth); } From 3453abdc30a26c1a8d33a908f8c48b934431d7a0 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 24 Sep 2023 21:03:55 -0400 Subject: [PATCH 05/59] remove old bad stuff --- src/TextareaPrompt.php | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index ee10768b..e3204442 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -43,7 +43,6 @@ function ($key) { if ($key === Key::ENTER) { $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; - // $this->checkScrollPosition(); } if ($key[0] === "\e") { @@ -62,31 +61,7 @@ function ($key) { if ($key === Key::CTRL_D) { $this->submit(); } - - // elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) { - // if ($this->cursorPosition === 0) { - // return; - // } - - // // $this->checkScrollPosition(); - // } elseif (ord($key) >= 32) { - // // $this->checkScrollPosition(); - // } } - - // $this->checkScrollPosition(); - - // ray($this->firstVisible, count($this->lines())); - // ray($this->firstVisible + $this->scroll < count($this->lines())); - // ray($this->firstVisible - $this->scroll > count($this->lines())); - - // if ($this->firstVisible - $this->scroll > count($this->lines())) { - // $this->firstVisible--; - // } - - // if ($this->firstVisible + $this->scroll < count($this->lines())) { - // $this->firstVisible++; - // } } ); } From 6a94030c5af4d501e1ce49c679857de990e800d7 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 24 Sep 2023 21:04:28 -0400 Subject: [PATCH 06/59] remove unused method --- src/TextareaPrompt.php | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index e3204442..e4989798 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -66,46 +66,6 @@ function ($key) { ); } - protected function checkScrollPosition() - { - $totalLineLength = 0; - - $currentLineIndex = collect($this->lines())->search(function ($line) use (&$totalLineLength) { - $totalLineLength += mb_strlen($line); - - return $totalLineLength >= $this->cursorPosition; - }); - - ray($this->firstVisible + $this->scroll, $currentLineIndex,); - - if ($this->firstVisible + $this->scroll <= $currentLineIndex) { - $this->firstVisible++; - } - - - // if ($currentLineIndex < $this->firstVisible) { - // $this->firstVisible--; - // } - - // if ($currentLineIndex > $this->firstVisible + $this->scroll) { - // $this->firstVisible++; - // } - - - - - - // if ($this->firstVisible + $this->scroll < count($this->lines())) { - // ray('adding'); - // $this->firstVisible++; - // } - - // if ($this->firstVisible - $this->scroll > count($this->lines())) { - // ray('subtracting'); - // $this->firstVisible--; - // } - } - protected function handleUpKey(): void { if ($this->cursorPosition === 0) { From 685916308bd56ed21157e44d62f59c1db34b2891 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Mon, 25 Sep 2023 01:07:19 +0000 Subject: [PATCH 07/59] Fix code styling --- src/TextareaPrompt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index c6adda93..93aa5bb5 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -41,7 +41,7 @@ public function __construct( 'key', function ($key) { if ($key === Key::ENTER) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; } From c4d0b9cf9c3a757381f903215cce7466994c2117 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 24 Sep 2023 22:15:15 -0400 Subject: [PATCH 08/59] i think we've reached some level of stability he said cautiously --- playground/textarea.php | 19 +++++----- src/TextareaPrompt.php | 80 +++++++++++------------------------------ 2 files changed, 32 insertions(+), 67 deletions(-) diff --git a/playground/textarea.php b/playground/textarea.php index 4c32a010..b6b70df5 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -2,18 +2,21 @@ use function Laravel\Prompts\textarea; -require __DIR__.'/../vendor/autoload.php'; +require __DIR__ . '/../vendor/autoload.php'; $email = textarea( label: 'Tell me a story', default: collect([ - 'first line', - 'second line', - 'third line though', - 'fourth line wow', - 'fifth line are you kidding me', - 'sixth line here we go', - 'seventh line ok sure', + // 12345678, + // 123456, + // 123456789, + // 'first line', + // 'second line', + // 'third line though', + // 'fourth line wow', + // 'fifth line are you kidding me', + // 'sixth line here we go', + // 'seventh line ok sure', ])->join(PHP_EOL), ); diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index c6adda93..f44da102 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -35,6 +35,7 @@ public function __construct( $this->reduceScrollingToFitTerminal(); + // TODO: Is this right? Or should it be at the end? $this->cursorPosition = 0; $this->on( @@ -49,7 +50,6 @@ function ($key) { match ($key) { Key::UP, Key::UP_ARROW, Key::CTRL_P => $this->handleUpKey(), Key::DOWN, Key::DOWN_ARROW, Key::CTRL_N => $this->handleDownKey(), - Key::DELETE => $this->checkScrollPosition(), default => null, }; @@ -77,13 +77,7 @@ protected function handleUpKey(): void // Line length + 1 for the newline character $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); - $totalLineLength = 0; - - $currentLineIndex = $lineLengths->search(function ($lineLength) use (&$totalLineLength) { - $totalLineLength += $lineLength; - - return $totalLineLength >= $this->cursorPosition; - }); + $currentLineIndex = $this->currentLineIndex(); if ($currentLineIndex === 0) { // They're already at the first line, jump them to the first position @@ -92,10 +86,6 @@ protected function handleUpKey(): void return; } - if ($currentLineIndex + $this->firstVisible < $this->scroll && $this->firstVisible > 0) { - $this->firstVisible--; - } - $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); @@ -104,17 +94,6 @@ protected function handleUpKey(): void $newColumn = min($destinationLineLength, $currentColumn); - // ray($lineLengths->get($currentLineIndex - 1), compact( - // 'currentLineIndex', - // 'currentColumn', - // 'destinationLineLength', - // 'newColumn', - // 'currentLines', - // 'lineLengths', - // 'lines', - // 'totalLineLength' - // )); - if ($newColumn < $currentColumn) { $newColumn--; } @@ -128,46 +107,28 @@ protected function handleDownKey(): void { $lines = collect($this->lines()); - // $this->firstVisible = min($lines->count() - $this->scroll, $this->firstVisible + 1); - - // if ($this->cursorPosition === mb_strlen($lines->implode(PHP_EOL))) { - // return; - // } - // Line length + 1 for the newline character $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); - $totalLineLengths = 0; - - $currentLineIndex = $lineLengths->search(function ($lineLength) use (&$totalLineLengths) { - $totalLineLengths += $lineLength; - - return $totalLineLengths >= $this->cursorPosition; - }); + $currentLineIndex = $this->currentLineIndex(); if ($currentLineIndex === $lines->count() - 1) { // They're already at the last line, jump them to the last position - // TODO: Fix this number, it's not using $lines - $this->cursorPosition = mb_strlen($this->typedValue); + $this->cursorPosition = mb_strlen($lines->implode(PHP_EOL)); return; } - if ($currentLineIndex + 1 >= $this->firstVisible + $this->scroll) { - $this->firstVisible++; - } - + // Lines up to and including the current line $currentLines = $lineLengths->slice(0, $currentLineIndex + 1); $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); - $newLineLength = $lineLengths->get($currentLineIndex + 1) ?? $currentLines->last(); - - $newColumn = min($newLineLength, $currentColumn); + $destinationLineLength = ($lineLengths->get($currentLineIndex + 1) ?? $currentLines->last()) - 1; - $fullLines = $lineLengths->slice(0, $currentLines->count()); + $newColumn = min($destinationLineLength, $currentColumn); - $this->cursorPosition = $fullLines->sum() + $newColumn; + $this->cursorPosition = $currentLines->sum() + $newColumn; } /** @@ -179,16 +140,19 @@ public function visible(): array { $currentLineIndex = $this->currentLineIndex(); + $lines = $this->lines(); + if ($this->firstVisible + $this->scroll <= $currentLineIndex) { $this->firstVisible++; } - // Make sure there are always the scroll amount visible - if ($this->firstVisible + $this->scroll > count($this->lines())) { - $this->firstVisible = count($this->lines()) - $this->scroll; + if ($currentLineIndex === $this->firstVisible - 1) { + $this->firstVisible = max(0, $this->firstVisible - 1); } - return array_slice($this->lines(), $this->firstVisible, $this->scroll, preserve_keys: true); + $withCursor = $this->valueWithCursor(implode(PHP_EOL, $lines), 10_000); + + return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); } protected function currentLineIndex(): int @@ -196,15 +160,16 @@ protected function currentLineIndex(): int $totalLineLength = 0; return collect($this->lines())->search(function ($line) use (&$totalLineLength) { - $totalLineLength += mb_strlen($line); + $totalLineLength += mb_strlen($line) + 1; - return $totalLineLength >= $this->cursorPosition; + return $totalLineLength > $this->cursorPosition; }); } public function lines(): array { - $value = $this->valueWithCursor(10_000); + // TODO: Figure out the real number here, this comes from the renderer? + $value = wordwrap($this->value(), 59, PHP_EOL, true); $lines = explode(PHP_EOL, $value); @@ -218,15 +183,12 @@ public function lines(): array /** * Get the entered value with a virtual cursor. */ - public function valueWithCursor(int $maxWidth): string + public function valueWithCursor(string $value, int $maxWidth): string { - if ($this->value() === '') { + if ($value === '') { return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth)); } - // TODO: Figure out the real number here, this comes from the renderer? - $value = wordwrap($this->value(), 59, PHP_EOL, true); - // TODO: Deal with max width properly return $this->addCursor($value, $this->cursorPosition, $maxWidth); } From 31b00b945e5e0aa6b5641a976f34d206f7c4828c Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 13:51:07 -0400 Subject: [PATCH 09/59] fixed value with cursor method --- src/TextareaPrompt.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index f44da102..bc73593f 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -60,6 +60,7 @@ function ($key) { foreach (mb_str_split($key) as $key) { if ($key === Key::CTRL_D) { $this->submit(); + return; } } } @@ -140,8 +141,6 @@ public function visible(): array { $currentLineIndex = $this->currentLineIndex(); - $lines = $this->lines(); - if ($this->firstVisible + $this->scroll <= $currentLineIndex) { $this->firstVisible++; } @@ -150,7 +149,7 @@ public function visible(): array $this->firstVisible = max(0, $this->firstVisible - 1); } - $withCursor = $this->valueWithCursor(implode(PHP_EOL, $lines), 10_000); + $withCursor = $this->valueWithCursor(10_000); return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); } @@ -183,8 +182,10 @@ public function lines(): array /** * Get the entered value with a virtual cursor. */ - public function valueWithCursor(string $value, int $maxWidth): string + public function valueWithCursor(int $maxWidth): string { + $value = implode(PHP_EOL, $this->lines()); + if ($value === '') { return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth)); } From 8cf183dc3e9c74a98864a440422fb19f2be45182 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 13:51:26 -0400 Subject: [PATCH 10/59] submit state --- src/Themes/Default/TextareaPromptRenderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index e7c2da60..aaf15351 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -21,7 +21,7 @@ public function __invoke(TextareaPrompt $prompt): string 'submit' => $this ->box( $this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), - $this->truncate($prompt->value(), $maxWidth), + collect($prompt->lines())->implode(PHP_EOL), ), 'cancel' => $this From eae29b27ee2a71a29e66f6af2d82ee07d1d9046b Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 13:53:03 -0400 Subject: [PATCH 11/59] cancel and error states --- src/Themes/Default/TextareaPromptRenderer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index aaf15351..60514e78 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -27,7 +27,7 @@ public function __invoke(TextareaPrompt $prompt): string 'cancel' => $this ->box( $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), - $this->strikethrough($this->dim($this->truncate($prompt->value() ?: $prompt->placeholder, $maxWidth))), + $this->strikethrough($this->dim(collect($prompt->lines())->implode(PHP_EOL))), color: 'red', ) ->error('Cancelled.'), @@ -35,7 +35,7 @@ public function __invoke(TextareaPrompt $prompt): string 'error' => $this ->box( $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), - $prompt->valueWithCursor($maxWidth), + collect($prompt->lines())->implode(PHP_EOL), color: 'yellow', ) ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), From 4e0993b9c0fc799c48b0b36ad93c9f8e781a66cb Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 14:01:59 -0400 Subject: [PATCH 12/59] fix cursor position if current is a new line --- src/Concerns/TypedValue.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 1cb07bad..cbaad93b 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -38,7 +38,7 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_F => $this->cursorPosition = min(mb_strlen($this->typedValue), $this->cursorPosition + 1), Key::HOME, Key::CTRL_A => $this->cursorPosition = 0, Key::END, Key::CTRL_E => $this->cursorPosition = mb_strlen($this->typedValue), - Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).mb_substr($this->typedValue, $this->cursorPosition + 1), + Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . mb_substr($this->typedValue, $this->cursorPosition + 1), default => null, }; @@ -60,10 +60,10 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca return; } - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1).mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1) . mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition--; } elseif (ord($key) >= 32) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; } } @@ -87,7 +87,7 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): $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); [$truncatedBefore, $wasTruncatedBefore] = mb_strwidth($before) > $spaceBefore @@ -100,10 +100,11 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): : [$after, false]; return ($wasTruncatedBefore ? $this->dim('…') : '') - .$truncatedBefore - .$this->inverse($cursor) - .$truncatedAfter - .($wasTruncatedAfter ? $this->dim('…') : ''); + . $truncatedBefore + . $this->inverse($cursor) + . ($current === PHP_EOL ? PHP_EOL : '') + . $truncatedAfter + . ($wasTruncatedAfter ? $this->dim('…') : ''); } /** From b16042a49ec54077a5cb4f37fa93058c1d1b5e77 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 14:06:25 -0400 Subject: [PATCH 13/59] do a final check to make sure we still have the minimum number of rows --- src/TextareaPrompt.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index bc73593f..240d7351 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -149,6 +149,11 @@ public function visible(): array $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; + } + $withCursor = $this->valueWithCursor(10_000); return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); From 8454a410c39ce20991c18158b720eaf32e7f4ff2 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 15:02:22 -0400 Subject: [PATCH 14/59] fixed error state --- src/Themes/Default/TextareaPromptRenderer.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index 60514e78..77bc577c 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -35,8 +35,9 @@ public function __invoke(TextareaPrompt $prompt): string 'error' => $this ->box( $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), - collect($prompt->lines())->implode(PHP_EOL), + collect($prompt->visible())->implode(PHP_EOL), color: 'yellow', + info: 'Ctrl+D to submit' ) ->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)), From c9fd450c6bdea7d71c762f4aa5628c660e8cb172 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 15:03:17 -0400 Subject: [PATCH 15/59] actually fixed error display --- src/Themes/Default/TextareaPromptRenderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index 77bc577c..fdbcfe38 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -35,7 +35,7 @@ public function __invoke(TextareaPrompt $prompt): string 'error' => $this ->box( $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), - collect($prompt->visible())->implode(PHP_EOL), + $this->renderText($prompt), color: 'yellow', info: 'Ctrl+D to submit' ) From 7f340a4264c1c69cc732cadc0470f2490a6739bb Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 15:18:33 -0400 Subject: [PATCH 16/59] allow new lines as text input --- src/Concerns/TypedValue.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index cbaad93b..8ee86d6d 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, ca $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,16 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca 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; From 268cb2f0509fc7763c94ce25e389e54c91782ffc Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 15:18:48 -0400 Subject: [PATCH 17/59] fixing formatting again --- src/TextareaPrompt.php | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 240d7351..77cb3725 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -30,7 +30,7 @@ public function __construct( $this->trackTypedValue( default: $default, submit: false, - ignore: fn ($key) => $key === Key::ENTER, + allowNewLine: true, ); $this->reduceScrollingToFitTerminal(); @@ -41,11 +41,6 @@ public function __construct( $this->on( 'key', function ($key) { - if ($key === Key::ENTER) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); - $this->cursorPosition++; - } - if ($key[0] === "\e") { match ($key) { Key::UP, Key::UP_ARROW, Key::CTRL_P => $this->handleUpKey(), @@ -139,6 +134,19 @@ protected function handleDownKey(): void */ public function visible(): array { + $this->adjustVisibleWindow(); + + $withCursor = $this->valueWithCursor(10_000); + + return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); + } + + protected function adjustVisibleWindow(): void + { + if (count($this->lines()) < $this->scroll) { + return; + } + $currentLineIndex = $this->currentLineIndex(); if ($this->firstVisible + $this->scroll <= $currentLineIndex) { @@ -153,10 +161,6 @@ public function visible(): array if ($this->firstVisible + $this->scroll > count($this->lines())) { $this->firstVisible = count($this->lines()) - $this->scroll; } - - $withCursor = $this->valueWithCursor(10_000); - - return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); } protected function currentLineIndex(): int @@ -175,13 +179,7 @@ public function lines(): array // TODO: Figure out the real number here, this comes from the renderer? $value = wordwrap($this->value(), 59, PHP_EOL, true); - $lines = explode(PHP_EOL, $value); - - while (count($lines) < $this->scroll) { - $lines[] = ''; - } - - return $lines; + return explode(PHP_EOL, $value); } /** @@ -191,11 +189,11 @@ public function valueWithCursor(int $maxWidth): string { $value = implode(PHP_EOL, $this->lines()); - if ($value === '') { - return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth)); + if ($this->value() === '') { + return $this->dim($this->addCursor($this->placeholder, 0, 10_000)); } // TODO: Deal with max width properly - return $this->addCursor($value, $this->cursorPosition, $maxWidth); + return $this->addCursor($value, $this->cursorPosition, 10_000); } } From d7db85fce713ccc31e7f1d81d53d61fdf499838b Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 15:18:59 -0400 Subject: [PATCH 18/59] handle scroll bottom buffer in renderer --- src/Themes/Default/TextareaPromptRenderer.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index fdbcfe38..e97a61d7 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -57,8 +57,14 @@ public function __invoke(TextareaPrompt $prompt): string protected function renderText(TextareaPrompt $prompt): string { + $visible = collect($prompt->visible()); + + while ($visible->count() < $prompt->scroll) { + $visible->push(''); + } + return $this->scrollbar( - collect($prompt->visible()), + $visible, $prompt->firstVisible, $prompt->scroll, count($prompt->lines()), From 969fe3b329aedcca3eb112af28406ad9950801f2 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 15:24:55 -0400 Subject: [PATCH 19/59] Update textarea.php --- playground/textarea.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/playground/textarea.php b/playground/textarea.php index b6b70df5..f86eb0eb 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -6,18 +6,7 @@ $email = textarea( label: 'Tell me a story', - default: collect([ - // 12345678, - // 123456, - // 123456789, - // 'first line', - // 'second line', - // 'third line though', - // 'fourth line wow', - // 'fifth line are you kidding me', - // 'sixth line here we go', - // 'seventh line ok sure', - ])->join(PHP_EOL), + placeholder: 'Weave me a tale', ); var_dump($email); From d5a80a94eaa3e67babf03c1a05abe828d565e0f9 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Tue, 26 Sep 2023 22:56:25 +0000 Subject: [PATCH 20/59] Fix code styling --- playground/textarea.php | 2 +- src/Concerns/TypedValue.php | 19 ++++++++++--------- src/TextareaPrompt.php | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/playground/textarea.php b/playground/textarea.php index f86eb0eb..070305f2 100644 --- a/playground/textarea.php +++ b/playground/textarea.php @@ -2,7 +2,7 @@ use function Laravel\Prompts\textarea; -require __DIR__ . '/../vendor/autoload.php'; +require __DIR__.'/../vendor/autoload.php'; $email = textarea( label: 'Tell me a story', diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 8ee86d6d..dbe9d70e 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -38,7 +38,7 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_F => $this->cursorPosition = min(mb_strlen($this->typedValue), $this->cursorPosition + 1), Key::HOME, Key::CTRL_A => $this->cursorPosition = 0, Key::END, Key::CTRL_E => $this->cursorPosition = mb_strlen($this->typedValue), - Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . mb_substr($this->typedValue, $this->cursorPosition + 1), + Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).mb_substr($this->typedValue, $this->cursorPosition + 1), default => null, }; @@ -54,11 +54,12 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca if ($key === Key::ENTER) { if ($submit) { $this->submit(); + return; } if ($allowNewLine) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . PHP_EOL . mb_substr($this->typedValue, $this->cursorPosition); + $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) { @@ -66,10 +67,10 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ca return; } - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1) . mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1).mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition--; } elseif (ord($key) >= 32) { - $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition) . $key . mb_substr($this->typedValue, $this->cursorPosition); + $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition); $this->cursorPosition++; } } @@ -106,11 +107,11 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): : [$after, false]; return ($wasTruncatedBefore ? $this->dim('…') : '') - . $truncatedBefore - . $this->inverse($cursor) - . ($current === PHP_EOL ? PHP_EOL : '') - . $truncatedAfter - . ($wasTruncatedAfter ? $this->dim('…') : ''); + .$truncatedBefore + .$this->inverse($cursor) + .($current === PHP_EOL ? PHP_EOL : '') + .$truncatedAfter + .($wasTruncatedAfter ? $this->dim('…') : ''); } /** diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 77cb3725..d183aebf 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -55,6 +55,7 @@ function ($key) { foreach (mb_str_split($key) as $key) { if ($key === Key::CTRL_D) { $this->submit(); + return; } } From 2b086b8f6367a1463515d00b7739b60c77fff1d3 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 18:57:22 -0400 Subject: [PATCH 21/59] Update TextareaPrompt.php --- src/TextareaPrompt.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 77cb3725..3eb6ef9f 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -35,7 +35,6 @@ public function __construct( $this->reduceScrollingToFitTerminal(); - // TODO: Is this right? Or should it be at the end? $this->cursorPosition = 0; $this->on( From 110673047faa12abd5185958fb527eb78f8d3055 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 19:05:34 -0400 Subject: [PATCH 22/59] rows param + docs --- src/TextareaPrompt.php | 22 +++++++++++++++++-- src/Themes/Default/TextareaPromptRenderer.php | 5 +++-- src/helpers.php | 6 ++--- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 3eb6ef9f..634a819d 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -14,6 +14,9 @@ class TextareaPrompt extends Prompt */ public int $firstVisible = 0; + /** + * The number of lines to scroll. + */ public int $scroll = 5; /** @@ -21,6 +24,7 @@ class TextareaPrompt extends Prompt */ public function __construct( public string $label, + public int $rows = 5, public string $placeholder = '', public string $default = '', public bool|string $required = false, @@ -33,6 +37,8 @@ public function __construct( allowNewLine: true, ); + $this->scroll = $this->rows; + $this->reduceScrollingToFitTerminal(); $this->cursorPosition = 0; @@ -61,6 +67,9 @@ function ($key) { ); } + /** + * Handle the up keypress. + */ protected function handleUpKey(): void { if ($this->cursorPosition === 0) { @@ -98,6 +107,9 @@ protected function handleUpKey(): void $this->cursorPosition = $fullLines->sum() + $newColumn; } + /** + * Handle the down keypress. + */ protected function handleDownKey(): void { $lines = collect($this->lines()); @@ -162,6 +174,9 @@ protected function adjustVisibleWindow(): void } } + /** + * Get the index of the current line that the cursor is on. + */ protected function currentLineIndex(): int { $totalLineLength = 0; @@ -173,6 +188,9 @@ protected function currentLineIndex(): int }); } + /** + * Get the formatted lines of the current value. + */ public function lines(): array { // TODO: Figure out the real number here, this comes from the renderer? @@ -182,7 +200,7 @@ public function lines(): array } /** - * Get the entered value with a virtual cursor. + * Get the formatted value with a virtual cursor. */ public function valueWithCursor(int $maxWidth): string { @@ -192,7 +210,7 @@ public function valueWithCursor(int $maxWidth): string return $this->dim($this->addCursor($this->placeholder, 0, 10_000)); } - // TODO: Deal with max width properly + // TODO: Deal with max width properly, 10_000 is a hack return $this->addCursor($value, $this->cursorPosition, 10_000); } } diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index e97a61d7..a953a522 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -15,8 +15,6 @@ class TextareaPromptRenderer extends Renderer implements Scrolling */ public function __invoke(TextareaPrompt $prompt): string { - $maxWidth = $prompt->terminal()->cols() - 6; - return match ($prompt->state) { 'submit' => $this ->box( @@ -55,6 +53,9 @@ public function __invoke(TextareaPrompt $prompt): string }; } + /** + * Render the text in the prompt. + */ protected function renderText(TextareaPrompt $prompt): string { $visible = collect($prompt->visible()); diff --git a/src/helpers.php b/src/helpers.php index a6db9a50..eef77692 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -14,11 +14,11 @@ function text(string $label, string $placeholder = '', string $default = '', boo } /** - * Prompt the user for text input. + * Prompt the user for multiline text input. */ -function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, Closure $validate = null, string $hint = ''): string +function textarea(string $label, int $rows = 5, string $placeholder = '', string $default = '', bool|string $required = false, Closure $validate = null, string $hint = ''): string { - return (new TextareaPrompt($label, $placeholder, $default, $required, $validate, $hint))->prompt(); + return (new TextareaPrompt($label, $rows, $placeholder, $default, $required, $validate, $hint))->prompt(); } /** From 4fde7da06f6ef9d6a1f305dc4cf29df161a6ec09 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 19:17:21 -0400 Subject: [PATCH 23/59] Create TextareaPromptTest.php --- tests/Feature/TextareaPromptTest.php | 152 +++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/Feature/TextareaPromptTest.php diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php new file mode 100644 index 00000000..07fc6da0 --- /dev/null +++ b/tests/Feature/TextareaPromptTest.php @@ -0,0 +1,152 @@ +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'); +}); + +test('support emacs style key binding', 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'); +}); + +test('move to the beginning and end of line', function () { + Prompt::fake(['e', 's', Key::HOME, 'J', KEY::END, 's', Key::CTRL_D]); + + $result = textarea(label: 'What is your name?'); + + expect($result)->toBe('Jess'); +}); + +test('move 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"); +}); + +test('will move 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"); +}); + +test('will move 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('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.'); From 86e98a497fc3a186c88a7013cab3b8b7937e0d5e Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Tue, 26 Sep 2023 23:18:26 +0000 Subject: [PATCH 24/59] Fix code styling --- tests/Feature/TextareaPromptTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php index 07fc6da0..89630eea 100644 --- a/tests/Feature/TextareaPromptTest.php +++ b/tests/Feature/TextareaPromptTest.php @@ -4,7 +4,6 @@ use Laravel\Prompts\Key; use Laravel\Prompts\Prompt; use Laravel\Prompts\TextareaPrompt; -use Laravel\Prompts\TextPrompt; use function Laravel\Prompts\textarea; From 12e02d77a6548ad1ae2c3b07e8e7ecd1866017ec Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 26 Sep 2023 19:26:30 -0400 Subject: [PATCH 25/59] fix static analysis --- src/TextareaPrompt.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index e339bd9f..812fe714 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -142,7 +142,7 @@ protected function handleDownKey(): void /** * The currently visible options. * - * @return array + * @return array */ public function visible(): array { @@ -182,15 +182,17 @@ protected function currentLineIndex(): int { $totalLineLength = 0; - return collect($this->lines())->search(function ($line) use (&$totalLineLength) { + return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) { $totalLineLength += mb_strlen($line) + 1; return $totalLineLength > $this->cursorPosition; - }); + }) ?: 0; } /** * Get the formatted lines of the current value. + * + * @return array */ public function lines(): array { From d20434e3fcc40282c13e2120a903864d26ae75ff Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 20 Oct 2023 17:11:11 +1000 Subject: [PATCH 26/59] Update scrolling initialization --- src/TextareaPrompt.php | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 812fe714..fe5ce126 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -6,19 +6,9 @@ class TextareaPrompt extends Prompt { - use Concerns\ReducesScrollingToFitTerminal; + use Concerns\Scrolling; use Concerns\TypedValue; - /** - * The index of the first visible option. - */ - public int $firstVisible = 0; - - /** - * The number of lines to scroll. - */ - public int $scroll = 5; - /** * Create a new TextareaPrompt instance. */ @@ -39,9 +29,7 @@ public function __construct( $this->scroll = $this->rows; - $this->reduceScrollingToFitTerminal(); - - $this->cursorPosition = 0; + $this->initializeScrolling(); $this->on( 'key', From 768125d5840d7087e987fb0a9141d4cd96377fb0 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 20 Oct 2023 17:38:31 +1000 Subject: [PATCH 27/59] Fix test --- tests/Feature/TextareaPromptTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php index 89630eea..f4d9f4e0 100644 --- a/tests/Feature/TextareaPromptTest.php +++ b/tests/Feature/TextareaPromptTest.php @@ -86,7 +86,7 @@ }); test('move to the beginning and end of line', function () { - Prompt::fake(['e', 's', Key::HOME, 'J', KEY::END, 's', Key::CTRL_D]); + Prompt::fake(['e', 's', Key::HOME[0], 'J', KEY::END[0], 's', Key::CTRL_D]); $result = textarea(label: 'What is your name?'); From 013a3add3527a148215168d6c6c154e741bad140 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 20 Oct 2023 17:52:07 +1000 Subject: [PATCH 28/59] Formatting --- tests/Feature/TextareaPromptTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php index f4d9f4e0..cfe61e43 100644 --- a/tests/Feature/TextareaPromptTest.php +++ b/tests/Feature/TextareaPromptTest.php @@ -77,7 +77,7 @@ expect($result)->toBe('result'); }); -test('support emacs style key binding', function () { +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?'); @@ -85,7 +85,7 @@ expect($result)->toBe('Jess'); }); -test('move to the beginning and end of line', function () { +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?'); @@ -93,7 +93,7 @@ expect($result)->toBe('Jess'); }); -test('move up and down lines', function () { +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, @@ -105,7 +105,7 @@ expect($result)->toBe("Jess\nJoe"); }); -test('will move to the start of the line if up is pressed twice on the first line', function () { +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, @@ -116,7 +116,7 @@ expect($result)->toBe("Jess\nJoe"); }); -test('will move to the end of the line if down is pressed twice on the last line', function () { +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, From d513b902f5f5a41bf3ed821274ed323f6a5fac3e Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Fri, 20 Oct 2023 17:52:27 +1000 Subject: [PATCH 29/59] Fix issue with empty last line --- src/TextareaPrompt.php | 2 +- tests/Feature/TextareaPromptTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index fe5ce126..eef3ae1f 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -122,7 +122,7 @@ protected function handleDownKey(): void $destinationLineLength = ($lineLengths->get($currentLineIndex + 1) ?? $currentLines->last()) - 1; - $newColumn = min($destinationLineLength, $currentColumn); + $newColumn = min(max(0, $destinationLineLength), $currentColumn); $this->cursorPosition = $currentLines->sum() + $newColumn; } diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php index cfe61e43..0b93b1a6 100644 --- a/tests/Feature/TextareaPromptTest.php +++ b/tests/Feature/TextareaPromptTest.php @@ -128,6 +128,19 @@ 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); From 7591fbe04334e0567daea291abb88b37d8c3c80f Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 20 Oct 2023 21:28:48 -0400 Subject: [PATCH 30/59] fixed cancelled state so that the strikethrough doesn't affect the box --- src/Themes/Default/TextareaPromptRenderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index a953a522..8d7e906f 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -25,7 +25,7 @@ public function __invoke(TextareaPrompt $prompt): string 'cancel' => $this ->box( $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), - $this->strikethrough($this->dim(collect($prompt->lines())->implode(PHP_EOL))), + collect($prompt->lines())->map(fn ($line) => $this->strikethrough($this->dim($line)))->implode(PHP_EOL), color: 'red', ) ->error('Cancelled.'), From 7ad75f978667c89eccfb95e98ba459ab4e188ec7 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 20 Oct 2023 21:44:06 -0400 Subject: [PATCH 31/59] calculate proper width with each render --- src/TextareaPrompt.php | 5 +++-- src/Themes/Default/TextareaPromptRenderer.php | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index eef3ae1f..a3904919 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -9,6 +9,8 @@ class TextareaPrompt extends Prompt use Concerns\Scrolling; use Concerns\TypedValue; + public int $width = 60; + /** * Create a new TextareaPrompt instance. */ @@ -184,8 +186,7 @@ protected function currentLineIndex(): int */ public function lines(): array { - // TODO: Figure out the real number here, this comes from the renderer? - $value = wordwrap($this->value(), 59, PHP_EOL, true); + $value = wordwrap($this->value(), $this->width - 1, PHP_EOL, true); return explode(PHP_EOL, $value); } diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index 8d7e906f..4afb9f8f 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -15,6 +15,8 @@ class TextareaPromptRenderer extends Renderer implements Scrolling */ public function __invoke(TextareaPrompt $prompt): string { + $prompt->width = min($this->minWidth, $prompt->terminal()->cols() - 6); + return match ($prompt->state) { 'submit' => $this ->box( @@ -69,7 +71,7 @@ protected function renderText(TextareaPrompt $prompt): string $prompt->firstVisible, $prompt->scroll, count($prompt->lines()), - $this->minWidth, + $prompt->width, )->implode(PHP_EOL); } From a242eebd6eecd193fc036aec5af07d0780135402 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 20 Oct 2023 21:53:15 -0400 Subject: [PATCH 32/59] pass max width as a negative number to avoid truncation --- src/Concerns/TypedValue.php | 4 ++-- src/TextareaPrompt.php | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index 52c40f24..33ff0c56 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -96,12 +96,12 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): $cursor = mb_strlen($current) && $current !== PHP_EOL ? $current : ' '; - $spaceBefore = $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0); + $spaceBefore = $maxWidth < 0 ? 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 ? 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]; diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index a3904919..9225f895 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -202,7 +202,6 @@ public function valueWithCursor(int $maxWidth): string return $this->dim($this->addCursor($this->placeholder, 0, 10_000)); } - // TODO: Deal with max width properly, 10_000 is a hack - return $this->addCursor($value, $this->cursorPosition, 10_000); + return $this->addCursor($value, $this->cursorPosition, -1); } } From 9758e82b9619fbeb27715b6b7163be79e0413d68 Mon Sep 17 00:00:00 2001 From: jessarcher Date: Wed, 27 Dec 2023 05:45:46 +0000 Subject: [PATCH 33/59] Fix code styling --- src/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.php b/src/helpers.php index a609d08f..21a083ce 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -16,7 +16,7 @@ function text(string $label, string $placeholder = '', string $default = '', boo /** * Prompt the user for multiline text input. */ -function textarea(string $label, int $rows = 5, string $placeholder = '', string $default = '', bool|string $required = false, Closure $validate = null, string $hint = ''): string +function textarea(string $label, int $rows = 5, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = ''): string { return (new TextareaPrompt($label, $rows, $placeholder, $default, $required, $validate, $hint))->prompt(); } From 508a275294b76fab30c95c8a69e1fa01ca9c1177 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 27 Dec 2023 16:05:40 -0800 Subject: [PATCH 34/59] fix long word wrapping + cursor position --- src/TextareaPrompt.php | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 9225f895..f2076134 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -11,6 +11,8 @@ class TextareaPrompt extends Prompt public int $width = 60; + protected int $cursorOffset = 0; + /** * Create a new TextareaPrompt instance. */ @@ -186,6 +188,8 @@ protected function currentLineIndex(): int */ public function lines(): array { + $this->calculateCursorOffset(); + $value = wordwrap($this->value(), $this->width - 1, PHP_EOL, true); return explode(PHP_EOL, $value); @@ -202,6 +206,24 @@ public function valueWithCursor(int $maxWidth): string return $this->dim($this->addCursor($this->placeholder, 0, 10_000)); } - return $this->addCursor($value, $this->cursorPosition, -1); + return $this->addCursor($value, $this->cursorPosition + $this->cursorOffset, -1); + } + + /** + * When there are long words that wrap, the `typedValue` and the display value are different, + * (due to the inserted new line characters from the word wrap) and the cursor calculation is off. + * This method calculates the difference and adjusts the cursor position accordingly. + */ + protected function calculateCursorOffset(): void + { + $this->cursorOffset = 0; + + preg_match_all('/\S{' . ($this->width) . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + + foreach ($matches[0] as $match) { + if ($match[1] + mb_strlen($match[0]) <= $this->cursorPosition + $this->cursorOffset) { + $this->cursorOffset += (int) floor(mb_strlen($match[0]) / ($this->width - 1)); + } + } } } From b130780e6945dcb3846297359e567a70e8ccf06c Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Thu, 28 Dec 2023 00:06:02 +0000 Subject: [PATCH 35/59] Fix code styling --- src/TextareaPrompt.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index f2076134..6b04eaed 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -218,10 +218,10 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{' . ($this->width) . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{'.($this->width).',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { - if ($match[1] + mb_strlen($match[0]) <= $this->cursorPosition + $this->cursorOffset) { + if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strlen($match[0])) { $this->cursorOffset += (int) floor(mb_strlen($match[0]) / ($this->width - 1)); } } From 0c8ea67236cf8f4630095ae246456416f77501aa Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 3 Jan 2024 20:47:45 -0800 Subject: [PATCH 36/59] mb_wordwrap --- src/helpers.php | 83 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/helpers.php b/src/helpers.php index 21a083ce..b4e4ec1f 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -192,3 +192,86 @@ function progress(string $label, iterable|int $steps, ?Closure $callback = null, return $progress; } + +/** + * Multi-byte version of wordwrap. + */ +function mb_wordwrap( + 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); +} From 8cf7e50aff5f61ad0f9f32199760b8d8e7de1e0d Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 3 Jan 2024 20:47:52 -0800 Subject: [PATCH 37/59] getting closer to consistent --- src/TextareaPrompt.php | 21 ++++++++++++------- src/Themes/Default/TextareaPromptRenderer.php | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 6b04eaed..7eac669d 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -11,6 +11,8 @@ class TextareaPrompt extends Prompt public int $width = 60; + public int $maxLineWidth = 60; + protected int $cursorOffset = 0; /** @@ -72,7 +74,7 @@ protected function handleUpKey(): void $lines = collect($this->lines()); // Line length + 1 for the newline character - $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); + $lineLengths = $lines->map(fn ($line, $index) => mb_strwidth($line) + ($index === $lines->count() - 1 ? 0 : 1)); $currentLineIndex = $this->currentLineIndex(); @@ -108,13 +110,13 @@ protected function handleDownKey(): void $lines = collect($this->lines()); // Line length + 1 for the newline character - $lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1)); + $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_strlen($lines->implode(PHP_EOL)); + $this->cursorPosition = mb_strwidth($lines->implode(PHP_EOL)); return; } @@ -175,7 +177,7 @@ protected function currentLineIndex(): int $totalLineLength = 0; return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) { - $totalLineLength += mb_strlen($line) + 1; + $totalLineLength += mb_strwidth($line) + 1; return $totalLineLength > $this->cursorPosition; }) ?: 0; @@ -188,9 +190,12 @@ protected function currentLineIndex(): int */ public function lines(): array { + // Subtract 2 for the scrollbar + $this->maxLineWidth = $this->width - 2; + $this->calculateCursorOffset(); - $value = wordwrap($this->value(), $this->width - 1, PHP_EOL, true); + $value = mb_wordwrap($this->value(), $this->maxLineWidth, PHP_EOL, true); return explode(PHP_EOL, $value); } @@ -218,11 +223,11 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{'.($this->width).',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { - if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strlen($match[0])) { - $this->cursorOffset += (int) floor(mb_strlen($match[0]) / ($this->width - 1)); + if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { + $this->cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->maxLineWidth); } } } diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index 4afb9f8f..d76660df 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -15,7 +15,7 @@ class TextareaPromptRenderer extends Renderer implements Scrolling */ public function __invoke(TextareaPrompt $prompt): string { - $prompt->width = min($this->minWidth, $prompt->terminal()->cols() - 6); + $prompt->width = $prompt->terminal()->cols() - 6; return match ($prompt->state) { 'submit' => $this From c6dce583dfe99a45815e1706f7ce9cfc78d675cb Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 3 Jan 2024 20:56:17 -0800 Subject: [PATCH 38/59] fix scroll width --- src/Themes/Default/TextareaPromptRenderer.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index d76660df..fa6487f1 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -20,13 +20,13 @@ public function __invoke(TextareaPrompt $prompt): string return match ($prompt->state) { 'submit' => $this ->box( - $this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $this->dim($this->truncate($prompt->label, $prompt->width)), collect($prompt->lines())->implode(PHP_EOL), ), 'cancel' => $this ->box( - $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), + $this->truncate($prompt->label, $prompt->width), collect($prompt->lines())->map(fn ($line) => $this->strikethrough($this->dim($line)))->implode(PHP_EOL), color: 'red', ) @@ -34,7 +34,7 @@ public function __invoke(TextareaPrompt $prompt): string 'error' => $this ->box( - $this->truncate($prompt->label, $prompt->terminal()->cols() - 6), + $this->truncate($prompt->label, $prompt->width), $this->renderText($prompt), color: 'yellow', info: 'Ctrl+D to submit' @@ -43,7 +43,7 @@ public function __invoke(TextareaPrompt $prompt): string default => $this ->box( - $this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)), + $this->cyan($this->truncate($prompt->label, $prompt->width)), $this->renderText($prompt), info: 'Ctrl+D to submit' ) @@ -66,12 +66,14 @@ protected function renderText(TextareaPrompt $prompt): string $visible->push(''); } + $longest = $this->longest($prompt->lines()) + 2; + return $this->scrollbar( $visible, $prompt->firstVisible, $prompt->scroll, count($prompt->lines()), - $prompt->width, + min($longest, $prompt->width), )->implode(PHP_EOL); } From 3347909ee00989a4d1d7bc1f234c63be40224446 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Thu, 4 Jan 2024 04:56:54 +0000 Subject: [PATCH 39/59] Fix code styling --- src/TextareaPrompt.php | 2 +- src/helpers.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 7eac669d..88725fff 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -223,7 +223,7 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { diff --git a/src/helpers.php b/src/helpers.php index b4e4ec1f..d9f25e54 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -208,6 +208,7 @@ function mb_wordwrap( foreach ($lines as $originalLine) { if (mb_strwidth($originalLine) <= $width) { $result[] = $originalLine; + continue; } @@ -222,7 +223,7 @@ function mb_wordwrap( $str = ''; foreach ($characters as $character) { - $tmp = $str . $character; + $tmp = $str.$character; if (mb_strwidth($tmp) > $width) { $strings[] = $str; @@ -243,7 +244,7 @@ function mb_wordwrap( } foreach ($words as $word) { - $tmp = ($line === null) ? $word : $line . ' ' . $word; + $tmp = ($line === null) ? $word : $line.' '.$word; // Look for zero-width joiner characters (combined emojis) preg_match('/\p{Cf}/u', $word, $joinerMatches); From 41cda9cf9f814e3be8a2fe16755d2d07ec7a6034 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 3 Jan 2024 21:00:10 -0800 Subject: [PATCH 40/59] appease phpstan --- src/helpers.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/helpers.php b/src/helpers.php index b4e4ec1f..e252c504 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -195,6 +195,8 @@ function progress(string $label, iterable|int $steps, ?Closure $callback = null, /** * Multi-byte version of wordwrap. + * + * @param non-empty-string $break */ function mb_wordwrap( string $string, From e8fdac9b061eaeea9e86202b89e0a65b6bda5d45 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Thu, 4 Jan 2024 05:01:06 +0000 Subject: [PATCH 41/59] Fix code styling --- src/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.php b/src/helpers.php index 86840e9f..9f6422db 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -196,7 +196,7 @@ function progress(string $label, iterable|int $steps, ?Closure $callback = null, /** * Multi-byte version of wordwrap. * - * @param non-empty-string $break + * @param non-empty-string $break */ function mb_wordwrap( string $string, From 5663c18b6a18987e030e03e672b782224a4c27a9 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 10 Jan 2024 21:49:58 -0500 Subject: [PATCH 42/59] Create MultiByteWordWrapTest.php --- tests/Feature/MultiByteWordWrapTest.php | 189 ++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/Feature/MultiByteWordWrapTest.php diff --git a/tests/Feature/MultiByteWordWrapTest.php b/tests/Feature/MultiByteWordWrapTest.php new file mode 100644 index 00000000..06c798fa --- /dev/null +++ b/tests/Feature/MultiByteWordWrapTest.php @@ -0,0 +1,189 @@ +toBe($result); +}); + +test('will match wordwrap on shorter strings', function () { + $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 = mb_wordwrap($str); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap on blank lines strings', function () { + $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 = mb_wordwrap($str); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap with cut long words enabled', function () { + $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 = mb_wordwrap($str, 25, "\n", true); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap with random multiple spaces', function () { + $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 = mb_wordwrap($str, 25, "\n", true); + + expect($mbResult)->toBe($result); +}); + +test('will match wordwrap with cut long words disabled', function () { + $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 = mb_wordwrap($str, 25, "\n", false); + + expect($mbResult)->toBe($result); +}); + +test('will wrap strings with multi-byte characters', function () { + $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 = mb_wordwrap($str, 18, "\n", false); + + $expectedResult = <<toBe($expectedResult); +}); + +test('will wrap strings with emojis', function () { + $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 = mb_wordwrap($str, 13, "\n", false); + + $expectedResult = <<toBe($expectedResult); +}); + +test('will wrap strings with emojis and multi-byte characters', function () { + $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 = mb_wordwrap($str, 11, "\n", false); + + $expectedResult = <<toBe($expectedResult); +}); + +test('will wrap strings with combined emojis', function () { + $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 = mb_wordwrap($str, 13, "\n", false); + + $expectedResult = <<toBe($expectedResult); +}); + +test('will handle long strings with multi-byte characters and emojis with cut long words enabled', function () { + $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 = mb_wordwrap($str, 13, "\n", false); + + $expectedResult = <<toBe($expectedResult); +}); From eb94a6c0c9fc24637b1eef474b9bd66e536bcfcb Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 30 Jan 2024 20:17:23 -0500 Subject: [PATCH 43/59] remove maxLineWIdth property --- src/TextareaPrompt.php | 9 +++------ src/Themes/Default/TextareaPromptRenderer.php | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 88725fff..a59bf610 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -190,12 +190,9 @@ protected function currentLineIndex(): int */ public function lines(): array { - // Subtract 2 for the scrollbar - $this->maxLineWidth = $this->width - 2; - $this->calculateCursorOffset(); - $value = mb_wordwrap($this->value(), $this->maxLineWidth, PHP_EOL, true); + $value = mb_wordwrap($this->value(), $this->width, PHP_EOL, true); return explode(PHP_EOL, $value); } @@ -223,11 +220,11 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { - $this->cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->maxLineWidth); + $this->cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->width); } } } diff --git a/src/Themes/Default/TextareaPromptRenderer.php b/src/Themes/Default/TextareaPromptRenderer.php index fa6487f1..7a1e6da7 100644 --- a/src/Themes/Default/TextareaPromptRenderer.php +++ b/src/Themes/Default/TextareaPromptRenderer.php @@ -15,7 +15,7 @@ class TextareaPromptRenderer extends Renderer implements Scrolling */ public function __invoke(TextareaPrompt $prompt): string { - $prompt->width = $prompt->terminal()->cols() - 6; + $prompt->width = $prompt->terminal()->cols() - 8; return match ($prompt->state) { 'submit' => $this @@ -73,7 +73,7 @@ protected function renderText(TextareaPrompt $prompt): string $prompt->firstVisible, $prompt->scroll, count($prompt->lines()), - min($longest, $prompt->width), + min($longest, $prompt->width + 2), )->implode(PHP_EOL); } From 2a2c6702480b5bb043e0577b066c188eec2df8b3 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 30 Jan 2024 20:17:59 -0500 Subject: [PATCH 44/59] actually remove maxLineWidth property --- src/TextareaPrompt.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index a59bf610..0991e8c4 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -11,8 +11,6 @@ class TextareaPrompt extends Prompt public int $width = 60; - public int $maxLineWidth = 60; - protected int $cursorOffset = 0; /** From c16cfb7b292a09bf76fd2a3f29be8c38c5f390dd Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 30 Jan 2024 20:59:14 -0500 Subject: [PATCH 45/59] fixed the off by one errors when using the up/down keys --- src/TextareaPrompt.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 0991e8c4..201a4a66 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -87,14 +87,10 @@ protected function handleUpKey(): void $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); - $destinationLineLength = $lineLengths->get($currentLineIndex - 1) ?? $currentLines->first(); + $destinationLineLength = ($lineLengths->get($currentLineIndex - 1) ?? $currentLines->first()) - 1; $newColumn = min($destinationLineLength, $currentColumn); - if ($newColumn < $currentColumn) { - $newColumn--; - } - $fullLines = $currentLines->slice(0, -2); $this->cursorPosition = $fullLines->sum() + $newColumn; @@ -124,7 +120,7 @@ protected function handleDownKey(): void $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); - $destinationLineLength = ($lineLengths->get($currentLineIndex + 1) ?? $currentLines->last()) - 1; + $destinationLineLength = ($lineLengths->get($currentLineIndex + 1) ?? $currentLines->last()); $newColumn = min(max(0, $destinationLineLength), $currentColumn); From 4a047ca58eadfe78bd165762cd60b17784148b8d Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 30 Jan 2024 21:02:58 -0500 Subject: [PATCH 46/59] fixed bug where pasting a bunch of content didn't put the cursor in the viewport --- src/TextareaPrompt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 201a4a66..bafcc998 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -149,7 +149,7 @@ protected function adjustVisibleWindow(): void $currentLineIndex = $this->currentLineIndex(); - if ($this->firstVisible + $this->scroll <= $currentLineIndex) { + while ($this->firstVisible + $this->scroll <= $currentLineIndex) { $this->firstVisible++; } From e1d46b459f303ea6791b3154644ed540fa8640c5 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Wed, 31 Jan 2024 02:03:20 +0000 Subject: [PATCH 47/59] Fix code styling --- src/TextareaPrompt.php | 2 +- tests/Feature/MultiByteWordWrapTest.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index bafcc998..9228d795 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -214,7 +214,7 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { diff --git a/tests/Feature/MultiByteWordWrapTest.php b/tests/Feature/MultiByteWordWrapTest.php index 06c798fa..88e6ad4d 100644 --- a/tests/Feature/MultiByteWordWrapTest.php +++ b/tests/Feature/MultiByteWordWrapTest.php @@ -67,7 +67,7 @@ $mbResult = mb_wordwrap($str, 18, "\n", false); - $expectedResult = << Date: Tue, 30 Jan 2024 21:06:38 -0500 Subject: [PATCH 48/59] changed visiblity of validate property --- src/TextareaPrompt.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 9228d795..a701a2cc 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -22,7 +22,7 @@ public function __construct( public string $placeholder = '', public string $default = '', public bool|string $required = false, - public ?Closure $validate = null, + protected ?Closure $validate = null, public string $hint = '' ) { $this->trackTypedValue( @@ -214,7 +214,7 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { From 8a677ce286667326f81ad4b27b8c5a18bf9db478 Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Wed, 31 Jan 2024 02:06:59 +0000 Subject: [PATCH 49/59] Fix code styling --- src/TextareaPrompt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index a701a2cc..658a621f 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -214,7 +214,7 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { From 3df51ffc3703d21b9e979727e78bcf89f35823ab Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 5 Mar 2024 12:14:40 -0500 Subject: [PATCH 50/59] validate property should be public --- src/TextareaPrompt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 658a621f..9228d795 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -22,7 +22,7 @@ public function __construct( public string $placeholder = '', public string $default = '', public bool|string $required = false, - protected ?Closure $validate = null, + public ?Closure $validate = null, public string $hint = '' ) { $this->trackTypedValue( From 9d72523c347ef1ff9e06f0bea6dc14c966e6e454 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 5 Mar 2024 12:16:15 -0500 Subject: [PATCH 51/59] validate property should be mixed --- src/TextareaPrompt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 9228d795..7707fd90 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -22,7 +22,7 @@ public function __construct( public string $placeholder = '', public string $default = '', public bool|string $required = false, - public ?Closure $validate = null, + public mixed $validate = null, public string $hint = '' ) { $this->trackTypedValue( From 4d8a63dea2e1373ba8b8d105a9c91048ae50e57d Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Tue, 5 Mar 2024 17:16:38 +0000 Subject: [PATCH 52/59] Fix code styling --- src/TextareaPrompt.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 7707fd90..dc896ff1 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -2,8 +2,6 @@ namespace Laravel\Prompts; -use Closure; - class TextareaPrompt extends Prompt { use Concerns\Scrolling; From 1a7a8c6be56b1685b0cda0278e13c7d612410b34 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 6 Mar 2024 08:58:34 -0500 Subject: [PATCH 53/59] add ability to reset cancel using + reset when using it in tests --- src/Prompt.php | 4 ++-- tests/Feature/TextPromptTest.php | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) 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/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]); From 66330e6e706efe0cbf17146cd23d22e9b40c9b28 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 20 Mar 2024 15:38:33 -0400 Subject: [PATCH 54/59] fix for strange down arrow behavior --- src/TextareaPrompt.php | 6 ++++- tests/Feature/TextareaPromptTest.php | 36 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index dc896ff1..c871221e 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -118,7 +118,11 @@ protected function handleDownKey(): void $currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition); - $destinationLineLength = ($lineLengths->get($currentLineIndex + 1) ?? $currentLines->last()); + $destinationLineLength = $lineLengths->get($currentLineIndex + 1) ?? $currentLines->last(); + + if ($currentLineIndex + 1 !== $lines->count() - 1) { + $destinationLineLength--; + } $newColumn = min(max(0, $destinationLineLength), $currentColumn); diff --git a/tests/Feature/TextareaPromptTest.php b/tests/Feature/TextareaPromptTest.php index 0b93b1a6..42d87ba5 100644 --- a/tests/Feature/TextareaPromptTest.php +++ b/tests/Feature/TextareaPromptTest.php @@ -162,3 +162,39 @@ 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"); +}); From d5699cfa212bd8ef59f8bbb2e0176ca1034a8000 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 31 Mar 2024 08:56:35 -0700 Subject: [PATCH 55/59] move mb_wordwrap to truncation trait --- src/Concerns/Truncation.php | 90 ++++++++++++++++++++++++- src/TextareaPrompt.php | 5 +- src/helpers.php | 86 ----------------------- tests/Feature/MultiByteWordWrapTest.php | 56 ++++++++------- 4 files changed, 125 insertions(+), 112 deletions(-) diff --git a/src/Concerns/Truncation.php b/src/Concerns/Truncation.php index ec40a02b..7bf2890e 100644 --- a/src/Concerns/Truncation.php +++ b/src/Concerns/Truncation.php @@ -15,6 +15,94 @@ protected function truncate(string $string, int $width): string throw new InvalidArgumentException("Width [{$width}] must be greater than zero."); } - return mb_strwidth($string) <= $width ? $string : (mb_strimwidth($string, 0, $width - 1).'…'); + 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/TextareaPrompt.php b/src/TextareaPrompt.php index c871221e..8bec0f71 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -6,6 +6,7 @@ class TextareaPrompt extends Prompt { use Concerns\Scrolling; use Concerns\TypedValue; + use Concerns\Truncation; public int $width = 60; @@ -188,7 +189,7 @@ public function lines(): array { $this->calculateCursorOffset(); - $value = mb_wordwrap($this->value(), $this->width, PHP_EOL, true); + $value = $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true); return explode(PHP_EOL, $value); } @@ -216,7 +217,7 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { diff --git a/src/helpers.php b/src/helpers.php index dfe42e2a..23b72ea7 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -200,89 +200,3 @@ function progress(string $label, iterable|int $steps, ?Closure $callback = null, return $progress; } - -/** - * Multi-byte version of wordwrap. - * - * @param non-empty-string $break - */ -function mb_wordwrap( - 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/tests/Feature/MultiByteWordWrapTest.php b/tests/Feature/MultiByteWordWrapTest.php index 88e6ad4d..84595f53 100644 --- a/tests/Feature/MultiByteWordWrapTest.php +++ b/tests/Feature/MultiByteWordWrapTest.php @@ -1,71 +1,81 @@ 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 = mb_wordwrap($str); + $mbResult = $instance->wordwrap($str); expect($mbResult)->toBe($result); }); -test('will match wordwrap on shorter strings', function () { +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 = mb_wordwrap($str); + $mbResult = $instance->wordwrap($str); expect($mbResult)->toBe($result); }); -test('will match wordwrap on blank lines strings', function () { +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 = mb_wordwrap($str); + $mbResult = $instance->wordwrap($str); expect($mbResult)->toBe($result); }); -test('will match wordwrap with cut long words enabled', function () { +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 = mb_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 () { +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 = mb_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 () { +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 = mb_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 () { +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 = mb_wordwrap($str, 18, "\n", false); + $mbResult = $instance->wordwrap($str, 18, "\n", false); $expectedResult = <<<'RESULT' This is a story @@ -84,10 +94,10 @@ expect($mbResult)->toBe($expectedResult); }); -test('will wrap strings with emojis', function () { +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 = mb_wordwrap($str, 13, "\n", false); + $mbResult = $instance->wordwrap($str, 13, "\n", false); $expectedResult = <<<'RESULT' This is a 📖 @@ -109,10 +119,10 @@ expect($mbResult)->toBe($expectedResult); }); -test('will wrap strings with emojis and multi-byte characters', function () { +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 = mb_wordwrap($str, 11, "\n", false); + $mbResult = $instance->wordwrap($str, 11, "\n", false); $expectedResult = <<<'RESULT' This is a @@ -138,10 +148,10 @@ expect($mbResult)->toBe($expectedResult); }); -test('will wrap strings with combined emojis', function () { +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 = mb_wordwrap($str, 13, "\n", false); + $mbResult = $instance->wordwrap($str, 13, "\n", false); $expectedResult = <<<'RESULT' This is a 📖 @@ -163,10 +173,10 @@ expect($mbResult)->toBe($expectedResult); }); -test('will handle long strings with multi-byte characters and emojis with cut long words enabled', function () { +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 = mb_wordwrap($str, 13, "\n", false); + $mbResult = $instance->wordwrap($str, 13, "\n", false); $expectedResult = <<<'RESULT' This is a 📖 From 4d5c1825c70e2cee69ec44011ffd010a84a0334b Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Sun, 31 Mar 2024 08:59:34 -0700 Subject: [PATCH 56/59] move rows param to last position --- src/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.php b/src/helpers.php index 23b72ea7..a371699f 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -16,7 +16,7 @@ function text(string $label, string $placeholder = '', string $default = '', boo /** * Prompt the user for multiline text input. */ -function textarea(string $label, int $rows = 5, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = ''): string +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, $rows, $placeholder, $default, $required, $validate, $hint))->prompt(); } From 1efa24db438012b7aa63db879da75ef9932f80bd Mon Sep 17 00:00:00 2001 From: joetannenbaum Date: Sun, 31 Mar 2024 23:55:55 +0000 Subject: [PATCH 57/59] Fix code styling --- src/Concerns/Truncation.php | 8 +++----- src/TextareaPrompt.php | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Concerns/Truncation.php b/src/Concerns/Truncation.php index 7bf2890e..84cf60e7 100644 --- a/src/Concerns/Truncation.php +++ b/src/Concerns/Truncation.php @@ -15,11 +15,9 @@ protected function truncate(string $string, int $width): string throw new InvalidArgumentException("Width [{$width}] must be greater than zero."); } - return mb_strwidth($string) <= $width ? $string : (mb_strimwidth($string, 0, $width - 1) . '…'); + return mb_strwidth($string) <= $width ? $string : (mb_strimwidth($string, 0, $width - 1).'…'); } - - /** * Multi-byte version of wordwrap. * @@ -52,7 +50,7 @@ protected function mbWordwrap( $str = ''; foreach ($characters as $character) { - $tmp = $str . $character; + $tmp = $str.$character; if (mb_strwidth($tmp) > $width) { $strings[] = $str; @@ -73,7 +71,7 @@ protected function mbWordwrap( } foreach ($words as $word) { - $tmp = ($line === null) ? $word : $line . ' ' . $word; + $tmp = ($line === null) ? $word : $line.' '.$word; // Look for zero-width joiner characters (combined emojis) preg_match('/\p{Cf}/u', $word, $joinerMatches); diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 8bec0f71..53d9feb1 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -5,8 +5,8 @@ class TextareaPrompt extends Prompt { use Concerns\Scrolling; - use Concerns\TypedValue; use Concerns\Truncation; + use Concerns\TypedValue; public int $width = 60; @@ -217,7 +217,7 @@ protected function calculateCursorOffset(): void { $this->cursorOffset = 0; - preg_match_all('/\S{' . $this->width . ',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); + preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { From 4515c98ebefca145592ed1a26a75a5d9fd7d3378 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Wed, 3 Apr 2024 14:44:59 +1000 Subject: [PATCH 58/59] Formatting --- src/TextareaPrompt.php | 123 ++++++++++++++++++++--------------------- src/helpers.php | 2 +- 2 files changed, 62 insertions(+), 63 deletions(-) diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 53d9feb1..203317b4 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -8,59 +8,83 @@ class TextareaPrompt extends Prompt use Concerns\Truncation; use Concerns\TypedValue; + /** + * The width of the textarea. + */ public int $width = 60; - protected int $cursorOffset = 0; - /** * Create a new TextareaPrompt instance. */ public function __construct( public string $label, - public int $rows = 5, public string $placeholder = '', public string $default = '', public bool|string $required = false, public mixed $validate = null, - public string $hint = '' + public string $hint = '', + int $rows = 5, ) { + $this->scroll = $rows; + + $this->initializeScrolling(); + $this->trackTypedValue( default: $default, submit: false, allowNewLine: true, ); - $this->scroll = $this->rows; + $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, + }; - $this->initializeScrolling(); + return; + } - $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, - }; + // Keys may be buffered. + foreach (mb_str_split($key) as $key) { + if ($key === Key::CTRL_D) { + $this->submit(); return; } + } + }); + } + + /** + * The currently visible lines. + * + * @return array + */ + public function visible(): array + { + $this->adjustVisibleWindow(); - // Keys may be buffered. - foreach (mb_str_split($key) as $key) { - if ($key === Key::CTRL_D) { - $this->submit(); + $withCursor = $this->valueWithCursor(); - return; - } - } - } - ); + return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); + } + + /** + * The formatted lines. + * + * @return array + */ + public function lines(): array + { + $value = $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true); + + return explode(PHP_EOL, $value); } /** - * Handle the up keypress. + * Handle the up key press. */ protected function handleUpKey(): void { @@ -96,7 +120,7 @@ protected function handleUpKey(): void } /** - * Handle the down keypress. + * Handle the down key press. */ protected function handleDownKey(): void { @@ -131,19 +155,8 @@ protected function handleDownKey(): void } /** - * The currently visible options. - * - * @return array + * Adjust the visible window to ensure the cursor is always visible. */ - public function visible(): array - { - $this->adjustVisibleWindow(); - - $withCursor = $this->valueWithCursor(10_000); - - return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); - } - protected function adjustVisibleWindow(): void { if (count($this->lines()) < $this->scroll) { @@ -180,49 +193,35 @@ protected function currentLineIndex(): int }) ?: 0; } - /** - * Get the formatted lines of the current value. - * - * @return array - */ - public function lines(): array - { - $this->calculateCursorOffset(); - - $value = $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true); - - return explode(PHP_EOL, $value); - } - /** * Get the formatted value with a virtual cursor. */ - public function valueWithCursor(int $maxWidth): string + public function valueWithCursor(): string { $value = implode(PHP_EOL, $this->lines()); if ($this->value() === '') { - return $this->dim($this->addCursor($this->placeholder, 0, 10_000)); + return $this->dim($this->addCursor($this->placeholder, 0, PHP_INT_MAX)); } - return $this->addCursor($value, $this->cursorPosition + $this->cursorOffset, -1); + return $this->addCursor($value, $this->cursorPosition + $this->cursorOffset(), -1); } /** - * When there are long words that wrap, the `typedValue` and the display value are different, - * (due to the inserted new line characters from the word wrap) and the cursor calculation is off. - * This method calculates the difference and adjusts the cursor position accordingly. + * Calculate the cursor offset considering wrapped words. */ - protected function calculateCursorOffset(): void + protected function cursorOffset(): int { - $this->cursorOffset = 0; + $cursorOffset = 0; preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { - if ($this->cursorPosition + $this->cursorOffset >= $match[1] + mb_strwidth($match[0])) { - $this->cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->width); + if ($this->cursorPosition + $cursorOffset >= $match[1] + mb_strwidth($match[0])) { + $cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->width); } } + + return $cursorOffset; } } diff --git a/src/helpers.php b/src/helpers.php index a371699f..b1b9aabe 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -18,7 +18,7 @@ function text(string $label, string $placeholder = '', string $default = '', boo */ 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, $rows, $placeholder, $default, $required, $validate, $hint))->prompt(); + return (new TextareaPrompt($label, $placeholder, $default, $required, $validate, $hint, $rows))->prompt(); } /** From 3c048320d520acb2f496441dfd474618efd7fc17 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Wed, 3 Apr 2024 15:08:21 +1000 Subject: [PATCH 59/59] Allow placeholder to wrap and contain newlines --- src/Concerns/TypedValue.php | 6 ++-- src/TextareaPrompt.php | 64 ++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/Concerns/TypedValue.php b/src/Concerns/TypedValue.php index f8951862..56d356ad 100644 --- a/src/Concerns/TypedValue.php +++ b/src/Concerns/TypedValue.php @@ -88,7 +88,7 @@ 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); @@ -96,12 +96,12 @@ protected function addCursor(string $value, int $cursorPosition, int $maxWidth): $cursor = mb_strlen($current) && $current !== PHP_EOL ? $current : ' '; - $spaceBefore = $maxWidth < 0 ? mb_strwidth($before) : $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 < 0 ? mb_strwidth($after) : $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]; diff --git a/src/TextareaPrompt.php b/src/TextareaPrompt.php index 203317b4..19a94e6b 100644 --- a/src/TextareaPrompt.php +++ b/src/TextareaPrompt.php @@ -58,17 +58,23 @@ public function __construct( } /** - * The currently visible lines. - * - * @return array + * Get the formatted value with a virtual cursor. */ - public function visible(): array + public function valueWithCursor(): string { - $this->adjustVisibleWindow(); + if ($this->value() === '') { + return $this->wrappedPlaceholderWithCursor(); + } - $withCursor = $this->valueWithCursor(); + return $this->addCursor($this->wrappedValue(), $this->cursorPosition + $this->cursorOffset(), -1); + } - return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); + /** + * The word-wrapped version of the typed value. + */ + public function wrappedValue(): string + { + return $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true); } /** @@ -78,9 +84,21 @@ public function visible(): array */ public function lines(): array { - $value = $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true); + return explode(PHP_EOL, $this->wrappedValue()); + } + + /** + * The currently visible lines. + * + * @return array + */ + public function visible(): array + { + $this->adjustVisibleWindow(); - return explode(PHP_EOL, $value); + $withCursor = $this->valueWithCursor(); + + return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true); } /** @@ -193,20 +211,6 @@ protected function currentLineIndex(): int }) ?: 0; } - /** - * Get the formatted value with a virtual cursor. - */ - public function valueWithCursor(): string - { - $value = implode(PHP_EOL, $this->lines()); - - if ($this->value() === '') { - return $this->dim($this->addCursor($this->placeholder, 0, PHP_INT_MAX)); - } - - return $this->addCursor($value, $this->cursorPosition + $this->cursorOffset(), -1); - } - /** * Calculate the cursor offset considering wrapped words. */ @@ -224,4 +228,18 @@ protected function cursorOffset(): int 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, + )) + )); + } }