first commit

This commit is contained in:
Sampanna Rimal
2024-08-27 17:48:06 +05:45
commit 53c0140f58
10839 changed files with 1125847 additions and 0 deletions

View File

@@ -0,0 +1,206 @@
<?php
namespace Laravel\Prompts\Concerns;
trait Colors
{
/**
* Reset all colors and styles.
*/
public function reset(string $text): string
{
return "\e[0m{$text}\e[0m";
}
/**
* Make the text bold.
*/
public function bold(string $text): string
{
return "\e[1m{$text}\e[22m";
}
/**
* Make the text dim.
*/
public function dim(string $text): string
{
return "\e[2m{$text}\e[22m";
}
/**
* Make the text italic.
*/
public function italic(string $text): string
{
return "\e[3m{$text}\e[23m";
}
/**
* Underline the text.
*/
public function underline(string $text): string
{
return "\e[4m{$text}\e[24m";
}
/**
* Invert the text and background colors.
*/
public function inverse(string $text): string
{
return "\e[7m{$text}\e[27m";
}
/**
* Hide the text.
*/
public function hidden(string $text): string
{
return "\e[8m{$text}\e[28m";
}
/**
* Strike through the text.
*/
public function strikethrough(string $text): string
{
return "\e[9m{$text}\e[29m";
}
/**
* Set the text color to black.
*/
public function black(string $text): string
{
return "\e[30m{$text}\e[39m";
}
/**
* Set the text color to red.
*/
public function red(string $text): string
{
return "\e[31m{$text}\e[39m";
}
/**
* Set the text color to green.
*/
public function green(string $text): string
{
return "\e[32m{$text}\e[39m";
}
/**
* Set the text color to yellow.
*/
public function yellow(string $text): string
{
return "\e[33m{$text}\e[39m";
}
/**
* Set the text color to blue.
*/
public function blue(string $text): string
{
return "\e[34m{$text}\e[39m";
}
/**
* Set the text color to magenta.
*/
public function magenta(string $text): string
{
return "\e[35m{$text}\e[39m";
}
/**
* Set the text color to cyan.
*/
public function cyan(string $text): string
{
return "\e[36m{$text}\e[39m";
}
/**
* Set the text color to white.
*/
public function white(string $text): string
{
return "\e[37m{$text}\e[39m";
}
/**
* Set the text background to black.
*/
public function bgBlack(string $text): string
{
return "\e[40m{$text}\e[49m";
}
/**
* Set the text background to red.
*/
public function bgRed(string $text): string
{
return "\e[41m{$text}\e[49m";
}
/**
* Set the text background to green.
*/
public function bgGreen(string $text): string
{
return "\e[42m{$text}\e[49m";
}
/**
* Set the text background to yellow.
*/
public function bgYellow(string $text): string
{
return "\e[43m{$text}\e[49m";
}
/**
* Set the text background to blue.
*/
public function bgBlue(string $text): string
{
return "\e[44m{$text}\e[49m";
}
/**
* Set the text background to magenta.
*/
public function bgMagenta(string $text): string
{
return "\e[45m{$text}\e[49m";
}
/**
* Set the text background to cyan.
*/
public function bgCyan(string $text): string
{
return "\e[46m{$text}\e[49m";
}
/**
* Set the text background to white.
*/
public function bgWhite(string $text): string
{
return "\e[47m{$text}\e[49m";
}
/**
* Set the text color to gray.
*/
public function gray(string $text): string
{
return "\e[90m{$text}\e[39m";
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Laravel\Prompts\Concerns;
trait Cursor
{
/**
* Indicates if the cursor has been hidden.
*/
protected static bool $cursorHidden = false;
/**
* Hide the cursor.
*/
public function hideCursor(): void
{
static::writeDirectly("\e[?25l");
static::$cursorHidden = true;
}
/**
* Show the cursor.
*/
public function showCursor(): void
{
static::writeDirectly("\e[?25h");
static::$cursorHidden = false;
}
/**
* Restore the cursor if it was hidden.
*/
public function restoreCursor(): void
{
if (static::$cursorHidden) {
$this->showCursor();
}
}
/**
* Move the cursor.
*/
public function moveCursor(int $x, int $y = 0): void
{
$sequence = '';
if ($x < 0) {
$sequence .= "\e[".abs($x).'D'; // Left
} elseif ($x > 0) {
$sequence .= "\e[{$x}C"; // Right
}
if ($y < 0) {
$sequence .= "\e[".abs($y).'A'; // Up
} elseif ($y > 0) {
$sequence .= "\e[{$y}B"; // Down
}
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

@@ -0,0 +1,31 @@
<?php
namespace Laravel\Prompts\Concerns;
trait Erase
{
/**
* Erase the given number of lines downwards from the cursor position.
*/
public function eraseLines(int $count): void
{
$clear = '';
for ($i = 0; $i < $count; $i++) {
$clear .= "\e[2K".($i < $count - 1 ? "\e[{$count}A" : '');
}
if ($count) {
$clear .= "\e[G";
}
static::writeDirectly($clear);
}
/**
* Erase from cursor until end of screen.
*/
public function eraseDown(): void
{
static::writeDirectly("\e[J");
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Laravel\Prompts\Concerns;
use Closure;
trait Events
{
/**
* The registered event listeners.
*
* @var array<string, array<int, Closure>>
*/
protected array $listeners = [];
/**
* Register an event listener.
*/
public function on(string $event, Closure $callback): void
{
$this->listeners[$event][] = $callback;
}
/**
* Emit an event.
*/
public function emit(string $event, mixed ...$data): void
{
foreach ($this->listeners[$event] ?? [] as $listener) {
$listener(...$data);
}
}
/**
* Clean the event listeners.
*/
public function clearListeners(): void
{
$this->listeners = [];
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Output\BufferedConsoleOutput;
use Laravel\Prompts\Terminal;
use PHPUnit\Framework\Assert;
use RuntimeException;
trait FakesInputOutput
{
/**
* Fake the terminal and queue key presses to be simulated.
*
* @param array<string> $keys
*/
public static function fake(array $keys = []): void
{
// Force interactive mode when testing because we will be mocking the terminal.
static::interactive();
$mock = \Mockery::mock(Terminal::class);
$mock->shouldReceive('write')->byDefault();
$mock->shouldReceive('exit')->byDefault();
$mock->shouldReceive('setTty')->byDefault();
$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);
}
static::$terminal = $mock;
self::setOutput(new BufferedConsoleOutput());
}
/**
* Assert that the output contains the given string.
*/
public static function assertOutputContains(string $string): void
{
Assert::assertStringContainsString($string, static::content());
}
/**
* Assert that the output doesn't contain the given string.
*/
public static function assertOutputDoesntContain(string $string): void
{
Assert::assertStringNotContainsString($string, static::content());
}
/**
* Assert that the stripped output contains the given string.
*/
public static function assertStrippedOutputContains(string $string): void
{
Assert::assertStringContainsString($string, static::strippedContent());
}
/**
* Assert that the stripped output doesn't contain the given string.
*/
public static function assertStrippedOutputDoesntContain(string $string): void
{
Assert::assertStringNotContainsString($string, static::strippedContent());
}
/**
* Get the buffered console output.
*/
public static function content(): string
{
if (! static::output() instanceof BufferedConsoleOutput) {
throw new RuntimeException('Prompt must be faked before accessing content.');
}
return static::output()->content();
}
/**
* Get the buffered console output, stripped of escape sequences.
*/
public static function strippedContent(): string
{
return preg_replace("/\e\[[0-9;?]*[A-Za-z]/", '', static::content());
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Laravel\Prompts\Concerns;
use Closure;
use RuntimeException;
trait Fallback
{
/**
* Whether to fallback to a custom implementation
*/
protected static bool $shouldFallback = false;
/**
* The fallback implementations.
*
* @var array<class-string, Closure($this): mixed>
*/
protected static array $fallbacks = [];
/**
* Enable the fallback implementation.
*/
public static function fallbackWhen(bool $condition): void
{
static::$shouldFallback = $condition || static::$shouldFallback;
}
/**
* Whether the prompt should fallback to a custom implementation.
*/
public static function shouldFallback(): bool
{
return static::$shouldFallback && isset(static::$fallbacks[static::class]);
}
/**
* Set the fallback implementation.
*
* @param Closure($this): mixed $fallback
*/
public static function fallbackUsing(Closure $fallback): void
{
static::$fallbacks[static::class] = $fallback;
}
/**
* Call the registered fallback implementation.
*/
public function fallback(): mixed
{
$fallback = static::$fallbacks[static::class] ?? null;
if ($fallback === null) {
throw new RuntimeException('No fallback implementation registered for ['.static::class.']');
}
return $fallback($this);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Exceptions\NonInteractiveValidationException;
trait Interactivity
{
/**
* Whether to render the prompt interactively.
*/
protected static bool $interactive;
/**
* Set interactive mode.
*/
public static function interactive(bool $interactive = true): void
{
static::$interactive = $interactive;
}
/**
* Return the default value if it passes validation.
*/
protected function default(): mixed
{
$default = $this->value();
$this->validate($default);
if ($this->state === 'error') {
throw new NonInteractiveValidationException($this->error);
}
return $default;
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Themes\Contracts\Scrolling as ScrollingRenderer;
trait Scrolling
{
/**
* The number of items to display before scrolling.
*/
public int $scroll;
/**
* The index of the highlighted option.
*/
public ?int $highlighted;
/**
* The index of the first visible option.
*/
public int $firstVisible = 0;
/**
* Initialize scrolling.
*/
protected function initializeScrolling(?int $highlighted = null): void
{
$this->highlighted = $highlighted;
$this->reduceScrollingToFitTerminal();
}
/**
* Reduce the scroll property to fit the terminal height.
*/
protected function reduceScrollingToFitTerminal(): void
{
$reservedLines = ($renderer = $this->getRenderer()) instanceof ScrollingRenderer ? $renderer->reservedLines() : 0;
$this->scroll = max(1, min($this->scroll, $this->terminal()->lines() - $reservedLines));
}
/**
* Highlight the given index.
*/
protected function highlight(?int $index): void
{
$this->highlighted = $index;
if ($this->highlighted === null) {
return;
}
if ($this->highlighted < $this->firstVisible) {
$this->firstVisible = $this->highlighted;
} elseif ($this->highlighted > $this->firstVisible + $this->scroll - 1) {
$this->firstVisible = $this->highlighted - $this->scroll + 1;
}
}
/**
* Highlight the previous entry, or wrap around to the last entry.
*/
protected function highlightPrevious(int $total, bool $allowNull = false): void
{
if ($total === 0) {
return;
}
if ($this->highlighted === null) {
$this->highlight($total - 1);
} elseif ($this->highlighted === 0) {
$this->highlight($allowNull ? null : ($total - 1));
} else {
$this->highlight($this->highlighted - 1);
}
}
/**
* Highlight the next entry, or wrap around to the first entry.
*/
protected function highlightNext(int $total, bool $allowNull = false): void
{
if ($total === 0) {
return;
}
if ($this->highlighted === $total - 1) {
$this->highlight($allowNull ? null : 0);
} else {
$this->highlight(($this->highlighted ?? -1) + 1);
}
}
/**
* Center the highlighted option.
*/
protected function scrollToHighlighted(int $total): void
{
if ($this->highlighted < $this->scroll) {
return;
}
$remaining = $total - $this->highlighted - 1;
$halfScroll = (int) floor($this->scroll / 2);
$endOffset = max(0, $halfScroll - $remaining);
if ($this->scroll % 2 === 0) {
$endOffset--;
}
$this->firstVisible = $this->highlighted - $halfScroll - $endOffset;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Output\BufferedConsoleOutput;
use function Termwind\render;
use function Termwind\renderUsing;
trait Termwind
{
protected function termwind(string $html)
{
renderUsing($output = new BufferedConsoleOutput());
render($html);
return $this->restoreEscapeSequences($output->fetch());
}
protected function restoreEscapeSequences(string $string)
{
return preg_replace('/\[(\d+)m/', "\e[".'\1m', $string);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Laravel\Prompts\Concerns;
use InvalidArgumentException;
use Laravel\Prompts\ConfirmPrompt;
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
{
/**
* The name of the active theme.
*/
protected static string $theme = 'default';
/**
* The available themes.
*
* @var array<string, array<class-string<\Laravel\Prompts\Prompt>, class-string<object&callable>>>
*/
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,
Spinner::class => SpinnerRenderer::class,
Note::class => NoteRenderer::class,
Table::class => TableRenderer::class,
Progress::class => ProgressRenderer::class,
],
];
/**
* Get or set the active theme.
*
* @throws \InvalidArgumentException
*/
public static function theme(?string $name = null): string
{
if ($name === null) {
return static::$theme;
}
if (! isset(static::$themes[$name])) {
throw new InvalidArgumentException("Prompt theme [{$name}] not found.");
}
return static::$theme = $name;
}
/**
* Add a new theme.
*
* @param array<class-string<\Laravel\Prompts\Prompt>, class-string<object&callable>> $renderers
*/
public static function addTheme(string $name, array $renderers): void
{
if ($name === 'default') {
throw new InvalidArgumentException('The default theme cannot be overridden.');
}
static::$themes[$name] = $renderers;
}
/**
* Get the renderer for the current prompt.
*/
protected function getRenderer(): callable
{
$class = get_class($this);
return new (static::$themes[static::$theme][$class] ?? static::$themes['default'][$class])($this);
}
/**
* Render the prompt using the active theme.
*/
protected function renderTheme(): string
{
$renderer = $this->getRenderer();
return $renderer($this);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Laravel\Prompts\Concerns;
use InvalidArgumentException;
trait Truncation
{
/**
* Truncate a value with an ellipsis if it exceeds the given width.
*/
protected function truncate(string $string, int $width): string
{
if ($width <= 0) {
throw new InvalidArgumentException("Width [{$width}] must be greater than zero.");
}
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

@@ -0,0 +1,128 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Key;
trait TypedValue
{
/**
* The value that has been typed.
*/
protected string $typedValue = '';
/**
* The position of the virtual cursor.
*/
protected int $cursorPosition = 0;
/**
* Track the value as the user types.
*/
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, $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;
}
match ($key) {
Key::LEFT, Key::LEFT_ARROW, Key::CTRL_B => $this->cursorPosition = max(0, $this->cursorPosition - 1),
Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_F => $this->cursorPosition = min(mb_strlen($this->typedValue), $this->cursorPosition + 1),
Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->cursorPosition = 0,
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->cursorPosition = mb_strlen($this->typedValue),
Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).mb_substr($this->typedValue, $this->cursorPosition + 1),
default => null,
};
return;
}
// Keys may be buffered.
foreach (mb_str_split($key) as $key) {
if ($ignore !== null && $ignore($key)) {
return;
}
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->cursorPosition++;
}
} elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) {
if ($this->cursorPosition === 0) {
return;
}
$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->cursorPosition++;
}
}
});
}
/**
* Get the value of the prompt.
*/
public function value(): string
{
return $this->typedValue;
}
/**
* Add a virtual cursor to the value and truncate if necessary.
*/
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 !== PHP_EOL ? $current : ' ';
$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 || $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('…') : '');
}
/**
* Get a truncated string with the specified width from the end.
*/
private function trimWidthBackwards(string $string, int $start, int $width): string
{
$reversed = implode('', array_reverse(mb_str_split($string, 1)));
$trimmed = mb_strimwidth($reversed, $start, $width);
return implode('', array_reverse(mb_str_split($trimmed, 1)));
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Laravel\Prompts;
class ConfirmPrompt extends Prompt
{
/**
* Whether the prompt has been confirmed.
*/
public bool $confirmed;
/**
* Create a new ConfirmPrompt instance.
*/
public function __construct(
public string $label,
public bool $default = true,
public string $yes = 'Yes',
public string $no = 'No',
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
) {
$this->confirmed = $default;
$this->on('key', fn ($key) => match ($key) {
'y' => $this->confirmed = true,
'n' => $this->confirmed = false,
Key::TAB, Key::UP, Key::UP_ARROW, Key::DOWN, Key::DOWN_ARROW, Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_P, Key::CTRL_F, Key::CTRL_N, Key::CTRL_B, 'h', 'j', 'k', 'l' => $this->confirmed = ! $this->confirmed,
Key::ENTER => $this->submit(),
default => null,
});
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return $this->confirmed;
}
/**
* Get the label of the selected option.
*/
public function label(): string
{
return $this->confirmed ? $this->yes : $this->no;
}
}

View File

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

View File

@@ -0,0 +1,10 @@
<?php
namespace Laravel\Prompts\Exceptions;
use RuntimeException;
class NonInteractiveValidationException 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): 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): 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): 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): 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): 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): 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): 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): 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): 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;
}
}

