Skip to content

Commit

Permalink
Add Component: Multiline Text Input (#88)
Browse files Browse the repository at this point in the history
* committing this insanity before i probably destroy it

* ok we're getting closer

* width is more stable

* Fix code styling

* remove old bad stuff

* remove unused method

* Fix code styling

* i think we've reached some level of stability

he said cautiously

* fixed value with cursor method

* submit state

* cancel and error states

* fix cursor position if current is a new line

* do a final check to make sure we still have the minimum number of rows

* fixed error state

* actually fixed error display

* allow new lines as text input

* fixing formatting again

* handle scroll bottom buffer in renderer

* Update textarea.php

* Fix code styling

* Update TextareaPrompt.php

* rows param + docs

* Create TextareaPromptTest.php

* Fix code styling

* fix static analysis

* Update scrolling initialization

* Fix test

* Formatting

* Fix issue with empty last line

* fixed cancelled state so that the strikethrough doesn't affect the box

* calculate proper width with each render

* pass max width as a negative number to avoid truncation

* Fix code styling

* fix long word wrapping + cursor position

* Fix code styling

* mb_wordwrap

* getting closer to consistent

* fix scroll width

* Fix code styling

* appease phpstan

* Fix code styling

* Create MultiByteWordWrapTest.php

* remove maxLineWIdth property

* actually remove maxLineWidth property

* fixed the off by one errors when using the up/down keys

* fixed bug where pasting a bunch of content didn't put the cursor in the viewport

* Fix code styling

* changed visiblity of validate property

* Fix code styling

* validate property should be public

* validate property should be mixed

* Fix code styling

* add ability to reset cancel using + reset when using it in tests

* fix for strange down arrow behavior

* move mb_wordwrap to truncation trait

* move rows param to last position

* Fix code styling

* Formatting

* Allow placeholder to wrap and contain newlines

---------

Co-authored-by: joetannenbaum <joetannenbaum@users.noreply.github.com>
Co-authored-by: Jess Archer <jess@jessarcher.com>
Co-authored-by: jessarcher <jessarcher@users.noreply.github.com>
  • Loading branch information
4 people authored Apr 3, 2024
1 parent 0ee548f commit 3318556
Show file tree
Hide file tree
Showing 12 changed files with 870 additions and 11 deletions.
14 changes: 14 additions & 0 deletions playground/textarea.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

use function Laravel\Prompts\textarea;

require __DIR__.'/../vendor/autoload.php';

$email = textarea(
label: 'Tell me a story',
placeholder: 'Weave me a tale',
);

var_dump($email);

echo str_repeat(PHP_EOL, 5);
3 changes: 3 additions & 0 deletions src/Concerns/Themes.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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;
Expand All @@ -28,6 +29,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
Expand All @@ -45,6 +47,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,
Expand Down
86 changes: 86 additions & 0 deletions src/Concerns/Truncation.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,90 @@ protected function truncate(string $string, int $width): string

return mb_strwidth($string) <= $width ? $string : (mb_strimwidth($string, 0, $width - 1).'');
}

/**
* Multi-byte version of wordwrap.
*
* @param non-empty-string $break
*/
protected function mbWordwrap(
string $string,
int $width = 75,
string $break = "\n",
bool $cut_long_words = false
): string {
$lines = explode($break, $string);
$result = [];

foreach ($lines as $originalLine) {
if (mb_strwidth($originalLine) <= $width) {
$result[] = $originalLine;

continue;
}

$words = explode(' ', $originalLine);
$line = null;
$lineWidth = 0;

if ($cut_long_words) {
foreach ($words as $index => $word) {
$characters = mb_str_split($word);
$strings = [];
$str = '';

foreach ($characters as $character) {
$tmp = $str.$character;

if (mb_strwidth($tmp) > $width) {
$strings[] = $str;
$str = $character;
} else {
$str = $tmp;
}
}

if ($str !== '') {
$strings[] = $str;
}

$words[$index] = implode(' ', $strings);
}

$words = explode(' ', implode(' ', $words));
}

foreach ($words as $word) {
$tmp = ($line === null) ? $word : $line.' '.$word;

// Look for zero-width joiner characters (combined emojis)
preg_match('/\p{Cf}/u', $word, $joinerMatches);

$wordWidth = count($joinerMatches) > 0 ? 2 : mb_strwidth($word);

$lineWidth += $wordWidth;

if ($line !== null) {
// Space between words
$lineWidth += 1;
}

if ($lineWidth <= $width) {
$line = $tmp;
} else {
$result[] = $line;
$line = $word;
$lineWidth = $wordWidth;
}
}

if ($line !== '') {
$result[] = $line;
}

$line = null;
}

return implode($break, $result);
}
}
26 changes: 17 additions & 9 deletions src/Concerns/TypedValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ 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;

if ($this->typedValue) {
$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;
Expand All @@ -51,10 +51,17 @@ protected function trackTypedValue(string $default = '', bool $submit = true, ?c
return;
}

if ($key === Key::ENTER && $submit) {
$this->submit();
if ($key === Key::ENTER) {
if ($submit) {
$this->submit();

return;
return;
}

if ($allowNewLine) {
$this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).PHP_EOL.mb_substr($this->typedValue, $this->cursorPosition);
$this->cursorPosition++;
}
} elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) {
if ($this->cursorPosition === 0) {
return;
Expand All @@ -81,27 +88,28 @@ public function value(): string
/**
* Add a virtual cursor to the value and truncate if necessary.
*/
protected function addCursor(string $value, int $cursorPosition, int $maxWidth): string
protected function addCursor(string $value, int $cursorPosition, ?int $maxWidth = null): string
{
$before = mb_substr($value, 0, $cursorPosition);
$current = mb_substr($value, $cursorPosition, 1);
$after = mb_substr($value, $cursorPosition + 1);

$cursor = mb_strlen($current) ? $current : ' ';
$cursor = mb_strlen($current) && $current !== PHP_EOL ? $current : ' ';

$spaceBefore = $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0);
$spaceBefore = $maxWidth < 0 || $maxWidth === null ? mb_strwidth($before) : $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0);
[$truncatedBefore, $wasTruncatedBefore] = mb_strwidth($before) > $spaceBefore
? [$this->trimWidthBackwards($before, 0, $spaceBefore - 1), true]
: [$before, false];

$spaceAfter = $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor);
$spaceAfter = $maxWidth < 0 || $maxWidth === null ? mb_strwidth($after) : $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor);
[$truncatedAfter, $wasTruncatedAfter] = mb_strwidth($after) > $spaceAfter
? [mb_strimwidth($after, 0, $spaceAfter - 1), true]
: [$after, false];

return ($wasTruncatedBefore ? $this->dim('') : '')
.$truncatedBefore
.$this->inverse($cursor)
.($current === PHP_EOL ? PHP_EOL : '')
.$truncatedAfter
.($wasTruncatedAfter ? $this->dim('') : '');
}
Expand Down
5 changes: 5 additions & 0 deletions src/Key.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ class Key
*/
const CTRL_A = "\x01";

/**
* EOF
*/
const CTRL_D = "\x04";

/**
* End
*/
Expand Down
4 changes: 2 additions & 2 deletions src/Prompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
Loading

0 comments on commit 3318556

Please sign in to comment.