This commit is contained in:
2025-05-12 14:25:25 +02:00
parent ab2db755ef
commit 9e378ca2b7
2719 changed files with 46505 additions and 60181 deletions

View File

@@ -1,6 +1,7 @@
{
"name": "laravel/prompts",
"type": "library",
"description": "Add beautiful and user-friendly forms to your command-line applications.",
"license": "MIT",
"autoload": {
"psr-4": {

View File

@@ -60,4 +60,20 @@ trait Cursor
static::writeDirectly($sequence);
}
/**
* Move the cursor to the given column.
*/
public function moveCursorToColumn(int $column): void
{
static::writeDirectly("\e[{$column}G");
}
/**
* Move the cursor up by the given number of lines.
*/
public function moveCursorUp(int $lines): void
{
static::writeDirectly("\e[{$lines}A");
}
}

View File

@@ -27,6 +27,7 @@ trait FakesInputOutput
$mock->shouldReceive('restoreTty')->byDefault();
$mock->shouldReceive('cols')->byDefault()->andReturn(80);
$mock->shouldReceive('lines')->byDefault()->andReturn(24);
$mock->shouldReceive('initDimensions')->byDefault();
foreach ($keys as $key) {
$mock->shouldReceive('read')->once()->andReturn($key);
@@ -34,7 +35,7 @@ trait FakesInputOutput
static::$terminal = $mock;
self::setOutput(new BufferedConsoleOutput());
self::setOutput(new BufferedConsoleOutput);
}
/**

View File

@@ -38,7 +38,7 @@ trait Scrolling
{
$reservedLines = ($renderer = $this->getRenderer()) instanceof ScrollingRenderer ? $renderer->reservedLines() : 0;
$this->scroll = min($this->scroll, $this->terminal()->lines() - $reservedLines);
$this->scroll = max(1, min($this->scroll, $this->terminal()->lines() - $reservedLines));
}
/**

View File

@@ -11,7 +11,7 @@ trait Termwind
{
protected function termwind(string $html)
{
renderUsing($output = new BufferedConsoleOutput());
renderUsing($output = new BufferedConsoleOutput);
render($html);

View File

@@ -8,24 +8,28 @@ use Laravel\Prompts\MultiSearchPrompt;
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Note;
use Laravel\Prompts\PasswordPrompt;
use Laravel\Prompts\PausePrompt;
use Laravel\Prompts\Progress;
use Laravel\Prompts\SearchPrompt;
use Laravel\Prompts\SelectPrompt;
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;
use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer;
use Laravel\Prompts\Themes\Default\NoteRenderer;
use Laravel\Prompts\Themes\Default\PasswordPromptRenderer;
use Laravel\Prompts\Themes\Default\PausePromptRenderer;
use Laravel\Prompts\Themes\Default\ProgressRenderer;
use Laravel\Prompts\Themes\Default\SearchPromptRenderer;
use Laravel\Prompts\Themes\Default\SelectPromptRenderer;
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
@@ -43,10 +47,12 @@ 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,
ConfirmPrompt::class => ConfirmPromptRenderer::class,
PausePrompt::class => PausePromptRenderer::class,
SearchPrompt::class => SearchPromptRenderer::class,
MultiSearchPrompt::class => MultiSearchPromptRenderer::class,
SuggestPrompt::class => SuggestPromptRenderer::class,

View File

@@ -17,4 +17,90 @@ trait Truncation
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);
}
}

View File

@@ -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 @@ trait 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;
@@ -51,10 +51,17 @@ trait TypedValue
return;
}
if ($key === Key::ENTER && $submit) {
$this->submit();
if ($key === Key::ENTER) {
if ($submit) {
$this->submit();
return;
return;
}
if ($allowNewLine) {
$this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).PHP_EOL.mb_substr($this->typedValue, $this->cursorPosition);
$this->cursorPosition++;
}
} elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) {
if ($this->cursorPosition === 0) {
return;
@@ -81,20 +88,20 @@ trait TypedValue
/**
* Add a virtual cursor to the value and truncate if necessary.
*/
protected function addCursor(string $value, int $cursorPosition, int $maxWidth): string
protected function addCursor(string $value, int $cursorPosition, ?int $maxWidth = null): string
{
$before = mb_substr($value, 0, $cursorPosition);
$current = mb_substr($value, $cursorPosition, 1);
$after = mb_substr($value, $cursorPosition + 1);
$cursor = mb_strlen($current) ? $current : ' ';
$cursor = mb_strlen($current) && $current !== PHP_EOL ? $current : ' ';
$spaceBefore = $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0);
$spaceBefore = $maxWidth < 0 || $maxWidth === null ? mb_strwidth($before) : $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0);
[$truncatedBefore, $wasTruncatedBefore] = mb_strwidth($before) > $spaceBefore
? [$this->trimWidthBackwards($before, 0, $spaceBefore - 1), true]
: [$before, false];
$spaceAfter = $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor);
$spaceAfter = $maxWidth < 0 || $maxWidth === null ? mb_strwidth($after) : $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor);
[$truncatedAfter, $wasTruncatedAfter] = mb_strwidth($after) > $spaceAfter
? [mb_strimwidth($after, 0, $spaceAfter - 1), true]
: [$after, false];
@@ -102,6 +109,7 @@ trait TypedValue
return ($wasTruncatedBefore ? $this->dim('…') : '')
.$truncatedBefore
.$this->inverse($cursor)
.($current === PHP_EOL ? PHP_EOL : '')
.$truncatedAfter
.($wasTruncatedAfter ? $this->dim('…') : '');
}

View File