98
vendor/laravel/prompts/src/Key.php vendored Normal file
View File

@@ -0,0 +1,98 @@
<?php
namespace Laravel\Prompts;
class Key
{
const UP = "\e[A";
const DOWN = "\e[B";
const RIGHT = "\e[C";
const LEFT = "\e[D";
const UP_ARROW = "\eOA";
const DOWN_ARROW = "\eOB";
const RIGHT_ARROW = "\eOC";
const LEFT_ARROW = "\eOD";
const DELETE = "\e[3~";
const BACKSPACE = "\177";
const ENTER = "\n";
const SPACE = ' ';
const TAB = "\t";
const SHIFT_TAB = "\e[Z";
const HOME = ["\e[1~", "\eOH", "\e[H", "\e[7~"];
const END = ["\e[4~", "\eOF", "\e[F", "\e[8~"];
/**
* Cancel/SIGINT
*/
const CTRL_C = "\x03";
/**
* Previous/Up
*/
const CTRL_P = "\x10";
/**
* Next/Down
*/
const CTRL_N = "\x0E";
/**
* Forward/Right
*/
const CTRL_F = "\x06";
/**
* Back/Left
*/
const CTRL_B = "\x02";
/**
* Backspace
*/
const CTRL_H = "\x08";
/**
* Home
*/
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
*
* @param array<string|array<string>> $keys
*/
public static function oneOf(array $keys, string $match): ?string
{
return collect($keys)->flatten()->contains($match) ? $match : null;
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Laravel\Prompts;
use Closure;
class MultiSearchPrompt extends Prompt
{
use Concerns\Scrolling;
use Concerns\Truncation;
use Concerns\TypedValue;
/**
* The cached matches.
*
* @var array<int|string, string>|null
*/
protected ?array $matches = null;
/**
* Whether the matches are initially a list.
*/
protected bool $isList;
/**
* The selected values.
*
* @var array<int|string, string>
*/
public array $values = [];
/**
* Create a new MultiSearchPrompt instance.
*
* @param Closure(string): array<int|string, string> $options
*/
public function __construct(
public string $label,
public Closure $options,
public string $placeholder = '',
public int $scroll = 5,
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
) {
$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);
$this->initializeScrolling(null);
$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::SPACE => $this->highlighted !== null ? $this->toggleHighlighted() : null,
Key::ENTER => $this->submit(),
Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW => $this->highlighted = null,
default => $this->search(),
});
}
/**
* Perform the search.
*/
protected function search(): void
{
$this->state = 'searching';
$this->highlighted = null;
$this->render();
$this->matches = null;
$this->firstVisible = 0;
$this->state = 'active';
}
/**
* Get the entered value with a virtual cursor.
*/
public function valueWithCursor(int $maxWidth): string
{
if ($this->highlighted !== null) {
return $this->typedValue === ''
? $this->dim($this->truncate($this->placeholder, $maxWidth))
: $this->truncate($this->typedValue, $maxWidth);
}
if ($this->typedValue === '') {
return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth));
}
return $this->addCursor($this->typedValue, $this->cursorPosition, $maxWidth);
}
/**
* Get options that match the input.
*
* @return array<string>
*/
public function matches(): array
{
if (is_array($this->matches)) {
return $this->matches;
}
$matches = ($this->options)($this->typedValue);
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);
}
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;
}
/**
* The currently visible matches
*
* @return array<string>
*/
public function visible(): array
{
return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Toggle the highlighted entry.
*/
protected function toggleHighlighted(): void
{
if ($this->isList()) {
$label = $this->matches[$this->highlighted];
$key = $label;
} else {
$key = array_keys($this->matches)[$this->highlighted];
$label = $this->matches[$key];
}
if (array_key_exists($key, $this->values)) {
unset($this->values[$key]);
} else {
$this->values[$key] = $label;
}
}
/**
* Get the current search query.
*/
public function searchValue(): string
{
return $this->typedValue;
}
/**
* Get the selected value.
*
* @return array<int|string>
*/
public function value(): array
{
return array_keys($this->values);
}
/**
* Get the selected labels.
*
* @return array<string>
*/
public function labels(): array
{
return array_values($this->values);
}
/**
* Whether the matches are initially a list.
*/
public function isList(): bool
{
return $this->isList;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Laravel\Prompts;
use Illuminate\Support\Collection;
class MultiSelectPrompt extends Prompt
{
use Concerns\Scrolling;
/**
* The options for the multi-select prompt.
*
* @var array<int|string, string>
*/
public array $options;
/**
* The default values the multi-select prompt.
*
* @var array<int|string>
*/
public array $default;
/**
* The selected values.
*
* @var array<int|string>
*/
protected array $values = [];
/**
* Create a new MultiSelectPrompt instance.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string>|Collection<int, int|string> $default
*/
public function __construct(
public string $label,
array|Collection $options,
array|Collection $default = [],
public int $scroll = 5,
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
) {
$this->options = $options instanceof Collection ? $options->all() : $options;
$this->default = $default instanceof Collection ? $default->all() : $default;
$this->values = $this->default;
$this->initializeScrolling(0);
$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::SPACE => $this->toggleHighlighted(),
Key::ENTER => $this->submit(),
default => null,
});
}
/**
* Get the selected values.
*
* @return array<int|string>
*/
public function value(): array
{
return array_values($this->values);
}
/**
* Get the selected labels.
*
* @return array<string>
*/
public function labels(): array
{
if (array_is_list($this->options)) {
return array_map(fn ($value) => (string) $value, $this->values);
}
return array_values(array_intersect_key($this->options, array_flip($this->values)));
}
/**
* The currently visible options.
*
* @return array<int|string, string>
*/
public function visible(): array
{
return array_slice($this->options, $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Check whether the value is currently highlighted.
*/
public function isHighlighted(string $value): bool
{
if (array_is_list($this->options)) {
return $this->options[$this->highlighted] === $value;
}
return array_keys($this->options)[$this->highlighted] === $value;
}
/**
* Check whether the value is currently selected.
*/
public function isSelected(string $value): bool
{
return in_array($value, $this->values);
}
/**
* Toggle the highlighted entry.
*/
protected function toggleHighlighted(): void
{
$value = array_is_list($this->options)
? $this->options[$this->highlighted]
: array_keys($this->options)[$this->highlighted];
if (in_array($value, $this->values)) {
$this->values = array_filter($this->values, fn ($v) => $v !== $value);
} else {
$this->values[] = $value;
}
}
}

44
vendor/laravel/prompts/src/Note.php vendored Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace Laravel\Prompts;
class Note extends Prompt
{
/**
* Create a new Note instance.
*/
public function __construct(public string $message, public ?string $type = null)
{
//
}
/**
* Display the note.
*/
public function display(): void
{
$this->prompt();
}
/**
* Display the note.
*/
public function prompt(): bool
{
$this->capturePreviousNewLines();
$this->state = 'submit';
static::output()->write($this->renderTheme());
return true;
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Laravel\Prompts\Output;
class BufferedConsoleOutput extends ConsoleOutput
{
/**
* The output buffer.
*/
protected string $buffer = '';
/**
* Empties the buffer and returns its content.
*/
public function fetch(): string
{
$content = $this->buffer;
$this->buffer = '';
return $content;
}
/**
* Return the content of the buffer.
*/
public function content(): string
{
return $this->buffer;
}
/**
* Write to the output buffer.
*/
protected function doWrite(string $message, bool $newline): void
{
$this->buffer .= $message;
if ($newline) {
$this->buffer .= \PHP_EOL;
}
}
/**
* Write output directly, bypassing newline capture.
*/
public function writeDirectly(string $message): void
{
$this->doWrite($message, false);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Laravel\Prompts\Output;
use Symfony\Component\Console\Output\ConsoleOutput as SymfonyConsoleOutput;
class ConsoleOutput extends SymfonyConsoleOutput
{
/**
* How many new lines were written by the last output.
*/
protected int $newLinesWritten = 1;
/**
* How many new lines were written by the last output.
*/
public function newLinesWritten(): int
{
return $this->newLinesWritten;
}
/**
* Write the output and capture the number of trailing new lines.
*/
protected function doWrite(string $message, bool $newline): void
{
parent::doWrite($message, $newline);
if ($newline) {
$message .= \PHP_EOL;
}
$trailingNewLines = strlen($message) - strlen(rtrim($message, \PHP_EOL));
if (trim($message) === '') {
$this->newLinesWritten += $trailingNewLines;
} else {
$this->newLinesWritten = $trailingNewLines;
}
}
/**
* Write output directly, bypassing newline capture.
*/
public function writeDirectly(string $message): void
{
parent::doWrite($message, false);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Laravel\Prompts;
class PasswordPrompt extends Prompt
{
use Concerns\TypedValue;
/**
* Create a new PasswordPrompt instance.
*/
public function __construct(
public string $label,
public string $placeholder = '',
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
) {
$this->trackTypedValue();
}
/**
* Get a masked version of the entered value.
*/
public function masked(): string
{
return str_repeat('•', mb_strlen($this->value()));
}
/**
* Get the masked value with a virtual cursor.
*/
public function maskedWithCursor(int $maxWidth): string
{
if ($this->value() === '') {
return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth));
}
return $this->addCursor($this->masked(), $this->cursorPosition, $maxWidth);
}
}

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;
}
}

205
vendor/laravel/prompts/src/Progress.php vendored Normal file
View File

@@ -0,0 +1,205 @@
<?php
namespace Laravel\Prompts;
use Closure;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* @template TSteps of iterable<mixed>|int
*/
class Progress extends Prompt
{
/**
* The current progress bar item count.
*/
public int $progress = 0;
/**
* The total number of steps.
*/
public int $total = 0;
/**
* The original value of pcntl_async_signals
*/
protected bool $originalAsync;
/**
* Create a new ProgressBar instance.
*
* @param TSteps $steps
*/
public function __construct(public string $label, public iterable|int $steps, public string $hint = '')
{
$this->total = match (true) { // @phpstan-ignore assign.propertyType
is_int($this->steps) => $this->steps,
is_countable($this->steps) => count($this->steps),
is_iterable($this->steps) => iterator_count($this->steps),
default => throw new InvalidArgumentException('Unable to count steps.'),
};
if ($this->total === 0) {
throw new InvalidArgumentException('Progress bar must have at least one item.');
}
}
/**
* Map over the steps while rendering the progress bar.
*
* @template TReturn
*
* @param Closure((TSteps is int ? int : value-of<TSteps>), $this): TReturn $callback
* @return array<TReturn>
*/
public function map(Closure $callback): array
{
$this->start();
$result = [];
try {
if (is_int($this->steps)) {
for ($i = 0; $i < $this->steps; $i++) {
$result[] = $callback($i, $this);
$this->advance();
}
} else {
foreach ($this->steps as $step) {
$result[] = $callback($step, $this);
$this->advance();
}
}
} catch (Throwable $e) {
$this->state = 'error';
$this->render();
$this->restoreCursor();
$this->resetSignals();
throw $e;
}
if ($this->hint !== '') {
// Just pause for one moment to show the final hint
// so it doesn't look like it was skipped
usleep(250_000);
}
$this->finish();
return $result;
}
/**
* Start the progress bar.
*/
public function start(): void
{
$this->capturePreviousNewLines();
if (function_exists('pcntl_signal')) {
$this->originalAsync = pcntl_async_signals(true);
pcntl_signal(SIGINT, function () {
$this->state = 'cancel';
$this->render();
exit();
});
}
$this->state = 'active';
$this->hideCursor();
$this->render();
}
/**
* Advance the progress bar.
*/
public function advance(int $step = 1): void
{
$this->progress += $step;
if ($this->progress > $this->total) {
$this->progress = $this->total;
}
$this->render();
}
/**
* Finish the progress bar.
*/
public function finish(): void
{
$this->state = 'submit';
$this->render();
$this->restoreCursor();
$this->resetSignals();
}
/**
* Update the label.
*/
public function label(string $label): static
{
$this->label = $label;
return $this;
}
/**
* Update the hint.
*/
public function hint(string $hint): static
{
$this->hint = $hint;
return $this;
}
/**
* Get the completion percentage.
*/
public function percentage(): int|float
{
return $this->progress / $this->total;
}
/**
* Disable prompting for input.
*
* @throws \RuntimeException
*/
public function prompt(): never
{
throw new RuntimeException('Progress Bar cannot be prompted.');
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
/**
* Reset the signal handling.
*/
protected function resetSignals(): void
{
if (isset($this->originalAsync)) {
pcntl_async_signals($this->originalAsync);
pcntl_signal(SIGINT, SIG_DFL);
}
}
/**
* Restore the cursor.
*/
public function __destruct()
{
$this->restoreCursor();
}
}

392
vendor/laravel/prompts/src/Prompt.php vendored Normal file
View File

@@ -0,0 +1,392 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Laravel\Prompts\Exceptions\FormRevertedException;
use Laravel\Prompts\Output\ConsoleOutput;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
abstract class Prompt
{
use Concerns\Colors;
use Concerns\Cursor;
use Concerns\Erase;
use Concerns\Events;
use Concerns\FakesInputOutput;
use Concerns\Fallback;
use Concerns\Interactivity;
use Concerns\Themes;
/**
* The current state of the prompt.
*/
public string $state = 'initial';
/**
* The error message from the validator.
*/
public string $error = '';
/**
* The cancel message displayed when this prompt is cancelled.
*/
public string $cancelMessage = 'Cancelled.';
/**
* The previously rendered frame.
*/
protected string $prevFrame = '';
/**
* How many new lines were written by the last output.
*/
protected int $newLinesWritten = 1;
/**
* Whether user input is required.
*/
public bool|string $required;
/**
* The validator callback or rules.
*/
public mixed $validate;
/**
* The cancellation callback.
*/
protected static ?Closure $cancelUsing;
/**
* Indicates if the prompt has been validated.
*/
protected bool $validated = false;
/**
* The custom validation callback.
*/
protected static ?Closure $validateUsing;
/**
* The revert handler from the StepBuilder.
*/
protected static ?Closure $revertUsing = null;
/**
* The output instance.
*/
protected static OutputInterface $output;
/**
* The terminal instance.
*/
protected static Terminal $terminal;
/**
* Get the value of the prompt.
*/
abstract public function value(): mixed;
/**
* Render the prompt and listen for input.
*/
public function prompt(): mixed
{
try {
$this->capturePreviousNewLines();
if (static::shouldFallback()) {
return $this->fallback();
}
static::$interactive ??= stream_isatty(STDIN);
if (! static::$interactive) {
return $this->default();
}
$this->checkEnvironment();
try {
static::terminal()->setTty('-icanon -isig -echo');
} catch (Throwable $e) {
static::output()->writeln("<comment>{$e->getMessage()}</comment>");
static::fallbackWhen(true);
return $this->fallback();
}
$this->hideCursor();
$this->render();
while (($key = static::terminal()->read()) !== null) {
$continue = $this->handleKeyPress($key);
$this->render();
if ($continue === false || $key === Key::CTRL_C) {
if ($key === Key::CTRL_C) {
if (isset(static::$cancelUsing)) {
return (static::$cancelUsing)();
} else {
static::terminal()->exit();
}
}
if ($key === Key::CTRL_U && self::$revertUsing) {
throw new FormRevertedException();
}
return $this->value();
}
}
} finally {
$this->clearListeners();
}
}
/**
* Register a callback to be invoked when a user cancels a prompt.
*/
public static function cancelUsing(?Closure $callback): void
{
static::$cancelUsing = $callback;
}
/**
* How many new lines were written by the last output.
*/
public function newLinesWritten(): int
{
return $this->newLinesWritten;
}
/**
* Capture the number of new lines written by the last output.
*/
protected function capturePreviousNewLines(): void
{
$this->newLinesWritten = method_exists(static::output(), 'newLinesWritten')
? static::output()->newLinesWritten()
: 1;
}
/**
* Set the output instance.
*/
public static function setOutput(OutputInterface $output): void
{
self::$output = $output;
}
/**
* Get the current output instance.
*/
protected static function output(): OutputInterface
{
return self::$output ??= new ConsoleOutput();
}
/**
* Write output directly, bypassing newline capture.
*/
protected static function writeDirectly(string $message): void
{
match (true) {
method_exists(static::output(), 'writeDirectly') => static::output()->writeDirectly($message),
method_exists(static::output(), 'getOutput') => static::output()->getOutput()->write($message),
default => static::output()->write($message),
};
}
/**
* Get the terminal instance.
*/
public static function terminal(): Terminal
{
return static::$terminal ??= new Terminal();
}
/**
* Set the custom validation callback.
*/
public static function validateUsing(Closure $callback): void
{
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) {
return;
}
if ($this->state === 'initial') {
static::output()->write($frame);
$this->state = 'active';
$this->prevFrame = $frame;
return;
}
$terminalHeight = $this->terminal()->lines();
$previousFrameHeight = count(explode(PHP_EOL, $this->prevFrame));
$renderableLines = array_slice(explode(PHP_EOL, $frame), abs(min(0, $terminalHeight - $previousFrameHeight)));
$this->moveCursorToColumn(1);
$this->moveCursorUp(min($terminalHeight, $previousFrameHeight) - 1);
$this->eraseDown();
$this->output()->write(implode(PHP_EOL, $renderableLines));
$this->prevFrame = $frame;
}
/**
* Submit the prompt.
*/
protected function submit(): void
{
$this->validate($this->value());
if ($this->state !== 'error') {
$this->state = 'submit';
}
}
/**
* Handle a key press and determine whether to continue.
*/
private function handleKeyPress(string $key): bool
{
if ($this->state === 'error') {
$this->state = 'active';
}
$this->emit('key', $key);
if ($this->state === 'submit') {
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';
return false;
}
if ($this->validated) {
$this->validate($this->value());
}
return true;
}
/**
* Validate the input.
*/
private function validate(mixed $value): void
{
$this->validated = true;
if ($this->required !== false && $this->isInvalidWhenRequired($value)) {
$this->state = 'error';
$this->error = is_string($this->required) && strlen($this->required) > 0 ? $this->required : 'Required.';
return;
}
if (! isset($this->validate) && ! isset(static::$validateUsing)) {
return;
}
$error = match (true) {
is_callable($this->validate) => ($this->validate)($value),
isset(static::$validateUsing) => (static::$validateUsing)($this),
default => throw new RuntimeException('The validation logic is missing.'),
};
if (! is_string($error) && ! is_null($error)) {
throw new RuntimeException('The validator must return a string or null.');
}
if (is_string($error) && strlen($error) > 0) {
$this->state = 'error';
$this->error = $error;
}
}
/**
* Determine whether the given value is invalid when the prompt is required.
*/
protected function isInvalidWhenRequired(mixed $value): bool
{
return $value === '' || $value === [] || $value === false || $value === null;
}
/**
* Check whether the environment can support the prompt.
*/
private function checkEnvironment(): void
{
if (PHP_OS_FAMILY === 'Windows') {
throw new RuntimeException('Prompts is not currently supported on Windows. Please use WSL or configure a fallback.');
}
}
/**
* Restore the cursor and terminal state.
*/
public function __destruct()
{
$this->restoreCursor();
static::terminal()->restoreTty();
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace Laravel\Prompts;
use Closure;
use InvalidArgumentException;
class SearchPrompt extends Prompt
{
use Concerns\Scrolling;
use Concerns\Truncation;
use Concerns\TypedValue;
/**
* The cached matches.
*
* @var array<int|string, string>|null
*/
protected ?array $matches = null;
/**
* Create a new SearchPrompt instance.
*
* @param Closure(string): array<int|string, string> $options
*/
public function __construct(
public string $label,
public Closure $options,
public string $placeholder = '',
public int $scroll = 5,
public mixed $validate = null,
public string $hint = '',
public bool|string $required = true,
) {
if ($this->required === false) {
throw new InvalidArgumentException('Argument [required] must be true or a string.');
}
$this->trackTypedValue(submit: false, ignore: fn ($key) => Key::oneOf([Key::HOME, Key::END, Key::CTRL_A, Key::CTRL_E], $key) && $this->highlighted !== null);
$this->initializeScrolling(null);
$this->on('key', fn ($key) => match ($key) {
Key::UP, Key::UP_ARROW, Key::SHIFT_TAB, Key::CTRL_P => $this->highlightPrevious(count($this->matches), true),
Key::DOWN, Key::DOWN_ARROW, Key::TAB, Key::CTRL_N => $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::ENTER => $this->highlighted !== null ? $this->submit() : $this->search(),
Key::oneOf([Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_B, Key::CTRL_F], $key) => $this->highlighted = null,
default => $this->search(),
});
}
/**
* Perform the search.
*/
protected function search(): void
{
$this->state = 'searching';
$this->highlighted = null;
$this->render();
$this->matches = null;
$this->firstVisible = 0;
$this->state = 'active';
}
/**
* Get the entered value with a virtual cursor.
*/
public function valueWithCursor(int $maxWidth): string
{
if ($this->highlighted !== null) {
return $this->typedValue === ''
? $this->dim($this->truncate($this->placeholder, $maxWidth))
: $this->truncate($this->typedValue, $maxWidth);
}
if ($this->typedValue === '') {
return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth));
}
return $this->addCursor($this->typedValue, $this->cursorPosition, $maxWidth);
}
/**
* Get options that match the input.
*
* @return array<string>
*/
public function matches(): array
{
if (is_array($this->matches)) {
return $this->matches;
}
return $this->matches = ($this->options)($this->typedValue);
}
/**
* The currently visible matches.
*
* @return array<string>
*/
public function visible(): array
{
return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Get the current search query.
*/
public function searchValue(): string
{
return $this->typedValue;
}
/**
* Get the selected value.
*/
public function value(): int|string|null
{
if ($this->matches === null || $this->highlighted === null) {
return null;
}
return array_is_list($this->matches)
? $this->matches[$this->highlighted]
: array_keys($this->matches)[$this->highlighted];
}
/**
* Get the selected label.
*/
public function label(): ?string
{
return $this->matches[array_keys($this->matches)[$this->highlighted]] ?? null;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Laravel\Prompts;
use Illuminate\Support\Collection;
use InvalidArgumentException;
class SelectPrompt extends Prompt
{
use Concerns\Scrolling;
/**
* The options for the select prompt.
*
* @var array<int|string, string>
*/
public array $options;
/**
* Create a new SelectPrompt instance.
*
* @param array<int|string, string>|Collection<int|string, string> $options
*/
public function __construct(
public string $label,
array|Collection $options,
public int|string|null $default = null,
public int $scroll = 5,
public mixed $validate = null,
public string $hint = '',
public bool|string $required = true,
) {
if ($this->required === false) {
throw new InvalidArgumentException('Argument [required] must be true or a string.');
}
$this->options = $options instanceof Collection ? $options->all() : $options;
if ($this->default) {
if (array_is_list($this->options)) {
$this->initializeScrolling(array_search($this->default, $this->options) ?: 0);
} else {
$this->initializeScrolling(array_search($this->default, array_keys($this->options)) ?: 0);
}
$this->scrollToHighlighted(count($this->options));
} else {
$this->initializeScrolling(0);
}
$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::ENTER => $this->submit(),
default => null,
});
}
/**
* Get the selected value.
*/
public function value(): int|string|null
{
if (static::$interactive === false) {
return $this->default;
}
if (array_is_list($this->options)) {
return $this->options[$this->highlighted] ?? null;
} else {
return array_keys($this->options)[$this->highlighted];
}
}
/**
* Get the selected label.
*/
public function label(): ?string
{
if (array_is_list($this->options)) {
return $this->options[$this->highlighted] ?? null;
} else {
return $this->options[array_keys($this->options)[$this->highlighted]] ?? null;
}
}
/**
* The currently visible options.
*
* @return array<int|string, string>
*/
public function visible(): array
{
return array_slice($this->options, $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Determine whether the given value is invalid when the prompt is required.
*/
protected function isInvalidWhenRequired(mixed $value): bool
{
return $value === null;
}
}

160
vendor/laravel/prompts/src/Spinner.php vendored Normal file
View File

@@ -0,0 +1,160 @@
<?php
namespace Laravel\Prompts;
use Closure;
use RuntimeException;
class Spinner extends Prompt
{
/**
* How long to wait between rendering each frame.
*/
public int $interval = 100;
/**
* The number of times the spinner has been rendered.
*/
public int $count = 0;
/**
* Whether the spinner can only be rendered once.
*/
public bool $static = false;
/**
* The process ID after forking.
*/
protected int $pid;
/**
* Create a new Spinner instance.
*/
public function __construct(public string $message = '')
{
//
}
/**
* Render the spinner and execute the callback.
*
* @template TReturn of mixed
*
* @param \Closure(): TReturn $callback
* @return TReturn
*/
public function spin(Closure $callback): mixed
{
$this->capturePreviousNewLines();
if (! function_exists('pcntl_fork')) {
return $this->renderStatically($callback);
}
$originalAsync = pcntl_async_signals(true);
pcntl_signal(SIGINT, fn () => exit());
try {
$this->hideCursor();
$this->render();
$this->pid = pcntl_fork();
if ($this->pid === 0) {
while (true) { // @phpstan-ignore-line
$this->render();
$this->count++;
usleep($this->interval * 1000);
}
} else {
$result = $callback();
$this->resetTerminal($originalAsync);
return $result;
}
} catch (\Throwable $e) {
$this->resetTerminal($originalAsync);
throw $e;
}
}
/**
* Reset the terminal.
*/
protected function resetTerminal(bool $originalAsync): void
{
pcntl_async_signals($originalAsync);
pcntl_signal(SIGINT, SIG_DFL);
$this->eraseRenderedLines();
}
/**
* Render a static version of the spinner.
*
* @template TReturn of mixed
*
* @param \Closure(): TReturn $callback
* @return TReturn
*/
protected function renderStatically(Closure $callback): mixed
{
$this->static = true;
try {
$this->hideCursor();
$this->render();
$result = $callback();
} finally {
$this->eraseRenderedLines();
}
return $result;
}
/**
* Disable prompting for input.
*
* @throws \RuntimeException
*/
public function prompt(): never
{
throw new RuntimeException('Spinner cannot be prompted.');
}
/**
* Get the current value of the prompt.
*/
public function value(): bool
{
return true;
}
/**
* Clear the lines rendered by the spinner.
*/
protected function eraseRenderedLines(): void
{
$lines = explode(PHP_EOL, $this->prevFrame);
$this->moveCursor(-999, -count($lines) + 1);
$this->eraseDown();
}
/**
* Clean up after the spinner.
*/
public function __destruct()
{
if (! empty($this->pid)) {
posix_kill($this->pid, SIGHUP);
}
parent::__destruct();
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
class SuggestPrompt extends Prompt
{
use Concerns\Scrolling;
use Concerns\Truncation;
use Concerns\TypedValue;
/**
* The options for the suggest prompt.
*
* @var array<string>|Closure(string): (array<string>|Collection<int, string>)
*/
public array|Closure $options;
/**
* The cache of matches.
*
* @var array<string>|null
*/
protected ?array $matches = null;
/**
* Create a new SuggestPrompt instance.
*
* @param array<string>|Collection<int, string>|Closure(string): (array<string>|Collection<int, string>) $options
*/
public function __construct(
public string $label,
array|Collection|Closure $options,
public string $placeholder = '',
public string $default = '',
public int $scroll = 5,
public bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
) {
$this->options = $options instanceof Collection ? $options->all() : $options;
$this->initializeScrolling(null);
$this->on('key', fn ($key) => match ($key) {
Key::UP, Key::UP_ARROW, Key::SHIFT_TAB, Key::CTRL_P => $this->highlightPrevious(count($this->matches()), true),
Key::DOWN, Key::DOWN_ARROW, Key::TAB, Key::CTRL_N => $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::ENTER => $this->selectHighlighted(),
Key::oneOf([Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_B, Key::CTRL_F], $key) => $this->highlighted = null,
default => (function () {
$this->highlighted = null;
$this->matches = null;
$this->firstVisible = 0;
})(),
});
$this->trackTypedValue($default, ignore: fn ($key) => Key::oneOf([Key::HOME, Key::END, Key::CTRL_A, Key::CTRL_E], $key) && $this->highlighted !== null);
}
/**
* Get the entered value with a virtual cursor.
*/
public function valueWithCursor(int $maxWidth): string
{
if ($this->highlighted !== null) {
return $this->value() === ''
? $this->dim($this->truncate($this->placeholder, $maxWidth))
: $this->truncate($this->value(), $maxWidth);
}
if ($this->value() === '') {
return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth));
}
return $this->addCursor($this->value(), $this->cursorPosition, $maxWidth);
}
/**
* Get options that match the input.
*
* @return array<string>
*/
public function matches(): array
{
if (is_array($this->matches)) {
return $this->matches;
}
if ($this->options instanceof Closure) {
$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) {
return str_starts_with(strtolower($option), strtolower($this->value()));
}));
}
/**
* The current visible matches.
*
* @return array<string>
*/
public function visible(): array
{
return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Select the highlighted entry.
*/
protected function selectHighlighted(): void
{
if ($this->highlighted === null) {
return;
}
$this->typedValue = $this->matches()[$this->highlighted];
}
}

71
vendor/laravel/prompts/src/Table.php vendored Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace Laravel\Prompts;
use Illuminate\Support\Collection;
class Table extends Prompt
{
/**
* The table headers.
*
* @var array<int, string|array<int, string>>
*/
public array $headers;
/**
* The table rows.
*
* @var array<int, array<int, string>>
*/
public array $rows;
/**
* Create a new Table instance.
*
* @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
*
* @phpstan-param ($rows is null ? list<list<string>>|Collection<int, list<string>> : list<string|list<string>>|Collection<int, string|list<string>>) $headers
*/
public function __construct(array|Collection $headers = [], array|Collection|null $rows = null)
{
if ($rows === null) {
$rows = $headers;
$headers = [];
}
$this->headers = $headers instanceof Collection ? $headers->all() : $headers;
$this->rows = $rows instanceof Collection ? $rows->all() : $rows;
}
/**
* Display the table.
*/
public function display(): void
{
$this->prompt();
}
/**
* Display the table.
*/
public function prompt(): bool
{
$this->capturePreviousNewLines();
$this->state = 'submit';
static::output()->write($this->renderTheme());
return true;
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
}

119
vendor/laravel/prompts/src/Terminal.php vendored Normal file
View File

@@ -0,0 +1,119 @@
<?php
namespace Laravel\Prompts;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\Console\Terminal as SymfonyTerminal;
class Terminal
{
/**
* The initial TTY mode.
*/
protected ?string $initialTtyMode;
/**
* The Symfony Terminal instance.
*/
protected SymfonyTerminal $terminal;
/**
* Create a new Terminal instance.
*/
public function __construct()
{
$this->terminal = new SymfonyTerminal();
}
/**
* Read a line from the terminal.
*/
public function read(): string
{
$input = fread(STDIN, 1024);
return $input !== false ? $input : '';
}
/**
* Set the TTY mode.
*/
public function setTty(string $mode): void
{
$this->initialTtyMode ??= $this->exec('stty -g');
$this->exec("stty $mode");
}
/**
* Restore the initial TTY mode.
*/
public function restoreTty(): void
{
if (isset($this->initialTtyMode)) {
$this->exec("stty {$this->initialTtyMode}");
$this->initialTtyMode = null;
}
}
/**
* Get the number of columns in the terminal.
*/
public function cols(): int
{
return $this->terminal->getWidth();
}
/**
* Get the number of lines in the terminal.
*/
public function lines(): int
{
return $this->terminal->getHeight();
}
/**
* (Re)initialize the terminal dimensions.
*/
public function initDimensions(): void
{
(new ReflectionClass($this->terminal))
->getMethod('initDimensions')
->invoke($this->terminal);
}
/**
* Exit the interactive session.
*/
public function exit(): void
{
exit(1);
}
/**
* Execute the given command and return the output.
*/
protected function exec(string $command): string
{
$process = proc_open($command, [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if (! $process) {
throw new RuntimeException('Failed to create process.');
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
$code = proc_close($process);
if ($code !== 0 || $stdout === false) {
throw new RuntimeException(trim($stderr ?: "Unknown error (code: $code)"), $code);
}
return $stdout;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Laravel\Prompts;
class TextPrompt extends Prompt
{
use Concerns\TypedValue;
/**
* Create a new TextPrompt 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 = '',
) {
$this->trackTypedValue($default);
}
/**
* 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));
}
return $this->addCursor($this->value(), $this->cursorPosition, $maxWidth);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace Laravel\Prompts;
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,
) {
$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_strwidth($line) + ($index === $lines->count() - 1 ? 0 : 1));
$currentLineIndex = $this->currentLineIndex();
if ($currentLineIndex === 0) {
// They're already at the first line, jump them to the first position
$this->cursorPosition = 0;
return;
}
$currentLines = $lineLengths->slice(0, $currentLineIndex + 1);
$currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition);
$destinationLineLength = ($lineLengths->get($currentLineIndex - 1) ?? $currentLines->first()) - 1;
$newColumn = min($destinationLineLength, $currentColumn);
$fullLines = $currentLines->slice(0, -2);
$this->cursorPosition = $fullLines->sum() + $newColumn;
}
/**
* Handle the down key press.
*/
protected function handleDownKey(): void
{
$lines = collect($this->lines());
// Line length + 1 for the newline character
$lineLengths = $lines->map(fn ($line, $index) => mb_strwidth($line) + ($index === $lines->count() - 1 ? 0 : 1));
$currentLineIndex = $this->currentLineIndex();
if ($currentLineIndex === $lines->count() - 1) {
// They're already at the last line, jump them to the last position
$this->cursorPosition = mb_strwidth($lines->implode(PHP_EOL));
return;
}
// Lines up to and including the current line
$currentLines = $lineLengths->slice(0, $currentLineIndex + 1);
$currentColumn = $currentLines->last() - ($currentLines->sum() - $this->cursorPosition);
$destinationLineLength = $lineLengths->get($currentLineIndex + 1) ?? $currentLines->last();
if ($currentLineIndex + 1 !== $lines->count() - 1) {
$destinationLineLength--;
}
$newColumn = min(max(0, $destinationLineLength), $currentColumn);
$this->cursorPosition = $currentLines->sum() + $newColumn;
}
/**
* Adjust the visible window to ensure the cursor is always visible.
*/
protected function adjustVisibleWindow(): void
{
if (count($this->lines()) < $this->scroll) {
return;
}
$currentLineIndex = $this->currentLineIndex();
while ($this->firstVisible + $this->scroll <= $currentLineIndex) {
$this->firstVisible++;
}
if ($currentLineIndex === $this->firstVisible - 1) {
$this->firstVisible = max(0, $this->firstVisible - 1);
}
// Make sure there are always the scroll amount visible
if ($this->firstVisible + $this->scroll > count($this->lines())) {
$this->firstVisible = count($this->lines()) - $this->scroll;
}
}
/**
* Get the index of the current line that the cursor is on.
*/
protected function currentLineIndex(): int
{
$totalLineLength = 0;
return (int) collect($this->lines())->search(function ($line) use (&$totalLineLength) {
$totalLineLength += mb_strwidth($line) + 1;
return $totalLineLength > $this->cursorPosition;
}) ?: 0;
}
/**
* Calculate the cursor offset considering wrapped words.
*/
protected function cursorOffset(): int
{
$cursorOffset = 0;
preg_match_all('/\S{'.$this->width.',}/u', $this->value(), $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $match) {
if ($this->cursorPosition + $cursorOffset >= $match[1] + mb_strwidth($match[0])) {
$cursorOffset += (int) floor(mb_strwidth($match[0]) / $this->width);
}
}
return $cursorOffset;
}
/**
* A wrapped version of the placeholder with the virtual cursor.
*/
protected function wrappedPlaceholderWithCursor(): string
{
return implode(PHP_EOL, array_map(
$this->dim(...),
explode(PHP_EOL, $this->addCursor(
$this->mbWordwrap($this->placeholder, $this->width, PHP_EOL, true),
cursorPosition: 0,
))
));
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Laravel\Prompts\Themes\Contracts;
interface Scrolling
{
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int;
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Laravel\Prompts\Themes\Default\Concerns;
use Laravel\Prompts\Prompt;
trait DrawsBoxes
{
protected int $minWidth = 60;
/**
* Draw a box.
*
* @return $this
*/
protected function box(
string $title,
string $body,
string $footer = '',
string $color = 'gray',
string $info = '',
): self {
$this->minWidth = min($this->minWidth, Prompt::terminal()->cols() - 6);
$bodyLines = collect(explode(PHP_EOL, $body));
$footerLines = collect(explode(PHP_EOL, $footer))->filter();
$width = $this->longest(
$bodyLines
->merge($footerLines)
->push($title)
->toArray()
);
$titleLength = mb_strwidth($this->stripEscapeSequences($title));
$titleLabel = $titleLength > 0 ? " {$title} " : '';
$topBorder = str_repeat('─', $width - $titleLength + ($titleLength > 0 ? 0 : 2));
$this->line("{$this->{$color}(' ┌')}{$titleLabel}{$this->{$color}($topBorder.'┐')}");
$bodyLines->each(function ($line) use ($width, $color) {
$this->line("{$this->{$color}(' │')} {$this->pad($line, $width)} {$this->{$color}('│')}");
});
if ($footerLines->isNotEmpty()) {
$this->line($this->{$color}(' ├'.str_repeat('─', $width + 2).'┤'));
$footerLines->each(function ($line) use ($width, $color) {
$this->line("{$this->{$color}(' │')} {$this->pad($line, $width)} {$this->{$color}('│')}");
});
}
$this->line($this->{$color}(' └'.str_repeat(
'─', $info ? ($width - mb_strwidth($this->stripEscapeSequences($info))) : ($width + 2)
).($info ? " {$info} " : '').'┘'));
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
{
// 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

@@ -0,0 +1,55 @@
<?php
namespace Laravel\Prompts\Themes\Default\Concerns;
use Illuminate\Support\Collection;
trait DrawsScrollbars
{
/**
* Render a scrollbar beside the visible items.
*
* @param \Illuminate\Support\Collection<int, string> $visible
* @return \Illuminate\Support\Collection<int, string>
*/
protected function scrollbar(Collection $visible, int $firstVisible, int $height, int $total, int $width, string $color = 'cyan'): Collection
{
if ($height >= $total) {
return $visible;
}
$scrollPosition = $this->scrollPosition($firstVisible, $height, $total);
return $visible
->values()
->map(fn ($line) => $this->pad($line, $width))
->map(fn ($line, $index) => match ($index) {
$scrollPosition => preg_replace('/.$/', $this->{$color}('┃'), $line),
default => preg_replace('/.$/', $this->gray('│'), $line),
});
}
/**
* Return the position where the scrollbar "handle" should be rendered.
*/
protected function scrollPosition(int $firstVisible, int $height, int $total): int
{
if ($firstVisible === 0) {
return 0;
}
$maxPosition = $total - $height;
if ($firstVisible === $maxPosition) {
return $height - 1;
}
if ($height <= 2) {
return -1;
}
$percent = $firstVisible / $maxPosition;
return (int) round($percent * ($height - 3)) + 1;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\ConfirmPrompt;
class ConfirmPromptRenderer extends Renderer
{
use Concerns\DrawsBoxes;
/**
* Render the confirm prompt.
*/
public function __invoke(ConfirmPrompt $prompt): string
{
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->truncate($prompt->label(), $prompt->terminal()->cols() - 6)
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'red'
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'yellow',
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->renderOptions($prompt),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
),
};
}
/**
* Render the confirm prompt options.
*/
protected function renderOptions(ConfirmPrompt $prompt): string
{
$length = (int) floor(($prompt->terminal()->cols() - 14) / 2);
$yes = $this->truncate($prompt->yes, $length);
$no = $this->truncate($prompt->no, $length);
if ($prompt->state === 'cancel') {
return $this->dim($prompt->confirmed
? "{$this->strikethrough($yes)} / ○ {$this->strikethrough($no)}"
: "{$this->strikethrough($yes)} / ● {$this->strikethrough($no)}");
}
return $prompt->confirmed
? "{$this->green('●')} {$yes} {$this->dim('/ ○ '.$no)}"
: "{$this->dim('○ '.$yes.' /')} {$this->green('●')} {$no}";
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\MultiSearchPrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
class MultiSearchPromptRenderer extends Renderer implements Scrolling
{
use Concerns\DrawsBoxes;
use Concerns\DrawsScrollbars;
/**
* Render the suggest prompt.
*/
public function __invoke(MultiSearchPrompt $prompt): string
{
$maxWidth = $prompt->terminal()->cols() - 6;
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->renderSelectedOptions($prompt),
),
'cancel' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->strikethrough($this->dim($this->truncate($prompt->searchValue() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$prompt->valueWithCursor($maxWidth),
$this->renderOptions($prompt),
color: 'yellow',
info: $this->getInfoText($prompt),
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
'searching' => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->valueWithCursorAndSearchIcon($prompt, $maxWidth),
$this->renderOptions($prompt),
info: $this->getInfoText($prompt),
)
->hint($prompt->hint),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$prompt->valueWithCursor($maxWidth),
$this->renderOptions($prompt),
info: $this->getInfoText($prompt),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
)
->spaceForDropdown($prompt)
};
}
/**
* Render the value with the cursor and a search icon.
*/
protected function valueWithCursorAndSearchIcon(MultiSearchPrompt $prompt, int $maxWidth): string
{
return preg_replace(
'/\s$/',
$this->cyan('…'),
$this->pad($prompt->valueWithCursor($maxWidth - 1).' ', min($this->longest($prompt->matches(), padding: 2), $maxWidth))
);
}
/**
* Render a spacer to prevent jumping when the suggestions are displayed.
*/
protected function spaceForDropdown(MultiSearchPrompt $prompt): self
{
if ($prompt->searchValue() !== '') {
return $this;
}
$this->newLine(max(
0,
min($prompt->scroll, $prompt->terminal()->lines() - 7) - count($prompt->matches()),
));
if ($prompt->matches() === []) {
$this->newLine();
}
return $this;
}
/**
* Render the options.
*/
protected function renderOptions(MultiSearchPrompt $prompt): string
{
if ($prompt->searchValue() !== '' && empty($prompt->matches())) {
return $this->gray(' '.($prompt->state === 'searching' ? 'Searching...' : 'No results.'));
}
return $this->scrollbar(
collect($prompt->visible())
->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 = $prompt->isList()
? in_array($label, $prompt->value())
: in_array($key, $prompt->value());
return match (true) {
$active && $selected => "{$this->cyan(' ◼')} {$label} ",
$active => "{$this->cyan('')}{$label} ",
$selected => " {$this->cyan('◼')} {$this->dim($label)} ",
default => " {$this->dim('◻')} {$this->dim($label)} ",
};
}),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->matches()),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6)
)->implode(PHP_EOL);
}
/**
* Render the selected options.
*/
protected function renderSelectedOptions(MultiSearchPrompt $prompt): string
{
if (count($prompt->labels()) === 0) {
return $this->gray('None');
}
return implode("\n", array_map(
fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 6),
$prompt->labels()
));
}
/**
* Render the info text.
*/
protected function getInfoText(MultiSearchPrompt $prompt): string
{
$info = count($prompt->value()).' selected';
$hiddenCount = count($prompt->value()) - collect($prompt->matches())
->filter(fn ($label, $key) => in_array($prompt->isList() ? $label : $key, $prompt->value()))
->count();
if ($hiddenCount > 0) {
$info .= " ($hiddenCount hidden)";
}
return $info;
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 7;
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
class MultiSelectPromptRenderer extends Renderer implements Scrolling
{
use Concerns\DrawsBoxes;
use Concerns\DrawsScrollbars;
/**
* Render the multiselect prompt.
*/
public function __invoke(MultiSelectPrompt $prompt): string
{
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->renderSelectedOptions($prompt)
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'yellow',
info: count($prompt->options) > $prompt->scroll ? (count($prompt->value()).' selected') : '',
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->renderOptions($prompt),
info: count($prompt->options) > $prompt->scroll ? (count($prompt->value()).' selected') : '',
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
),
};
}
/**
* Render the options.
*/
protected function renderOptions(MultiSelectPrompt $prompt): string
{
return $this->scrollbar(
collect($prompt->visible())
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 12))
->map(function ($label, $key) use ($prompt) {
$index = array_search($key, array_keys($prompt->options));
$active = $index === $prompt->highlighted;
if (array_is_list($prompt->options)) {
$value = $prompt->options[$index];
} else {
$value = array_keys($prompt->options)[$index];
}
$selected = in_array($value, $prompt->value());
if ($prompt->state === 'cancel') {
return $this->dim(match (true) {
$active && $selected => "{$this->strikethrough($label)} ",
$active => "{$this->strikethrough($label)} ",
$selected => "{$this->strikethrough($label)} ",
default => "{$this->strikethrough($label)} ",
});
}
return match (true) {
$active && $selected => "{$this->cyan(' ◼')} {$label} ",
$active => "{$this->cyan('')}{$label} ",
$selected => " {$this->cyan('◼')} {$this->dim($label)} ",
default => " {$this->dim('◻')} {$this->dim($label)} ",
};
})
->values(),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->options),
min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
)->implode(PHP_EOL);
}
/**
* Render the selected options.
*/
protected function renderSelectedOptions(MultiSelectPrompt $prompt): string
{
if (count($prompt->labels()) === 0) {
return $this->gray('None');
}
return implode("\n", array_map(
fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 6),
$prompt->labels()
));
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 5;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Note;
class NoteRenderer extends Renderer
{
/**
* Render the note.
*/
public function __invoke(Note $note): string
{
$lines = collect(explode(PHP_EOL, $note->message));
switch ($note->type) {
case 'intro':
case 'outro':
$lines = $lines->map(fn ($line) => " {$line} ");
$longest = $lines->map(fn ($line) => strlen($line))->max();
$lines
->each(function ($line) use ($longest) {
$line = str_pad($line, $longest, ' ');
$this->line(" {$this->bgCyan($this->black($line))}");
});
return $this;
case 'warning':
$lines->each(fn ($line) => $this->line($this->yellow(" {$line}")));
return $this;
case 'error':
$lines->each(fn ($line) => $this->line($this->red(" {$line}")));
return $this;
case 'alert':
$lines->each(fn ($line) => $this->line(" {$this->bgRed($this->white(" {$line} "))}"));
return $this;
case 'info':
$lines->each(fn ($line) => $this->line($this->green(" {$line}")));
return $this;
default:
$lines->each(fn ($line) => $this->line(" {$line}"));
return $this;
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\PasswordPrompt;
class PasswordPromptRenderer extends Renderer
{
use Concerns\DrawsBoxes;
/**
* Render the password prompt.
*/
public function __invoke(PasswordPrompt $prompt): string
{
$maxWidth = $prompt->terminal()->cols() - 6;
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($prompt->label),
$this->truncate($prompt->masked(), $maxWidth),
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->strikethrough($this->dim($this->truncate($prompt->masked() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$prompt->maskedWithCursor($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->maskedWithCursor($maxWidth),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
),
};
}
}

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

@@ -0,0 +1,63 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Progress;
class ProgressRenderer extends Renderer
{
use Concerns\DrawsBoxes;
/**
* The character to use for the progress bar.
*/
protected string $barCharacter = '█';
/**
* Render the progress bar.
*
* @param Progress<int|iterable<mixed>> $progress
*/
public function __invoke(Progress $progress): string
{
$filled = str_repeat($this->barCharacter, (int) ceil($progress->percentage() * min($this->minWidth, $progress->terminal()->cols() - 6)));
return match ($progress->state) {
'submit' => $this
->box(
$this->dim($this->truncate($progress->label, $progress->terminal()->cols() - 6)),
$this->dim($filled),
info: $progress->progress.'/'.$progress->total,
),
'error' => $this
->box(
$this->truncate($progress->label, $progress->terminal()->cols() - 6),
$this->dim($filled),
color: 'red',
info: $progress->progress.'/'.$progress->total,
),
'cancel' => $this
->box(
$this->truncate($progress->label, $progress->terminal()->cols() - 6),
$this->dim($filled),
color: 'red',
info: $progress->progress.'/'.$progress->total,
)
->error($progress->cancelMessage),
default => $this
->box(
$this->cyan($this->truncate($progress->label, $progress->terminal()->cols() - 6)),
$this->dim($filled),
info: $progress->progress.'/'.$progress->total,
)
->when(
$progress->hint,
fn () => $this->hint($progress->hint),
fn () => $this->newLine() // Space for errors
)
};
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Concerns\Colors;
use Laravel\Prompts\Concerns\Truncation;
use Laravel\Prompts\Prompt;
abstract class Renderer
{
use Colors;
use Truncation;
/**
* The output to be rendered.
*/
protected string $output = '';
/**
* Create a new renderer instance.
*/
public function __construct(protected Prompt $prompt)
{
//
}
/**
* Render a line of output.
*/
protected function line(string $message): self
{
$this->output .= $message.PHP_EOL;
return $this;
}
/**
* Render a new line.
*/
protected function newLine(int $count = 1): self
{
$this->output .= str_repeat(PHP_EOL, $count);
return $this;
}
/**
* Render a warning message.
*/
protected function warning(string $message): self
{
return $this->line($this->yellow("{$message}"));
}
/**
* Render an error message.
*/
protected function error(string $message): self
{
return $this->line($this->red("{$message}"));
}
/**
* Render an hint message.
*/
protected function hint(string $message): self
{
if ($message === '') {
return $this;
}
$message = $this->truncate($message, $this->prompt->terminal()->cols() - 6);
return $this->line($this->gray(" {$message}"));
}
/**
* Apply the callback if the given "value" is truthy.
*
* @return $this
*/
protected function when(mixed $value, callable $callback, ?callable $default = null): self
{
if ($value) {
$callback($this);
} elseif ($default) {
$default($this);
}
return $this;
}
/**
* Render the output with a blank line above and below.
*/
public function __toString()
{
return str_repeat(PHP_EOL, max(2 - $this->prompt->newLinesWritten(), 0))
.$this->output
.(in_array($this->prompt->state, ['submit', 'cancel']) ? PHP_EOL : '');
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\SearchPrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
class SearchPromptRenderer extends Renderer implements Scrolling
{
use Concerns\DrawsBoxes;
use Concerns\DrawsScrollbars;
/**
* Render the suggest prompt.
*/
public function __invoke(SearchPrompt $prompt): string
{
$maxWidth = $prompt->terminal()->cols() - 6;
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->truncate($prompt->label(), $maxWidth),
),
'cancel' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->strikethrough($this->dim($this->truncate($prompt->searchValue() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$prompt->valueWithCursor($maxWidth),
$this->renderOptions($prompt),
color: 'yellow',
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
'searching' => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->valueWithCursorAndSearchIcon($prompt, $maxWidth),
$this->renderOptions($prompt),
)
->hint($prompt->hint),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$prompt->valueWithCursor($maxWidth),
$this->renderOptions($prompt),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
)
->spaceForDropdown($prompt)
};
}
/**
* Render the value with the cursor and a search icon.
*/
protected function valueWithCursorAndSearchIcon(SearchPrompt $prompt, int $maxWidth): string
{
return preg_replace(
'/\s$/',
$this->cyan('…'),
$this->pad($prompt->valueWithCursor($maxWidth - 1).' ', min($this->longest($prompt->matches(), padding: 2), $maxWidth))
);
}
/**
* Render a spacer to prevent jumping when the suggestions are displayed.
*/
protected function spaceForDropdown(SearchPrompt $prompt): self
{
if ($prompt->searchValue() !== '') {
return $this;
}
$this->newLine(max(
0,
min($prompt->scroll, $prompt->terminal()->lines() - 7) - count($prompt->matches()),
));
if ($prompt->matches() === []) {
$this->newLine();
}
return $this;
}
/**
* Render the options.
*/
protected function renderOptions(SearchPrompt $prompt): string
{
if ($prompt->searchValue() !== '' && empty($prompt->matches())) {
return $this->gray(' '.($prompt->state === 'searching' ? 'Searching...' : 'No results.'));
}
return $this->scrollbar(
collect($prompt->visible())
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10))
->map(function ($label, $key) use ($prompt) {
$index = array_search($key, array_keys($prompt->matches()));
return $prompt->highlighted === $index
? "{$this->cyan('')} {$label} "
: " {$this->dim($label)} ";
})
->values(),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->matches()),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6)
)->implode(PHP_EOL);
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 7;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\SelectPrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
class SelectPromptRenderer extends Renderer implements Scrolling
{
use Concerns\DrawsBoxes;
use Concerns\DrawsScrollbars;
/**
* Render the select prompt.
*/
public function __invoke(SelectPrompt $prompt): string
{
$maxWidth = $prompt->terminal()->cols() - 6;
return match ($prompt->state) {
'submit' => $this
->box(
$this->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->truncate($prompt->label(), $maxWidth),
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->renderOptions($prompt),
color: 'yellow',
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->renderOptions($prompt),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
),
};
}
/**
* Render the options.
*/
protected function renderOptions(SelectPrompt $prompt): string
{
return $this->scrollbar(
collect($prompt->visible())
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 12))
->map(function ($label, $key) use ($prompt) {
$index = array_search($key, array_keys($prompt->options));
if ($prompt->state === 'cancel') {
return $this->dim($prompt->highlighted === $index
? "{$this->strikethrough($label)} "
: "{$this->strikethrough($label)} "
);
}
return $prompt->highlighted === $index
? "{$this->cyan('')} {$this->cyan('●')} {$label} "
: " {$this->dim('○')} {$this->dim($label)} ";
})
->values(),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->options),
min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
)->implode(PHP_EOL);
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 5;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Spinner;
class SpinnerRenderer extends Renderer
{
/**
* The frames of the spinner.
*
* @var array<string>
*/
protected array $frames = ['⠂', '⠒', '⠐', '⠰', '⠠', '⠤', '⠄', '⠆'];
/**
* The frame to render when the spinner is static.
*/
protected string $staticFrame = '⠶';
/**
* The interval between frames.
*/
protected int $interval = 75;
/**
* Render the spinner.
*/
public function __invoke(Spinner $spinner): string
{
if ($spinner->static) {
return $this->line(" {$this->cyan($this->staticFrame)} {$spinner->message}");
}
$spinner->interval = $this->interval;
$frame = $this->frames[$spinner->count % count($this->frames)];
return $this->line(" {$this->cyan($frame)} {$spinner->message}");
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\SuggestPrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
class SuggestPromptRenderer extends Renderer implements Scrolling
{
use Concerns\DrawsBoxes;
use Concerns\DrawsScrollbars;
/**
* Render the suggest prompt.
*/
public function __invoke(SuggestPrompt $prompt): string
{
$maxWidth = $prompt->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->dim($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->strikethrough($this->dim($this->truncate($prompt->value() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error($prompt->cancelMessage),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->valueWithCursorAndArrow($prompt, $maxWidth),
$this->renderOptions($prompt),
color: 'yellow',
)
->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$this->valueWithCursorAndArrow($prompt, $maxWidth),
$this->renderOptions($prompt),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
)
->spaceForDropdown($prompt),
};
}
/**
* Render the value with the cursor and an arrow.
*/
protected function valueWithCursorAndArrow(SuggestPrompt $prompt, int $maxWidth): string
{
if ($prompt->highlighted !== null || $prompt->value() !== '' || count($prompt->matches()) === 0) {
return $prompt->valueWithCursor($maxWidth);
}
return preg_replace(
'/\s$/',
$this->cyan('⌄'),
$this->pad($prompt->valueWithCursor($maxWidth - 1).' ', min($this->longest($prompt->matches(), padding: 2), $maxWidth))
);
}
/**
* Render a spacer to prevent jumping when the suggestions are displayed.
*/
protected function spaceForDropdown(SuggestPrompt $prompt): self
{
if ($prompt->value() === '' && $prompt->highlighted === null) {
$this->newLine(min(
count($prompt->matches()),
$prompt->scroll,
$prompt->terminal()->lines() - 7
) + 1);
}
return $this;
}
/**
* Render the options.
*/
protected function renderOptions(SuggestPrompt $prompt): string
{
if (empty($prompt->matches()) || ($prompt->value() === '' && $prompt->highlighted === null)) {
return '';
}
return $this->scrollbar(
collect($prompt->visible())
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10))
->map(fn ($label, $key) => $prompt->highlighted === $key
? "{$this->cyan('')} {$label} "
: " {$this->dim($label)} "
),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->matches()),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
)->implode(PHP_EOL);
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 7;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Output\BufferedConsoleOutput;
use Laravel\Prompts\Table;
use Symfony\Component\Console\Helper\Table as SymfonyTable;
use Symfony\Component\Console\Helper\TableStyle;
class TableRenderer extends Renderer
{
/**
* Render the table.
*/
public function __invoke(Table $table): string
{
$tableStyle = (new TableStyle())
->setHorizontalBorderChars('─')
->setVerticalBorderChars('│', '│')
->setCellHeaderFormat($this->dim('<fg=default>%s</>'))
->setCellRowFormat('<fg=default>%s</>');
if (empty($table->headers)) {
$tableStyle->setCrossingChars('┼', '', '', '', '┤', '┘</>', '┴', '└', '├', '<fg=gray>┌', '┬', '┐');
} else {
$tableStyle->setCrossingChars('┼', '<fg=gray>┌', '┬', '┐', '┤', '┘</>', '┴', '└', '├');
}
$buffered = new BufferedConsoleOutput();
(new SymfonyTable($buffered))
->setHeaders($table->headers)
->setRows($table->rows)
->setStyle($tableStyle)
->render();
collect(explode(PHP_EOL, trim($buffered->content(), PHP_EOL)))
->each(fn ($line) => $this->line(' '.$line));
return $this;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\TextPrompt;
class TextPromptRenderer extends Renderer
{
use Concerns\DrawsBoxes;
/**
* Render the text prompt.
*/
public function __invoke(TextPrompt $prompt): string
{
$maxWidth = $prompt->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($prompt->cancelMessage),
'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),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
)
};
}
}

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;
}
}

207
vendor/laravel/prompts/src/helpers.php vendored Normal file
View File

@@ -0,0 +1,207 @@
<?php
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 multiline text input.
*/
function textarea(string $label, string $placeholder = '', string $default = '', bool|string $required = false, ?Closure $validate = null, string $hint = '', int $rows = 5): string
{
return (new TextareaPrompt($label, $placeholder, $default, $required, $validate, $hint, $rows))->prompt();
}
/**
* Prompt the user for input, hiding the value.
*/
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 to continue or cancel after pausing.
*/
function pause(string $message = 'Press enter to continue...'): bool
{
return (new PausePrompt(...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);
}
return $progress;
}
function form(): FormBuilder
{
return new FormBuilder();
}