@@ -2,6 +2,8 @@
namespace Laravel\Prompts;
use Closure;
class ConfirmPrompt extends Prompt
{
/**
@@ -20,6 +22,7 @@ class ConfirmPrompt extends Prompt
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->confirmed = $default;

View File

@@ -0,0 +1,10 @@
<?php
namespace Laravel\Prompts\Exceptions;
use RuntimeException;
class FormRevertedException extends RuntimeException
{
//
}

View File

@@ -0,0 +1,279 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
use Laravel\Prompts\Exceptions\FormRevertedException;
class FormBuilder
{
/**
* Each step that should be executed.
*
* @var array<int, \Laravel\Prompts\FormStep>
*/
protected array $steps = [];
/**
* The responses provided by each step.
*
* @var array<mixed>
*/
protected array $responses = [];
/**
* Add a new step.
*/
public function add(Closure $step, ?string $name = null, bool $ignoreWhenReverting = false): self
{
$this->steps[] = new FormStep($step, true, $name, $ignoreWhenReverting);
return $this;
}
/**
* Run all of the given steps.
*
* @return array<mixed>
*/
public function submit(): array
{
$index = 0;
$wasReverted = false;
while ($index < count($this->steps)) {
$step = $this->steps[$index];
if ($wasReverted && $index > 0 && $step->shouldIgnoreWhenReverting($this->responses)) {
$index--;
continue;
}
$wasReverted = false;
$index > 0
? Prompt::revertUsing(function () use (&$wasReverted) {
$wasReverted = true;
}) : Prompt::preventReverting();
try {
$this->responses[$step->name ?? $index] = $step->run(
$this->responses,
$this->responses[$step->name ?? $index] ?? null,
);
} catch (FormRevertedException) {
$wasReverted = true;
}
$wasReverted ? $index-- : $index++;
}
Prompt::preventReverting();
return $this->responses;
}
/**
* Prompt the user for text input.
*/
public function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(text(...), get_defined_vars());
}
/**
* Prompt the user for multiline text input.
*/
public function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = '', int $rows = 5, ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(textarea(...), get_defined_vars());
}
/**
* Prompt the user for input, hiding the value.
*/
public function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(password(...), get_defined_vars());
}
/**
* Prompt the user to select an option.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param true|string $required
*/
public function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(select(...), get_defined_vars());
}
/**
* Prompt the user to select multiple options.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string>|Collection<int, int|string> $default
*/
public function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(multiselect(...), get_defined_vars());
}
/**
* Prompt the user to confirm an action.
*/
public function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(confirm(...), get_defined_vars());
}
/**
* Prompt the user to continue or cancel after pausing.
*/
public function pause(string $message = 'Press enter to continue...', ?string $name = null): self
{
return $this->runPrompt(pause(...), get_defined_vars());
}
/**
* Prompt the user for text input with auto-completion.
*
* @param array<string>|Collection<int, string>|Closure(string): array<string> $options
*/
public function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = '', ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(suggest(...), get_defined_vars());
}
/**
* Allow the user to search for an option.
*
* @param Closure(string): array<int|string, string> $options
* @param true|string $required
*/
public function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(search(...), get_defined_vars());
}
/**
* Allow the user to search for multiple option.
*
* @param Closure(string): array<int|string, string> $options
*/
public function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?string $name = null, ?Closure $transform = null): self
{
return $this->runPrompt(multisearch(...), get_defined_vars());
}
/**
* Render a spinner while the given callback is executing.
*
* @param \Closure(): mixed $callback
*/
public function spin(Closure $callback, string $message = '', ?string $name = null): self
{
return $this->runPrompt(spin(...), get_defined_vars(), true);
}
/**
* Display a note.
*/
public function note(string $message, ?string $type = null, ?string $name = null): self
{
return $this->runPrompt(note(...), get_defined_vars(), true);
}
/**
* Display an error.
*/
public function error(string $message, ?string $name = null): self
{
return $this->runPrompt(error(...), get_defined_vars(), true);
}
/**
* Display a warning.
*/
public function warning(string $message, ?string $name = null): self
{
return $this->runPrompt(warning(...), get_defined_vars(), true);
}
/**
* Display an alert.
*/
public function alert(string $message, ?string $name = null): self
{
return $this->runPrompt(alert(...), get_defined_vars(), true);
}
/**
* Display an informational message.
*/
public function info(string $message, ?string $name = null): self
{
return $this->runPrompt(info(...), get_defined_vars(), true);
}
/**
* Display an introduction.
*/
public function intro(string $message, ?string $name = null): self
{
return $this->runPrompt(intro(...), get_defined_vars(), true);
}
/**
* Display a closing message.
*/
public function outro(string $message, ?string $name = null): self
{
return $this->runPrompt(outro(...), get_defined_vars(), true);
}
/**
* Display a table.
*
* @param array<int, string|array<int, string>>|Collection<int, string|array<int, string>> $headers
* @param array<int, array<int, string>>|Collection<int, array<int, string>> $rows
*/
public function table(array|Collection $headers = [], array|Collection|null $rows = null, ?string $name = null): self
{
return $this->runPrompt(table(...), get_defined_vars(), true);
}
/**
* Display a progress bar.
*
* @template TSteps of iterable<mixed>|int
* @template TReturn
*
* @param TSteps $steps
* @param ?Closure((TSteps is int ? int : value-of<TSteps>), Progress<TSteps>): TReturn $callback
*/
public function progress(string $label, iterable|int $steps, ?Closure $callback = null, string $hint = '', ?string $name = null): self
{
return $this->runPrompt(progress(...), get_defined_vars(), true);
}
/**
* Execute the given prompt passing the given arguments.
*
* @param array<mixed> $arguments
*/
protected function runPrompt(callable $prompt, array $arguments, bool $ignoreWhenReverting = false): self
{
return $this->add(function (array $responses, mixed $previousResponse) use ($prompt, $arguments) {
unset($arguments['name']);
if (array_key_exists('default', $arguments) && $previousResponse !== null) {
$arguments['default'] = $previousResponse;
}
return $prompt(...$arguments);
}, name: $arguments['name'], ignoreWhenReverting: $ignoreWhenReverting);
}
}

59
vendor/laravel/prompts/src/FormStep.php vendored Normal file
View File

@@ -0,0 +1,59 @@
<?php
namespace Laravel\Prompts;
use Closure;
class FormStep
{
protected readonly Closure $condition;
public function __construct(
protected readonly Closure $step,
bool|Closure $condition,
public readonly ?string $name,
protected readonly bool $ignoreWhenReverting,
) {
$this->condition = is_bool($condition)
? fn () => $condition
: $condition;
}
/**
* Execute this step.
*
* @param array<mixed> $responses
*/
public function run(array $responses, mixed $previousResponse): mixed
{
if (! $this->shouldRun($responses)) {
return null;
}
return ($this->step)($responses, $previousResponse);
}
/**
* Whether the step should run based on the given condition.
*
* @param array<mixed> $responses
*/
protected function shouldRun(array $responses): bool
{
return ($this->condition)($responses);
}
/**
* Whether this step should be skipped over when a subsequent step is reverted.
*
* @param array<mixed> $responses
*/
public function shouldIgnoreWhenReverting(array $responses): bool
{
if (! $this->shouldRun($responses)) {
return true;
}
return $this->ignoreWhenReverting;
}
}

View File

@@ -6,8 +6,12 @@ class Key
{
const UP = "\e[A";
const SHIFT_UP = "\e[1;2A";
const DOWN = "\e[B";
const SHIFT_DOWN = "\e[1;2B";
const RIGHT = "\e[C";
const LEFT = "\e[D";
@@ -20,6 +24,8 @@ class Key
const LEFT_ARROW = "\eOD";
const ESCAPE = "\e";
const DELETE = "\e[3~";
const BACKSPACE = "\177";
@@ -71,11 +77,21 @@ class Key
*/
const CTRL_A = "\x01";
/**
* EOF
*/
const CTRL_D = "\x04";
/**
* End
*/
const CTRL_E = "\x05";
/**
* Negative affirmation
*/
const CTRL_U = "\x15";
/**
* Checks for the constant values for the given match and returns the match
*

View File

@@ -17,6 +17,11 @@ class MultiSearchPrompt extends Prompt
*/
protected ?array $matches = null;
/**
* Whether the matches are initially a list.
*/
protected bool $isList;
/**
* The selected values.
*
@@ -37,6 +42,7 @@ class MultiSearchPrompt extends Prompt
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->trackTypedValue(submit: false, ignore: fn ($key) => Key::oneOf([Key::SPACE, Key::HOME, Key::END, Key::CTRL_A, Key::CTRL_E], $key) && $this->highlighted !== null);
@@ -45,9 +51,11 @@ class MultiSearchPrompt extends Prompt
$this->on('key', fn ($key) => match ($key) {
Key::UP, Key::UP_ARROW, Key::SHIFT_TAB => $this->highlightPrevious(count($this->matches), true),
Key::DOWN, Key::DOWN_ARROW, Key::TAB => $this->highlightNext(count($this->matches), true),
Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlighted !== null ? $this->highlight(0) : null,
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlighted !== null ? $this->highlight(count($this->matches()) - 1) : null,
Key::oneOf(Key::HOME, $key) => $this->highlighted !== null ? $this->highlight(0) : null,
Key::oneOf(Key::END, $key) => $this->highlighted !== null ? $this->highlight(count($this->matches()) - 1) : null,
Key::SPACE => $this->highlighted !== null ? $this->toggleHighlighted() : null,
Key::CTRL_A => $this->highlighted !== null ? $this->toggleAll() : null,
Key::CTRL_E => null,
Key::ENTER => $this->submit(),
Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW => $this->highlighted = null,
default => $this->search(),
@@ -96,16 +104,25 @@ class MultiSearchPrompt extends Prompt
return $this->matches;
}
if (strlen($this->typedValue) === 0) {
$matches = ($this->options)($this->typedValue);
$matches = ($this->options)($this->typedValue);
return $this->matches = [
...array_diff($this->values, $matches),
...$matches,
];
if (! isset($this->isList) && count($matches) > 0) {
// This needs to be captured the first time we receive matches so
// we know what we're dealing with later if matches is empty.
$this->isList = array_is_list($matches);
}
return $this->matches = ($this->options)($this->typedValue);
if (! isset($this->isList)) {
return $this->matches = [];
}
if (strlen($this->typedValue) > 0) {
return $this->matches = $matches;
}
return $this->matches = $this->isList
? [...array_diff(array_values($this->values), $matches), ...$matches]
: array_diff($this->values, $matches) + $matches;
}
/**
@@ -118,12 +135,33 @@ class MultiSearchPrompt extends Prompt
return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Toggle all options.
*/
protected function toggleAll(): void
{
$allMatchesSelected = collect($this->matches)->every(fn ($label, $key) => $this->isList()
? array_key_exists($label, $this->values)
: array_key_exists($key, $this->values));
if ($allMatchesSelected) {
$this->values = array_filter($this->values, fn ($value) => $this->isList()
? ! in_array($value, $this->matches)
: ! array_key_exists(array_search($value, $this->matches), $this->matches)
);
} else {
$this->values = $this->isList()
? array_merge($this->values, array_combine(array_values($this->matches), array_values($this->matches)))
: array_merge($this->values, array_combine(array_keys($this->matches), array_values($this->matches)));
}
}
/**
* Toggle the highlighted entry.
*/
protected function toggleHighlighted(): void
{
if (array_is_list($this->matches)) {
if ($this->isList()) {
$label = $this->matches[$this->highlighted];
$key = $label;
} else {
@@ -165,4 +203,12 @@ class MultiSearchPrompt extends Prompt
{
return array_values($this->values);
}
/**
* Whether the matches are initially a list.
*/
public function isList(): bool
{
return $this->isList;
}
}

View File

@@ -2,6 +2,7 @@
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
class MultiSelectPrompt extends Prompt
@@ -43,6 +44,7 @@ class MultiSelectPrompt extends Prompt
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->options = $options instanceof Collection ? $options->all() : $options;
$this->default = $default instanceof Collection ? $default->all() : $default;
@@ -53,9 +55,10 @@ class MultiSelectPrompt extends Prompt
$this->on('key', fn ($key) => match ($key) {
Key::UP, Key::UP_ARROW, Key::LEFT, Key::LEFT_ARROW, Key::SHIFT_TAB, Key::CTRL_P, Key::CTRL_B, 'k', 'h' => $this->highlightPrevious(count($this->options)),
Key::DOWN, Key::DOWN_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::TAB, Key::CTRL_N, Key::CTRL_F, 'j', 'l' => $this->highlightNext(count($this->options)),
Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlight(0),
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlight(count($this->options) - 1),
Key::oneOf(Key::HOME, $key) => $this->highlight(0),
Key::oneOf(Key::END, $key) => $this->highlight(count($this->options) - 1),
Key::SPACE => $this->toggleHighlighted(),
Key::CTRL_A => $this->toggleAll(),
Key::ENTER => $this->submit(),
default => null,
});
@@ -115,6 +118,20 @@ class MultiSelectPrompt extends Prompt
return in_array($value, $this->values);
}
/**
* Toggle all options.
*/
protected function toggleAll(): void
{
if (count($this->values) === count($this->options)) {
$this->values = [];
} else {
$this->values = array_is_list($this->options)
? array_values($this->options)
: array_keys($this->options);
}
}
/**
* Toggle the highlighted entry.
*/

View File

@@ -2,6 +2,8 @@
namespace Laravel\Prompts;
use Closure;
class PasswordPrompt extends Prompt
{
use Concerns\TypedValue;
@@ -15,6 +17,7 @@ class PasswordPrompt extends Prompt
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->trackTypedValue();
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Laravel\Prompts;
class PausePrompt extends Prompt
{
/**
* Create a new PausePrompt instance.
*/
public function __construct(public string $message = 'Press enter to continue...')
{
$this->required = false;
$this->validate = null;
$this->on('key', fn ($key) => match ($key) {
Key::ENTER => $this->submit(),
default => null,
});
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return static::$interactive;
}
}

View File

@@ -138,6 +138,14 @@ class Progress extends Prompt
$this->resetSignals();
}
/**
* Force the progress bar to re-render.
*/
public function render(): void
{
parent::render();
}
/**
* Update the label.
*/

View File

@@ -3,6 +3,7 @@
namespace Laravel\Prompts;
use Closure;
use Laravel\Prompts\Exceptions\FormRevertedException;
use Laravel\Prompts\Output\ConsoleOutput;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
@@ -29,6 +30,11 @@ abstract class Prompt
*/
public string $error = '';
/**
* The cancel message displayed when this prompt is cancelled.
*/
public string $cancelMessage = 'Cancelled.';
/**
* The previously rendered frame.
*/
@@ -44,6 +50,11 @@ abstract class Prompt
*/
public bool|string $required;
/**
* The transformation callback.
*/
public ?Closure $transform = null;
/**
* The validator callback or rules.
*/
@@ -52,7 +63,7 @@ abstract class Prompt
/**
* The cancellation callback.
*/
protected static Closure $cancelUsing;
protected static ?Closure $cancelUsing;
/**
* Indicates if the prompt has been validated.
@@ -64,6 +75,11 @@ abstract class Prompt
*/
protected static ?Closure $validateUsing;
/**
* The revert handler from the StepBuilder.
*/
protected static ?Closure $revertUsing = null;
/**
* The output instance.
*/
@@ -125,7 +141,11 @@ abstract class Prompt
}
}
return $this->value();
if ($key === Key::CTRL_U && self::$revertUsing) {
throw new FormRevertedException;
}
return $this->transformedValue();
}
}
} finally {
@@ -136,7 +156,7 @@ abstract class Prompt
/**
* 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;
}
@@ -172,7 +192,7 @@ abstract class Prompt
*/
protected static function output(): OutputInterface
{
return self::$output ??= new ConsoleOutput();
return self::$output ??= new ConsoleOutput;
}
/**
@@ -192,7 +212,7 @@ abstract class Prompt
*/
public static function terminal(): Terminal
{
return static::$terminal ??= new Terminal();
return static::$terminal ??= new Terminal;
}
/**
@@ -203,11 +223,33 @@ abstract class Prompt
static::$validateUsing = $callback;
}
/**
* Revert the prompt using the given callback.
*
* @internal
*/
public static function revertUsing(Closure $callback): void
{
static::$revertUsing = $callback;
}
/**
* Clear any previous revert callback.
*
* @internal
*/
public static function preventReverting(): void
{
static::$revertUsing = null;
}
/**
* Render the prompt.
*/
protected function render(): void
{
$this->terminal()->initDimensions();
$frame = $this->renderTheme();
if ($frame === $this->prevFrame) {
@@ -223,35 +265,14 @@ abstract class Prompt
return;
}
$this->resetCursorPosition();
$terminalHeight = $this->terminal()->lines();
$previousFrameHeight = count(explode(PHP_EOL, $this->prevFrame));
$renderableLines = array_slice(explode(PHP_EOL, $frame), abs(min(0, $terminalHeight - $previousFrameHeight)));
// Ensure that the full frame is buffered so subsequent output can see how many trailing newlines were written.
if ($this->state === 'submit') {
$this->eraseDown();
static::output()->write($frame);
$this->prevFrame = '';
return;
}
$diff = $this->diffLines($this->prevFrame, $frame);
if (count($diff) === 1) { // Update the single line that changed.
$diffLine = $diff[0];
$this->moveCursor(0, $diffLine);
$this->eraseLines(1);
$lines = explode(PHP_EOL, $frame);
static::output()->write($lines[$diffLine]);
$this->moveCursor(0, count($lines) - $diffLine - 1);
} elseif (count($diff) > 1) { // Re-render everything past the first change
$diffLine = $diff[0];
$this->moveCursor(0, $diffLine);
$this->eraseDown();
$lines = explode(PHP_EOL, $frame);
$newLines = array_slice($lines, $diffLine);
static::output()->write(implode(PHP_EOL, $newLines));
}
$this->moveCursorToColumn(1);
$this->moveCursorUp(min($terminalHeight, $previousFrameHeight) - 1);
$this->eraseDown();
$this->output()->write(implode(PHP_EOL, $renderableLines));
$this->prevFrame = $frame;
}
@@ -261,47 +282,13 @@ abstract class Prompt
*/
protected function submit(): void
{
$this->validate($this->value());
$this->validate($this->transformedValue());
if ($this->state !== 'error') {
$this->state = 'submit';
}
}
/**
* Reset the cursor position to the beginning of the previous frame.
*/
private function resetCursorPosition(): void
{
$lines = count(explode(PHP_EOL, $this->prevFrame)) - 1;
$this->moveCursor(-999, $lines * -1);
}
/**
* Get the difference between two strings.
*
* @return array<int>
*/
private function diffLines(string $a, string $b): array
{
if ($a === $b) {
return [];
}
$aLines = explode(PHP_EOL, $a);
$bLines = explode(PHP_EOL, $b);
$diff = [];
for ($i = 0; $i < max(count($aLines), count($bLines)); $i++) {
if (! isset($aLines[$i]) || ! isset($bLines[$i]) || $aLines[$i] !== $bLines[$i]) {
$diff[] = $i;
}
}
return $diff;
}
/**
* Handle a key press and determine whether to continue.
*/
@@ -317,6 +304,22 @@ abstract class Prompt
return false;
}
if ($key === Key::CTRL_U) {
if (! self::$revertUsing) {
$this->state = 'error';
$this->error = 'This cannot be reverted.';
return true;
}
$this->state = 'cancel';
$this->cancelMessage = 'Reverted.';
call_user_func(self::$revertUsing);
return false;
}
if ($key === Key::CTRL_C) {
$this->state = 'cancel';
@@ -324,12 +327,32 @@ abstract class Prompt
}
if ($this->validated) {
$this->validate($this->value());
$this->validate($this->transformedValue());
}
return true;
}
/**
* Transform the input.
*/
private function transform(mixed $value): mixed
{
if (is_null($this->transform)) {
return $value;
}
return call_user_func($this->transform, $value);
}
/**
* Get the transformed value of the prompt.
*/
protected function transformedValue(): mixed
{
return $this->transform($this->value());
}
/**
* Validate the input.
*/

View File

@@ -31,6 +31,7 @@ class SearchPrompt extends Prompt
public mixed $validate = null,
public string $hint = '',
public bool|string $required = true,
public ?Closure $transform = null,
) {
if ($this->required === false) {
throw new InvalidArgumentException('Argument [required] must be true or a string.');

View File

@@ -2,6 +2,7 @@
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
use InvalidArgumentException;
@@ -29,6 +30,7 @@ class SelectPrompt extends Prompt
public mixed $validate = null,
public string $hint = '',
public bool|string $required = true,
public ?Closure $transform = null,
) {
if ($this->required === false) {
throw new InvalidArgumentException('Argument [required] must be true or a string.');

View File

@@ -14,7 +14,7 @@ class SuggestPrompt extends Prompt
/**
* The options for the suggest prompt.
*
* @var array<string>|Closure(string): array<string>
* @var array<string>|Closure(string): (array<string>|Collection<int, string>)
*/
public array|Closure $options;
@@ -28,7 +28,7 @@ class SuggestPrompt extends Prompt
/**
* Create a new SuggestPrompt instance.
*
* @param array<string>|Collection<int, string>|Closure(string): array<string> $options
* @param array<string>|Collection<int, string>|Closure(string): (array<string>|Collection<int, string>) $options
*/
public function __construct(
public string $label,
@@ -39,6 +39,7 @@ class SuggestPrompt extends Prompt
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->options = $options instanceof Collection ? $options->all() : $options;
@@ -91,7 +92,9 @@ class SuggestPrompt extends Prompt
}
if ($this->options instanceof Closure) {
return $this->matches = array_values(($this->options)($this->value()));
$matches = ($this->options)($this->value());
return $this->matches = array_values($matches instanceof Collection ? $matches->all() : $matches);
}
return $this->matches = array_values(array_filter($this->options, function ($option) {

View File

@@ -2,6 +2,7 @@
namespace Laravel\Prompts;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\Console\Terminal as SymfonyTerminal;
@@ -13,14 +14,17 @@ class Terminal
protected ?string $initialTtyMode;
/**
* The number of columns in the terminal.
* The Symfony Terminal instance.
*/
protected int $cols;
protected SymfonyTerminal $terminal;
/**
* The number of lines in the terminal.
* Create a new Terminal instance.
*/
protected int $lines;
public function __construct()
{
$this->terminal = new SymfonyTerminal;
}
/**
* Read a line from the terminal.
@@ -59,7 +63,7 @@ class Terminal
*/
public function cols(): int
{
return $this->cols ??= (new SymfonyTerminal())->getWidth();
return $this->terminal->getWidth();
}
/**
@@ -67,7 +71,17 @@ class Terminal
*/
public function lines(): int
{
return $this->lines ??= (new SymfonyTerminal())->getHeight();
return $this->terminal->getHeight();
}
/**
* (Re)initialize the terminal dimensions.
*/
public function initDimensions(): void
{
(new ReflectionClass($this->terminal))
->getMethod('initDimensions')
->invoke($this->terminal);
}
/**

View File

@@ -2,6 +2,8 @@
namespace Laravel\Prompts;
use Closure;
class TextPrompt extends Prompt
{
use Concerns\TypedValue;
@@ -16,6 +18,7 @@ class TextPrompt extends Prompt
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->trackTypedValue($default);
}

View File

@@ -0,0 +1,248 @@
<?php
namespace Laravel\Prompts;
use Closure;
class TextareaPrompt extends Prompt
{
use Concerns\Scrolling;
use Concerns\Truncation;
use Concerns\TypedValue;
/**
* The width of the textarea.
*/
public int $width = 60;
/**
* Create a new TextareaPrompt instance.
*/
public function __construct(
public string $label,
public string $placeholder = '',
public string $default = '',
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
int $rows = 5,
public ?Closure $transform = null,
) {
$this->scroll = $rows;
$this->initializeScrolling();
$this->trackTypedValue(
default: $default,
submit: false,
allowNewLine: true,
);
$this->on('key', function ($key) {
if ($key[0] === "\e") {
match ($key) {
Key::UP, Key::UP_ARROW, Key::CTRL_P => $this->handleUpKey(),
Key::DOWN, Key::DOWN_ARROW, Key::CTRL_N => $this->handleDownKey(),
default => null,
};
return;
}
// Keys may be buffered.
foreach (mb_str_split($key) as $key) {
if ($key === Key::CTRL_D) {
$this->submit();
return;
}
}
});
}
/**
* Get the formatted value with a virtual cursor.
*/
public function valueWithCursor(): string
{
if ($this->value() === '') {
return $this->wrappedPlaceholderWithCursor();
}
return $this->addCursor($this->wrappedValue(), $this->cursorPosition + $this->cursorOffset(), -1);
}
/**
* The word-wrapped version of the typed value.
*/
public function wrappedValue(): string
{
return $this->mbWordwrap($this->value(), $this->width, PHP_EOL, true);
}
/**
* The formatted lines.
*
* @return array<int, string>
*/
public function lines(): array
{
return explode(PHP_EOL, $this->wrappedValue());
}
/**
* The currently visible lines.
*
* @return array<int, string>
*/
public function visible(): array
{
$this->adjustVisibleWindow();
$withCursor = $this->valueWithCursor();
return array_slice(explode(PHP_EOL, $withCursor), $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Handle the up key press.
*/
protected function handleUpKey(): void
{
if ($this->cursorPosition === 0) {
return;
}
$lines = collect($this->lines());
// Line length + 1 for the newline character
$lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1));
$currentLineIndex = $this->currentLineIndex();
if ($currentLineIndex === 0) {
// They're already at the first line, jump them to the first position
$this->cursorPosition = 0;
return;
}
$currentLines = $lineLengths->slice(0, $currentLineIndex + 1);
$currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition);
$destinationLineLength = ($lineLengths->get($currentLineIndex - 1) ?? $currentLines->first()) - 1;
$newColumn = min($destinationLineLength, $currentColumn);
$fullLines = $currentLines->slice(0, -2);
$this->cursorPosition = $fullLines->sum() + $newColumn;
}
/**
* Handle the down key press.
*/
protected function handleDownKey(): void
{
$lines = collect($this->lines());
// Line length + 1 for the newline character
$lineLengths = $lines->map(fn ($line, $index) => mb_strlen($line) + ($index === $lines->count() - 1 ? 0 : 1));
$currentLineIndex = $this->currentLineIndex();
if ($currentLineIndex === $lines->count() - 1) {
// They're already at the last line, jump them to the last position
$this->cursorPosition = mb_strlen($lines->implode(PHP_EOL));
return;
}
// Lines up to and including the current line
$currentLines = $lineLengths->slice(0, $currentLineIndex + 1);
$currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition);
$destinationLineLength = $lineLengths->get($currentLineIndex + 1) ?? $currentLines->last();
if ($currentLineIndex + 1 !== $lines->count() - 1) {
$destinationLineLength--;
}
$newColumn = min(max(0, $destinationLineLength), $currentColumn);
$this->cursorPosition = $currentLines->sum() + $newColumn;
}
/**
* Adjust the visible window to ensure the cursor is always visible.
*/
protected function adjustVisibleWindow(): void
{
if (count($this->lines()) < $this->scroll) {
return;
}
$currentLineIndex = $this->currentLineIndex();
while ($this->firstVisible + $this->scroll <= $currentLineIndex) {
$this->firstVisible++;
}
if ($currentLineIndex === $this->firstVisible - 1) {
$this->firstVisible = max(0, $this->firstVisible - 1);
}
// Make sure there are always the scroll amount visible
if ($this->firstVisible + $this->scroll > count($this->lines())) {
$this->firstVisible = count($this->lines()) - $this->scroll;
}
}
/**
* Get the index of the current line that the cursor is on.
*/
protected function currentLineIndex(): int
{
$totalLineLength = 0;
return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) {
$totalLineLength += mb_strlen($line) + 1;
return $totalLineLength > $this->cursorPosition;
}) ?: 0;
}
/**
* Calculate the cursor offset considering wrapped words.
*/
protected function cursorOffset(): int
{
$cursorOffset = 0;
preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $match) {
if ($this->cursorPosition + $cursorOffset >= $match[1] + mb_strwidth($match[0])) {
$cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->width);
}
}
return $cursorOffset;
}
/**
* A wrapped version of the placeholder with the virtual cursor.
*/
protected function wrappedPlaceholderWithCursor(): string
{
return implode(PHP_EOL, array_map(
$this->dim(...),
explode(PHP_EOL, $this->addCursor(
$this->mbWordwrap($this->placeholder, $this->width, PHP_EOL, true),
cursorPosition: 0,
))
));
}
}

View File

@@ -6,6 +6,8 @@ use Laravel\Prompts\Prompt;
trait DrawsBoxes
{
use InteractsWithStrings;
protected int $minWidth = 60;
/**
@@ -55,39 +57,4 @@ trait DrawsBoxes
return $this;
}
/**
* Get the length of the longest line.
*
* @param array<string> $lines
*/
protected function longest(array $lines, int $padding = 0): int
{
return max(
$this->minWidth,
collect($lines)
->map(fn ($line) => mb_strwidth($this->stripEscapeSequences($line)) + $padding)
->max()
);
}
/**
* Pad text ignoring ANSI escape sequences.
*/
protected function pad(string $text, int $length): string
{
$rightPadding = str_repeat(' ', max(0, $length - mb_strwidth($this->stripEscapeSequences($text))));
return "{$text}{$rightPadding}";
}
/**
* Strip ANSI escape sequences from the given text.
*/
protected function stripEscapeSequences(string $text): string
{
$text = preg_replace("/\e[^m]*m/", '', $text);
return preg_replace("/<(?:(?:[fb]g|options)=[a-z,;]+)+>(.*?)<\/>/i", '$1', $text);
}
}

View File

@@ -20,7 +20,7 @@ trait DrawsScrollbars
$scrollPosition = $this->scrollPosition($firstVisible, $height, $total);
return $visible
return $visible // @phpstan-ignore return.type
->values()
->map(fn ($line) => $this->pad($line, $width))
->map(fn ($line, $index) => match ($index) {

View File

@@ -0,0 +1,46 @@
<?php
namespace Laravel\Prompts\Themes\Default\Concerns;
trait InteractsWithStrings
{
/**
* Get the length of the longest line.
*
* @param array<string> $lines
*/
protected function longest(array $lines, int $padding = 0): int
{
return max(
$this->minWidth,
collect($lines)
->map(fn ($line) => mb_strwidth($this->stripEscapeSequences($line)) + $padding)
->max()
);
}
/**
* Pad text ignoring ANSI escape sequences.
*/
protected function pad(string $text, int $length, string $char = ' '): string
{
$rightPadding = str_repeat($char, max(0, $length - mb_strwidth($this->stripEscapeSequences($text))));
return "{$text}{$rightPadding}";
}
/**
* Strip ANSI escape sequences from the given text.
*/
protected function stripEscapeSequences(string $text): string
{
// Strip ANSI escape sequences.
$text = preg_replace("/\e[^m]*m/", '', $text);
// Strip Symfony named style tags.
$text = preg_replace("/<(info|comment|question|error)>(.*?)<\/\\1>/", '$2', $text);
// Strip Symfony inline style tags.
return preg_replace("/<(?:(?:[fb]g|options)=[a-z,;]+)+>(.*?)<\/>/i", '$1', $text);
}
}

View File

@@ -26,7 +26,7 @@ class ConfirmPromptRenderer extends Renderer
$this->renderOptions($prompt),
color: 'red'
)
->error('Cancelled.'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -30,7 +30,7 @@ class MultiSearchPromptRenderer extends Renderer implements Scrolling
$this->strikethrough($this->dim($this->truncate($prompt->searchValue() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error('Cancelled'),
->error($prompt->cancelMessage),
'error' => $this
->box(
@@ -111,11 +111,11 @@ class MultiSearchPromptRenderer extends Renderer implements Scrolling
return $this->scrollbar(
collect($prompt->visible())
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10))
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 12))
->map(function ($label, $key) use ($prompt) {
$index = array_search($key, array_keys($prompt->matches()));
$active = $index === $prompt->highlighted;
$selected = array_is_list($prompt->visible())
$selected = $prompt->isList()
? in_array($label, $prompt->value())
: in_array($key, $prompt->value());
@@ -156,7 +156,7 @@ class MultiSearchPromptRenderer extends Renderer implements Scrolling
$info = count($prompt->value()).' selected';
$hiddenCount = count($prompt->value()) - collect($prompt->matches())
->filter(fn ($label, $key) => in_array(array_is_list($prompt->matches()) ? $label : $key, $prompt->value()))
->filter(fn ($label, $key) => in_array($prompt->isList() ? $label : $key, $prompt->value()))
->count();
if ($hiddenCount > 0) {

View File

@@ -28,7 +28,7 @@ class MultiSelectPromptRenderer extends Renderer implements Scrolling
$this->renderOptions($prompt),
color: 'red',
)
->error('Cancelled.'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -28,7 +28,7 @@ class PasswordPromptRenderer extends Renderer
$this->strikethrough($this->dim($this->truncate($prompt->masked() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error('Cancelled.'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -0,0 +1,25 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\PausePrompt;
class PausePromptRenderer extends Renderer
{
use Concerns\DrawsBoxes;
/**
* Render the pause prompt.
*/
public function __invoke(PausePrompt $prompt): string
{
match ($prompt->state) {
'submit' => collect(explode(PHP_EOL, $prompt->message))
->each(fn ($line) => $this->line($this->gray(" {$line}"))),
default => collect(explode(PHP_EOL, $prompt->message))
->each(fn ($line) => $this->line($this->green(" {$line}")))
};
return $this;
}
}

View File

@@ -45,7 +45,7 @@ class ProgressRenderer extends Renderer
color: 'red',
info: $progress->progress.'/'.$progress->total,
)
->error('Cancelled.'),
->error($progress->cancelMessage),
default => $this
->box(

View File

@@ -5,7 +5,6 @@ namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Concerns\Colors;
use Laravel\Prompts\Concerns\Truncation;
use Laravel\Prompts\Prompt;
use RuntimeException;
abstract class Renderer
{
@@ -22,7 +21,7 @@ abstract class Renderer
*/
public function __construct(protected Prompt $prompt)
{
$this->checkTerminalSize($prompt);
//
}
/**
@@ -100,19 +99,4 @@ abstract class Renderer
.$this->output
.(in_array($this->prompt->state, ['submit', 'cancel']) ? PHP_EOL : '');
}
/**
* Check that the terminal is large enough to render the prompt.
*/
private function checkTerminalSize(Prompt $prompt): void
{
$required = 8;
$actual = $prompt->terminal()->lines();
if ($actual < $required) {
throw new RuntimeException(
"The terminal height must be at least [$required] lines but is currently [$actual]. Please increase the height or reduce the font size."
);
}
}
}

View File

@@ -30,7 +30,7 @@ class SearchPromptRenderer extends Renderer implements Scrolling
$this->strikethrough($this->dim($this->truncate($prompt->searchValue() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error('Cancelled'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -30,7 +30,7 @@ class SelectPromptRenderer extends Renderer implements Scrolling
$this->renderOptions($prompt),
color: 'red',
)
->error('Cancelled.'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -30,7 +30,7 @@ class SuggestPromptRenderer extends Renderer implements Scrolling
$this->strikethrough($this->dim($this->truncate($prompt->value() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error('Cancelled'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -14,7 +14,7 @@ class TableRenderer extends Renderer
*/
public function __invoke(Table $table): string
{
$tableStyle = (new TableStyle())
$tableStyle = (new TableStyle)
->setHorizontalBorderChars('─')
->setVerticalBorderChars('│', '│')
->setCellHeaderFormat($this->dim('<fg=default>%s</>'))
@@ -26,7 +26,7 @@ class TableRenderer extends Renderer
$tableStyle->setCrossingChars('┼', '<fg=gray>┌', '┬', '┐', '┤', '┘</>', '┴', '└', '├');
}
$buffered = new BufferedConsoleOutput();
$buffered = new BufferedConsoleOutput;
(new SymfonyTable($buffered))
->setHeaders($table->headers)

View File

@@ -28,7 +28,7 @@ class TextPromptRenderer extends Renderer
$this->strikethrough($this->dim($this->truncate($prompt->value() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error('Cancelled.'),
->error($prompt->cancelMessage),
'error' => $this
->box(

View File

@@ -0,0 +1,87 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\TextareaPrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
class TextareaPromptRenderer extends Renderer implements Scrolling
{
use Concerns\DrawsBoxes;
use Concerns\DrawsScrollbars;
/**
* Render the textarea prompt.
*/
public function __invoke(TextareaPrompt $prompt): string
{
$prompt->width = $prompt->terminal()->cols() - 8;
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->width)),
collect($prompt->lines())->implode(PHP_EOL),
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->width),
collect($prompt->lines())->map(fn ($line) => $this->strikethrough($this->dim($line)))->implode(PHP_EOL),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->width),
$this->renderText($prompt),
color: 'yellow',
info: 'Ctrl+D to submit'
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->width)),
$this->renderText($prompt),
info: 'Ctrl+D to submit'
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
)
};
}
/**
* Render the text in the prompt.
*/
protected function renderText(TextareaPrompt $prompt): string
{
$visible = collect($prompt->visible());
while ($visible->count() < $prompt->scroll) {
$visible->push('');
}
$longest = $this->longest($prompt->lines()) + 2;
return $this->scrollbar(
$visible,
$prompt->firstVisible,
$prompt->scroll,
count($prompt->lines()),
min($longest, $prompt->width + 2),
)->implode(PHP_EOL);
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 5;
}
}

View File

@@ -5,182 +5,245 @@ namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
/**
* Prompt the user for text input.
*/
function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = ''): string
{
return (new TextPrompt(...func_get_args()))->prompt();
}
/**
* Prompt the user for input, hiding the value.
*/
function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = ''): string
{
return (new PasswordPrompt(...func_get_args()))->prompt();
}
/**
* Prompt the user to select an option.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param true|string $required
*/
function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true): int|string
{
return (new SelectPrompt(...func_get_args()))->prompt();
}
/**
* Prompt the user to select multiple options.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string>|Collection<int, int|string> $default
* @return array<int|string>
*/
function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array
{
return (new MultiSelectPrompt(...func_get_args()))->prompt();
}
/**
* Prompt the user to confirm an action.
*/
function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = ''): bool
{
return (new ConfirmPrompt(...func_get_args()))->prompt();
}
/**
* Prompt the user for text input with auto-completion.
*
* @param array<string>|Collection<int, string>|Closure(string): array<string> $options
*/
function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = ''): string
{
return (new SuggestPrompt(...func_get_args()))->prompt();
}
/**
* Allow the user to search for an option.
*
* @param Closure(string): array<int|string, string> $options
* @param true|string $required
*/
function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true): int|string
{
return (new SearchPrompt(...func_get_args()))->prompt();
}
/**
* Allow the user to search for multiple option.
*
* @param Closure(string): array<int|string, string> $options
* @return array<int|string>
*/
function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.'): array
{
return (new MultiSearchPrompt(...func_get_args()))->prompt();
}
/**
* Render a spinner while the given callback is executing.
*
* @template TReturn of mixed
*
* @param \Closure(): TReturn $callback
* @return TReturn
*/
function spin(Closure $callback, string $message = ''): mixed
{
return (new Spinner($message))->spin($callback);
}
/**
* Display a note.
*/
function note(string $message, ?string $type = null): void
{
(new Note($message, $type))->display();
}
/**
* Display an error.
*/
function error(string $message): void
{
(new Note($message, 'error'))->display();
}
/**
* Display a warning.
*/
function warning(string $message): void
{
(new Note($message, 'warning'))->display();
}
/**
* Display an alert.
*/
function alert(string $message): void
{
(new Note($message, 'alert'))->display();
}
/**
* Display an informational message.
*/
function info(string $message): void
{
(new Note($message, 'info'))->display();
}
/**
* Display an introduction.
*/
function intro(string $message): void
{
(new Note($message, 'intro'))->display();
}
/**
* Display a closing message.
*/
function outro(string $message): void
{
(new Note($message, 'outro'))->display();
}
/**
* Display a table.
*
* @param array<int, string|array<int, string>>|Collection<int, string|array<int, string>> $headers
* @param array<int, array<int, string>>|Collection<int, array<int, string>> $rows
*/
function table(array|Collection $headers = [], array|Collection|null $rows = null): void
{
(new Table($headers, $rows))->display();
}
/**
* Display a progress bar.
*
* @template TSteps of iterable<mixed>|int
* @template TReturn
*
* @param TSteps $steps
* @param ?Closure((TSteps is int ? int : value-of<TSteps>), Progress<TSteps>): TReturn $callback
* @return ($callback is null ? Progress<TSteps> : array<TReturn>)
*/
function progress(string $label, iterable|int $steps, ?Closure $callback = null, string $hint = ''): array|Progress
{
$progress = new Progress($label, $steps, $hint);
if ($callback !== null) {
return $progress->map($callback);
if (! function_exists('\Laravel\Prompts\text')) {
/**
* Prompt the user for text input.
*/
function text(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): string
{
return (new TextPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\textarea')) {
/**
* Prompt the user for multiline text input.
*/
function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', int $rows = 5, ?Closure $transform = null): string
{
return (new TextareaPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\password')) {
/**
* Prompt the user for input, hiding the value.
*/
function password(string $label, string $placeholder = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): string
{
return (new PasswordPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\select')) {
/**
* Prompt the user to select an option.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param true|string $required
*/
function select(string $label, array|Collection $options, int|string|null $default = null, int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?Closure $transform = null): int|string
{
return (new SelectPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\multiselect')) {
/**
* Prompt the user to select multiple options.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string>|Collection<int, int|string> $default
* @return array<int|string>
*/
function multiselect(string $label, array|Collection $options, array|Collection $default = [], int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?Closure $transform = null): array
{
return (new MultiSelectPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\confirm')) {
/**
* Prompt the user to confirm an action.
*/
function confirm(string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): bool
{
return (new ConfirmPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\pause')) {
/**
* Prompt the user to continue or cancel after pausing.
*/
function pause(string $message = 'Press enter to continue...'): bool
{
return (new PausePrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\suggest')) {
/**
* Prompt the user for text input with auto-completion.
*
* @param array<string>|Collection<int, string>|Closure(string): array<string> $options
*/
function suggest(string $label, array|Collection|Closure $options, string $placeholder = '', string $default = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = '', ?Closure $transform = null): string
{
return (new SuggestPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\search')) {
/**
* Allow the user to search for an option.
*
* @param Closure(string): array<int|string, string> $options
* @param true|string $required
*/
function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, mixed $validate = null, string $hint = '', bool|string $required = true, ?Closure $transform = null): int|string
{
return (new SearchPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\multisearch')) {
/**
* Allow the user to search for multiple option.
*
* @param Closure(string): array<int|string, string> $options
* @return array<int|string>
*/
function multisearch(string $label, Closure $options, string $placeholder = '', int $scroll = 5, bool|string $required = false, mixed $validate = null, string $hint = 'Use the space bar to select options.', ?Closure $transform = null): array
{
return (new MultiSearchPrompt(...func_get_args()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\spin')) {
/**
* Render a spinner while the given callback is executing.
*
* @template TReturn of mixed
*
* @param \Closure(): TReturn $callback
* @return TReturn
*/
function spin(Closure $callback, string $message = ''): mixed
{
return (new Spinner($message))->spin($callback);
}
}
if (! function_exists('\Laravel\Prompts\note')) {
/**
* Display a note.
*/
function note(string $message, ?string $type = null): void
{
(new Note($message, $type))->display();
}
}
if (! function_exists('\Laravel\Prompts\error')) {
/**
* Display an error.
*/
function error(string $message): void
{
(new Note($message, 'error'))->display();
}
}
if (! function_exists('\Laravel\Prompts\warning')) {
/**
* Display a warning.
*/
function warning(string $message): void
{
(new Note($message, 'warning'))->display();
}
}
if (! function_exists('\Laravel\Prompts\alert')) {
/**
* Display an alert.
*/
function alert(string $message): void
{
(new Note($message, 'alert'))->display();
}
}
if (! function_exists('\Laravel\Prompts\info')) {
/**
* Display an informational message.
*/
function info(string $message): void
{
(new Note($message, 'info'))->display();
}
}
if (! function_exists('\Laravel\Prompts\intro')) {
/**
* Display an introduction.
*/
function intro(string $message): void
{
(new Note($message, 'intro'))->display();
}
}
if (! function_exists('\Laravel\Prompts\outro')) {
/**
* Display a closing message.
*/
function outro(string $message): void
{
(new Note($message, 'outro'))->display();
}
}
if (! function_exists('\Laravel\Prompts\table')) {
/**
* Display a table.
*
* @param array<int, string|array<int, string>>|Collection<int, string|array<int, string>> $headers
* @param array<int, array<int, string>>|Collection<int, array<int, string>> $rows
*/
function table(array|Collection $headers = [], array|Collection|null $rows = null): void
{
(new Table($headers, $rows))->display();
}
}
if (! function_exists('\Laravel\Prompts\progress')) {
/**
* Display a progress bar.
*
* @template TSteps of iterable<mixed>|int
* @template TReturn
*
* @param TSteps $steps
* @param ?Closure((TSteps is int ? int : value-of<TSteps>), Progress<TSteps>): TReturn $callback
* @return ($callback is null ? Progress<TSteps> : array<TReturn>)
*/
function progress(string $label, iterable|int $steps, ?Closure $callback = null, string $hint = ''): array|Progress
{
$progress = new Progress($label, $steps, $hint);
if ($callback !== null) {
return $progress->map($callback);
}
return $progress;
}
}
if (! function_exists('\Laravel\Prompts\form')) {
function form(): FormBuilder
{
return new FormBuilder;
}
return $progress;
}