refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

View File

@@ -0,0 +1,169 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
class AutoCompletePrompt extends Prompt
{
use Concerns\TypedValue;
/**
* The options for the autocomplete prompt.
*
* @var array<string>|Closure(string): (array<string>|Collection<int, string>)
*/
public array|Closure $options;
protected string $match = '';
protected int $highlighted = 0;
/**
* @var array<string>|null
*/
protected ?array $matches = null;
/**
* Create a new AutoCompletePrompt 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 bool|string $required = false,
public mixed $validate = null,
public string $hint = '',
public ?Closure $transform = null,
) {
$this->options = $options instanceof Collection ? $options->all() : $options;
$this->on('key', function ($key) {
if (in_array($key, [Key::UP, Key::UP_ARROW])) {
$matches = $this->matches();
if (count($matches) > 0) {
$this->highlighted = ($this->highlighted - 1 + count($matches)) % count($matches);
}
return;
}
if (in_array($key, [Key::DOWN, Key::DOWN_ARROW])) {
$matches = $this->matches();
if (count($matches) > 0) {
$this->highlighted = ($this->highlighted + 1) % count($matches);
}
return;
}
if ($key === Key::TAB && $this->cursorPosition >= mb_strlen($this->typedValue)) {
$match = $this->getMatch();
if ($match !== '' && mb_strlen($match) > mb_strlen($this->value())) {
// Ghost text is showing — accept it
$this->typedValue = $match;
$this->cursorPosition = mb_strlen($match);
} else {
// No ghost text — request suggestions
$this->matches = null;
$this->highlighted = 0;
}
return;
}
if (in_array($key, [Key::RIGHT, Key::RIGHT_ARROW]) && $this->cursorPosition >= mb_strlen($this->typedValue)) {
$match = $this->getMatch();
if ($match !== '') {
$this->typedValue = $match;
$this->cursorPosition = mb_strlen($match);
}
return;
}
// Any other key resets the highlight and match cache
$this->highlighted = 0;
$this->matches = null;
});
$this->trackTypedValue(
$default,
ignore: fn ($key) => in_array($key, [Key::UP, Key::UP_ARROW, Key::DOWN, Key::DOWN_ARROW]),
);
}
/**
* 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));
}
$this->match = $this->getMatch();
$ghostText = '';
if ($this->match !== '' && mb_strlen($this->match) > mb_strlen($this->value())) {
$ghostText = mb_substr($this->match, mb_strlen($this->value()));
}
// When cursor is at the end and there's ghost text, make the first
// ghost character the inverted cursor so it flows naturally.
if ($ghostText !== '' && $this->cursorPosition >= mb_strlen($this->value())) {
$cursorChar = mb_substr($ghostText, 0, 1);
$remainingGhost = mb_substr($ghostText, 1);
return $this->value()
.$this->inverse($cursorChar)
.$this->dim($remainingGhost);
}
return $this->addCursor(
$this->value(),
$this->cursorPosition,
$maxWidth
).$this->dim($ghostText);
}
/**
* Get the current matches for the typed value.
*
* @return array<string>
*/
public function matches(): array
{
if (is_array($this->matches)) {
return $this->matches;
}
if ($this->options instanceof Closure) {
$options = ($this->options)($this->value());
return $this->matches = array_values($options instanceof Collection ? $options->all() : $options);
}
return $this->matches = array_values(array_filter(
$this->options,
fn ($option) => str_starts_with(strtolower($option), strtolower($this->value())),
));
}
/**
* Get the current match.
*/
protected function getMatch(): string
{
return $this->matches()[$this->highlighted] ?? '';
}
}

35
vendor/laravel/prompts/src/Clear.php vendored Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace Laravel\Prompts;
class Clear extends Prompt
{
/**
* Clear the terminal.
*/
public function prompt(): bool
{
// Fill the previous newline count so subsequent prompts won't add padding.
static::output()->write(PHP_EOL.PHP_EOL);
$this->writeDirectly($this->renderTheme());
return true;
}
/**
* Clear the terminal.
*/
public function display(): void
{
$this->prompt();
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
}

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,110 @@
<?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();
$mock->shouldReceive('supportsTrueColor')->byDefault()->andReturn(false);
static::fakeKeyPresses($keys, function (string $key) use ($mock): void {
$mock->shouldReceive('read')->once()->andReturn($key);
});
static::$terminal = $mock;
self::setOutput(new BufferedConsoleOutput);
}
/**
* Implementation of the looping mechanism for simulating key presses.
*
* By ignoring the `$callable` parameter which contains the default logic
* for simulating fake key presses, we can use a custom implementation
* to emit key presses instead, allowing us to use different inputs.
*
* @param array<string> $keys
* @param callable(string $key): void $callable
*/
public static function fakeKeyPresses(array $keys, callable $callable): void
{
foreach ($keys as $key) {
$callable($key);
}
}
/**
* 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,20 @@
<?php
namespace Laravel\Prompts\Concerns;
use Closure;
trait HasInfo
{
/**
* Get the resolved info text.
*/
public function infoText(): string
{
if ($this->info instanceof Closure) {
return ($this->info)($this->highlightedValue()) ?? '';
}
return $this->info;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Laravel\Prompts\Concerns;
trait HasSpinner
{
/**
* The frames of the spinner (single dot moving around the perimeter).
*
* @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;
public function spinnerFrame(int $count): string
{
return $this->frames[$count % count($this->frames)];
}
}

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,142 @@
<?php
namespace Laravel\Prompts\Concerns;
use InvalidArgumentException;
use Laravel\Prompts\AutoCompletePrompt;
use Laravel\Prompts\Clear;
use Laravel\Prompts\ConfirmPrompt;
use Laravel\Prompts\DataTablePrompt;
use Laravel\Prompts\Grid;
use Laravel\Prompts\MultiSearchPrompt;
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Note;
use Laravel\Prompts\NumberPrompt;
use Laravel\Prompts\PasswordPrompt;
use Laravel\Prompts\PausePrompt;
use Laravel\Prompts\Progress;
use Laravel\Prompts\Prompt;
use Laravel\Prompts\SearchPrompt;
use Laravel\Prompts\SelectPrompt;
use Laravel\Prompts\Spinner;
use Laravel\Prompts\Stream;
use Laravel\Prompts\SuggestPrompt;
use Laravel\Prompts\Table;
use Laravel\Prompts\Task;
use Laravel\Prompts\TextareaPrompt;
use Laravel\Prompts\TextPrompt;
use Laravel\Prompts\Themes\Default\AutoCompletePromptRenderer;
use Laravel\Prompts\Themes\Default\ClearRenderer;
use Laravel\Prompts\Themes\Default\ConfirmPromptRenderer;
use Laravel\Prompts\Themes\Default\DataTableRenderer;
use Laravel\Prompts\Themes\Default\GridRenderer;
use Laravel\Prompts\Themes\Default\MultiSearchPromptRenderer;
use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer;
use Laravel\Prompts\Themes\Default\NoteRenderer;
use Laravel\Prompts\Themes\Default\NumberPromptRenderer;
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\StreamRenderer;
use Laravel\Prompts\Themes\Default\SuggestPromptRenderer;
use Laravel\Prompts\Themes\Default\TableRenderer;
use Laravel\Prompts\Themes\Default\TaskRenderer;
use Laravel\Prompts\Themes\Default\TextareaPromptRenderer;
use Laravel\Prompts\Themes\Default\TextPromptRenderer;
use Laravel\Prompts\Themes\Default\TitleRenderer;
use Laravel\Prompts\Title;
trait Themes
{
/**
* The name of the active theme.
*/
protected static string $theme = 'default';
/**
* The available themes.
*
* @var array<string, array<class-string<Prompt>, class-string<object&callable>>>
*/
protected static array $themes = [
'default' => [
TextPrompt::class => TextPromptRenderer::class,
NumberPrompt::class => NumberPromptRenderer::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,
Clear::class => ClearRenderer::class,
Grid::class => GridRenderer::class,
AutoCompletePrompt::class => AutoCompletePromptRenderer::class,
Title::class => TitleRenderer::class,
Stream::class => StreamRenderer::class,
Task::class => TaskRenderer::class,
DataTablePrompt::class => DataTableRenderer::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<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,20 @@
<?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).'…');
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Laravel\Prompts\Concerns;
use IntlBreakIterator;
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 (strlen($this->typedValue) > 0) {
$this->cursorPosition = mb_strlen($this->typedValue);
}
$this->on('key', function (string $key) use ($submit, $ignore, $allowNewLine): void {
if ($key !== '' &&
($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),
Key::OPTION_BACKSPACE => $this->deleteWordBackward(),
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 (mb_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)));
}
/**
* Delete from the start of the current word (before cursor) through the cursor.
*/
protected function deleteWordBackward(): void
{
if ($this->cursorPosition === 0) {
return;
}
$start = $this->findWordStartBeforeCursor();
$this->typedValue = mb_substr($this->typedValue, 0, $start).mb_substr($this->typedValue, $this->cursorPosition);
$this->cursorPosition = $start;
}
/**
* Character offset of the word boundary immediately before the cursor (Intl + punctuation).
* Punctuation (e.g. . - _) is treated as a word boundary so "word.word" deletes in two steps.
*/
protected function findWordStartBeforeCursor(): int
{
$before = mb_substr($this->typedValue, 0, $this->cursorPosition);
if ($before === '') {
return 0;
}
$regexStart = $this->findLastWordStartByLettersAndNumbers($before);
if (extension_loaded('intl')) {
$iterator = IntlBreakIterator::createWordInstance('');
$iterator->setText($before);
$endByte = strlen($before);
$wordStartByte = $iterator->preceding($endByte);
if ($wordStartByte === IntlBreakIterator::DONE) {
return $regexStart;
}
$intlStart = mb_strlen(substr($before, 0, $wordStartByte), 'UTF-8');
return max($intlStart, $regexStart);
}
return $regexStart;
}
/**
* Start (character offset) of the last run of letters/numbers in string (punctuation breaks words).
*/
protected function findLastWordStartByLettersAndNumbers(string $before): int
{
if (preg_match_all('/((?:\p{L}\p{M}*|\p{N})+)/u', $before, $m, PREG_OFFSET_CAPTURE) && $m[1] !== []) {
$last = end($m[1]);
return mb_strlen(substr($before, 0, $last[1]), 'UTF-8');
}
return 0;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Laravel\Prompts;
use Closure;
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 = '',
public ?Closure $transform = null,
) {
$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,257 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
class DataTablePrompt extends Prompt
{
use Concerns\Scrolling;
use Concerns\TypedValue;
/**
* The table headers.
*
* @var array<int, string|array<int, string>>
*/
public array $headers;
/**
* The table rows.
*
* @var array<int|string, array<int, string>>
*/
public array $rows;
/**
* The cached filtered rows.
*
* @var array<int|string, array<int, string>>|null
*/
protected ?array $filteredCache = null;
/**
* The previous search query (for cache invalidation).
*/
protected string $previousQuery = '';
/**
* Create a new DataTable instance.
*
* @param array<int, string|array<int, string>>|Collection<int, string|array<int, string>> $headers
* @param array<int|string, array<int, string>>|Collection<int|string, array<int, string>>|null $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,
public int $scroll = 10,
public string $label = '',
public string $hint = '',
public bool|string $required = false,
public mixed $validate = null,
public ?Closure $transform = null,
public ?Closure $filter = null,
) {
if ($rows === null) {
$rows = $headers;
$headers = [];
}
$this->headers = $headers instanceof Collection ? $headers->all() : $headers;
$this->rows = $rows instanceof Collection ? $rows->all() : $rows;
$this->initializeScrolling(0);
$this->trackTypedValue(
submit: false,
ignore: fn ($key) => $this->state !== 'search',
);
$this->on('key', fn ($key) => match ($this->state) {
'search' => $this->handleSearchKey($key),
default => $this->handleBrowseKey($key),
});
}
/**
* Handle key presses in browse mode.
*/
protected function handleBrowseKey(string $key): void
{
$total = count($this->filteredRows());
match ($key) {
Key::UP, Key::UP_ARROW, Key::CTRL_P => $this->highlightPrevious($total),
Key::DOWN, Key::DOWN_ARROW, Key::CTRL_N => $this->highlightNext($total),
Key::PAGE_UP => $this->highlight(max(0, $this->highlighted - $this->scroll)),
Key::PAGE_DOWN => $this->highlight(min($total - 1, $this->highlighted + $this->scroll)),
Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlight(0),
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlight(max(0, $total - 1)),
Key::ENTER => $total > 0 ? $this->submit() : null,
'/' => $this->enterSearch(),
default => null,
};
}
/**
* Handle key presses in search mode.
*/
protected function handleSearchKey(string $key): void
{
match ($key) {
Key::ENTER => $this->exitSearch(),
Key::ESCAPE => $this->cancelSearch(),
default => $this->search(),
};
}
/**
* Enter search mode.
*/
protected function enterSearch(): void
{
$this->state = 'search';
$this->typedValue = '';
$this->cursorPosition = 0;
}
/**
* Exit search mode, keeping the filtered results.
*/
protected function exitSearch(): void
{
$this->state = 'active';
$this->highlighted = 0;
$this->firstVisible = 0;
}
/**
* Cancel search, clearing the query and showing all rows.
*/
protected function cancelSearch(): void
{
$this->state = 'active';
$this->typedValue = '';
$this->cursorPosition = 0;
$this->filteredCache = null;
$this->previousQuery = '';
$this->highlighted = 0;
$this->firstVisible = 0;
}
/**
* Handle typing in search mode.
*/
protected function search(): void
{
$this->filteredCache = null;
$this->highlighted = 0;
$this->firstVisible = 0;
}
/**
* Get the filtered rows based on the current search query.
*
* @return array<int|string, array<int, string>>
*/
public function filteredRows(): array
{
if ($this->filteredCache !== null && $this->previousQuery === $this->typedValue) {
return $this->filteredCache;
}
$this->previousQuery = $this->typedValue;
if ($this->typedValue === '') {
return $this->filteredCache = $this->rows;
}
if ($this->filter !== null) {
return $this->filteredCache = array_filter(
$this->rows,
fn ($row) => ($this->filter)($row, $this->typedValue),
);
}
return $this->filteredCache = array_filter(
$this->rows,
fn ($row) => str_contains(
mb_strtolower(implode(' ', $row)),
mb_strtolower($this->typedValue),
),
);
}
/**
* The currently visible rows.
*
* @return array<int|string, array<int, string>>
*/
public function visible(): array
{
return array_slice($this->filteredRows(), $this->firstVisible, $this->scroll, preserve_keys: true);
}
/**
* Get the current search query.
*/
public function searchValue(): string
{
return $this->typedValue;
}
/**
* Get the search query with a virtual cursor.
*/
public function searchWithCursor(int $maxWidth): string
{
if ($this->typedValue === '') {
return $this->dim($this->addCursor('', 0, $maxWidth));
}
return $this->addCursor($this->typedValue, $this->cursorPosition, $maxWidth);
}
/**
* Get the value of the prompt.
*/
public function value(): mixed
{
if ($this->highlighted === null) {
return null;
}
$filtered = $this->filteredRows();
$keys = array_keys($filtered);
if (! isset($keys[$this->highlighted])) {
return null;
}
return $keys[$this->highlighted];
}
/**
* Get the selected row for display purposes.
*
* @return array<int, string>|null
*/
public function selectedRow(): ?array
{
if ($this->highlighted === null) {
return null;
}
$filtered = $this->filteredRows();
$keys = array_keys($filtered);
if (! isset($keys[$this->highlighted])) {
return null;
}
return $filtered[$keys[$this->highlighted]];
}
}

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

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

@@ -0,0 +1,59 @@
<?php
namespace Laravel\Prompts;
use Closure;
class FormStep
{
protected readonly Closure $condition;
public function __construct(
protected readonly Closure $step,
bool|Closure $condition,
public readonly ?string $name,
protected readonly bool $ignoreWhenReverting,
) {
$this->condition = is_bool($condition)
? fn () => $condition
: $condition;
}
/**
* Execute this step.
*
* @param array<mixed> $responses
*/
public function run(array $responses, mixed $previousResponse): mixed
{
if (! $this->shouldRun($responses)) {
return null;
}
return ($this->step)($responses, $previousResponse, $this->name);
}
/**
* 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;
}
}

65
vendor/laravel/prompts/src/Grid.php vendored Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace Laravel\Prompts;
use Illuminate\Support\Collection;
class Grid extends Prompt
{
/**
* The grid items.
*
* @var array<int, string>
*/
public array $items;
/**
* The maximum width of the grid.
*/
public int $maxWidth;
/**
* Create a new Grid instance.
*
* @param array<int, string>|Collection<int, string> $items
*/
public function __construct(array|Collection $items = [], ?int $maxWidth = null)
{
$this->items = $items instanceof Collection ? $items->all() : $items;
$this->maxWidth = $maxWidth ?? static::terminal()->cols() ?: 80;
}
/**
* Display the grid.
*/
public function display(): void
{
$this->prompt();
}
/**
* Display the grid.
*/
public function prompt(): bool
{
if ($this->items === []) {
return true;
}
$this->capturePreviousNewLines();
$this->state = 'submit';
static::output()->write($this->renderTheme());
return true;
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
}

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

@@ -0,0 +1,118 @@
<?php
namespace Laravel\Prompts;
class Key
{
const UP = "\e[A";
const SHIFT_UP = "\e[1;2A";
const PAGE_UP = "\e[5~";
const DOWN = "\e[B";
const SHIFT_DOWN = "\e[1;2B";
const PAGE_DOWN = "\e[6~";
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 ESCAPE = "\e";
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";
const OPTION_BACKSPACE = "\e\177";
/**
* 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
{
foreach ($keys as $key) {
if (is_array($key) && static::oneOf($key, $match) !== null) {
return $match;
} elseif ($key === $match) {
return $match;
}
}
return null;
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Laravel\Prompts\Support\Utils;
class MultiSearchPrompt extends Prompt
{
use Concerns\HasInfo;
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 = '',
public ?Closure $transform = null,
public string|Closure $info = '',
) {
$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) => $this->highlighted !== null ? $this->highlight(0) : null,
Key::oneOf(Key::END, $key) => $this->highlighted !== null ? $this->highlight(count($this->matches()) - 1) : null,
Key::SPACE => $this->highlighted !== null ? $this->toggleHighlighted() : null,
Key::CTRL_A => $this->highlighted !== null ? $this->toggleAll() : null,
Key::CTRL_E => null,
Key::ENTER => $this->submit(),
Key::LEFT, Key::LEFT_ARROW, Key::RIGHT, Key::RIGHT_ARROW => $this->highlighted = null,
default => $this->search(),
});
}
/**
* Get the value of the highlighted option.
*/
public function highlightedValue(): int|string|null
{
if ($this->highlighted === null || ! is_array($this->matches)) {
return null;
}
if ($this->isList()) {
return $this->matches[$this->highlighted] ?? null;
}
return array_keys($this->matches)[$this->highlighted] ?? null;
}
/**
* 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 all options.
*/
protected function toggleAll(): void
{
$allMatchesSelected = Utils::allMatch($this->matches, fn ($label, $key) => $this->isList()
? array_key_exists($label, $this->values)
: array_key_exists($key, $this->values));
if ($allMatchesSelected) {
$this->values = array_filter($this->values, fn ($value) => $this->isList()
? ! in_array($value, $this->matches)
: ! array_key_exists(array_search($value, $this->matches), $this->matches)
);
} else {
$this->values = $this->isList()
? array_merge($this->values, array_combine(array_values($this->matches), array_values($this->matches)))
: array_merge($this->values, array_combine(array_keys($this->matches), array_values($this->matches)));
}
}
/**
* Toggle the highlighted entry.
*/
protected function toggleHighlighted(): void
{
if ($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,168 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
class MultiSelectPrompt extends Prompt
{
use Concerns\HasInfo;
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 = '',
public ?Closure $transform = null,
public string|Closure $info = '',
) {
$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) => $this->highlight(0),
Key::oneOf(Key::END, $key) => $this->highlight(count($this->options) - 1),
Key::SPACE => $this->toggleHighlighted(),
Key::CTRL_A => $this->toggleAll(),
Key::ENTER => $this->submit(),
default => null,
});
}
/**
* Get the value of the highlighted option.
*/
public function highlightedValue(): int|string|null
{
if ($this->highlighted === null) {
return null;
}
if (array_is_list($this->options)) {
return $this->options[$this->highlighted] ?? null;
}
return array_keys($this->options)[$this->highlighted] ?? 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 all options.
*/
protected function toggleAll(): void
{
if (count($this->values) === count($this->options)) {
$this->values = [];
} else {
$this->values = array_is_list($this->options)
? array_values($this->options)
: array_keys($this->options);
}
}
/**
* Toggle the highlighted entry.
*/
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;
}
}
}

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

@@ -0,0 +1,48 @@
<?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();
if (static::shouldFallback()) {
return $this->fallback();
}
$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,139 @@
<?php
namespace Laravel\Prompts;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
class NotifyPrompt extends Prompt
{
/**
* Create a new NotifyPrompt instance.
*/
public function __construct(
public string $title,
public string $body = '',
public string $subtitle = '',
public string $sound = '',
public string $icon = '',
) {
//
}
/**
* Send the notification.
*/
public function prompt(): bool
{
return match (PHP_OS_FAMILY) {
'Darwin' => $this->sendMacOS(),
'Linux' => $this->sendLinux(),
default => false,
};
}
/**
* Send a notification on macOS using osascript.
*/
protected function sendMacOS(): bool
{
$script = 'display notification '.$this->escapeAppleScript($this->body);
$script .= ' with title '.$this->escapeAppleScript($this->title);
if ($this->subtitle !== '') {
$script .= ' subtitle '.$this->escapeAppleScript($this->subtitle);
}
if ($this->sound !== '') {
$script .= ' sound name '.$this->escapeAppleScript($this->sound);
}
return $this->execute(['osascript', '-e', $script]);
}
/**
* Send a notification on Linux, trying available notifiers.
*/
protected function sendLinux(): bool
{
$finder = new ExecutableFinder;
if ($finder->find('notify-send') !== null) {
return $this->sendLinuxNotifySend();
}
if ($finder->find('kdialog') !== null) {
return $this->sendLinuxKDialog();
}
return false;
}
/**
* Send a notification using notify-send.
*/
protected function sendLinuxNotifySend(): bool
{
$command = ['notify-send'];
if ($this->icon !== '') {
$command[] = '--icon';
$command[] = $this->icon;
}
$command[] = $this->title;
if ($this->body !== '') {
$command[] = $this->body;
}
return $this->execute($command);
}
/**
* Send a notification using kdialog.
*/
protected function sendLinuxKDialog(): bool
{
$message = $this->body !== '' ? "{$this->title}: {$this->body}" : $this->title;
return $this->execute(['kdialog', '--passivepopup', $message, '5', '--title', $this->title]);
}
/**
* Execute a command and return whether it was successful.
*
* @param array<int, string> $command
*/
protected function execute(array $command): bool
{
$process = new Process($command);
$process->run();
return $process->isSuccessful();
}
/**
* Escape a string for use in AppleScript.
*/
protected function escapeAppleScript(string $value): string
{
return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
}
/**
* Send the notification.
*/
public function display(): void
{
$this->prompt();
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Laravel\Prompts;
use Closure;
use RuntimeException;
class NumberPrompt extends Prompt
{
use Concerns\TypedValue;
/**
* Create a new NumberPrompt 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 = '',
public ?Closure $transform = null,
public ?int $min = null,
public ?int $max = null,
public ?int $step = null,
) {
$this->trackTypedValue($default);
$this->step = max(1, $this->step ?? 1);
$this->min ??= PHP_INT_MIN;
$this->max ??= PHP_INT_MAX;
$originalValidate = $this->validate;
$this->validate = $this->wrapValidation($this->validate);
$this->on('key', function (string $key) {
match ($key) {
Key::UP, Key::UP_ARROW => $this->increaseValue(),
Key::DOWN, Key::DOWN_ARROW => $this->decreaseValue(),
default => null,
};
});
}
protected function wrapValidation(mixed $validate): callable
{
return function ($value) use ($validate) {
if ($value !== '' && ! is_numeric($value)) {
return 'Must be a number';
}
if (is_numeric($value)) {
if ($value < $this->min) {
return 'Must be at least '.$this->min;
}
if ($value > $this->max) {
return 'Must be less than '.$this->max;
}
}
if (! $validate && ! isset(static::$validateUsing)) {
return null;
}
return match (true) {
is_callable($validate) => ($validate)($value),
isset(static::$validateUsing) => (static::$validateUsing)($this),
default => throw new RuntimeException('The validation logic is missing.'),
};
};
}
/**
* Increase the value of the prompt by the step.
*/
protected function increaseValue(): void
{
if ($this->typedValue === '') {
$this->typedValue = (string) ($this->min === PHP_INT_MIN ? 1 : $this->min);
$this->cursorPosition++;
return;
}
if (is_numeric($this->typedValue)) {
$previousValueLength = mb_strlen($this->typedValue);
$this->typedValue = (string) min($this->max, (int) $this->typedValue + $this->step);
if (mb_strlen($this->typedValue) > $previousValueLength) {
$this->cursorPosition++;
}
}
}
/**
* Decrease the value of the prompt by the step.
*/
protected function decreaseValue(): void
{
if ($this->typedValue === '') {
$this->typedValue = (string) ($this->max === PHP_INT_MAX ? 0 : $this->max);
$this->cursorPosition++;
return;
}
if (is_numeric($this->typedValue)) {
$previousValueLength = mb_strlen($this->typedValue);
$this->typedValue = (string) max($this->min, (int) $this->typedValue - $this->step);
if (mb_strlen($this->typedValue) < $previousValueLength) {
$this->cursorPosition--;
}
}
}
public function value(): int|string
{
if (is_numeric($this->typedValue)) {
return (int) $this->typedValue;
}
return $this->typedValue;
}
/**
* 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((string) $this->value(), $this->cursorPosition, $maxWidth);
}
}

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,51 @@
<?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;
}
preg_match('/(?:\r?\n)*$/', $message, $matches);
$trailingNewLines = substr_count($matches[0] ?? '', "\n");
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,44 @@
<?php
namespace Laravel\Prompts;
use Closure;
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 = '',
public ?Closure $transform = null,
) {
$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;
}
}

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

@@ -0,0 +1,217 @@
<?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 = '')
{
/** @phpstan-ignore assign.propertyType (PHPStan doesn't parse that we convert from iterable to int in the below match) */
$this->total = match (true) {
is_int($this->steps) => $this->steps,
is_countable($this->steps) => count($this->steps),
is_iterable($this->steps) => iterator_count($this->steps),
/** @phpstan-ignore match.unreachable (Technically we shouldn't be able to reach the default as should be int|countable|iterable ) */
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>
*
* @throws Throwable
*/
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->hideCursor();
$this->render();
$this->state = 'active';
}
/**
* 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();
}
/**
* Force the progress bar to re-render.
*/
public function render(): void
{
parent::render();
}
/**
* 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();
}
}

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

@@ -0,0 +1,448 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Laravel\Prompts\Exceptions\FormRevertedException;
use Laravel\Prompts\Output\ConsoleOutput;
use Laravel\Prompts\Support\Result;
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 transformation callback.
*/
public ?Closure $transform = null;
/**
* 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();
$result = $this->runLoop(function (string $key): ?Result {
$continue = $this->handleKeyPress($key);
$this->render();
if ($continue === false || $key === Key::CTRL_C) {
if ($key === Key::CTRL_C) {
if (isset(static::$cancelUsing)) {
return Result::from((static::$cancelUsing)());
} else {
static::terminal()->exit();
}
}
if ($key === Key::CTRL_U && self::$revertUsing) {
throw new FormRevertedException;
}
return Result::from($this->transformedValue());
}
// Continue looping.
return null;
});
return $result;
} finally {
$this->clearListeners();
}
}
/**
* Implementation of the prompt looping mechanism.
*
* @param callable(string $key): ?Result $callable
*/
public function runLoop(callable $callable): mixed
{
while (($key = static::terminal()->read()) !== null) {
/**
* If $key is an empty string, Terminal::read
* has failed. We can continue to the next
* iteration of the loop, and try again.
*/
if ($key === '') {
continue;
}
$result = $callable($key);
if ($result instanceof Result) {
return $result->value;
}
}
}
/**
* 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->transformedValue());
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->transformedValue());
}
return true;
}
/**
* Transform the input.
*/
private function transform(mixed $value): mixed
{
if (is_null($this->transform)) {
return $value;
}
return call_user_func($this->transform, $value);
}
/**
* Get the transformed value of the prompt.
*/
protected function transformedValue(): mixed
{
return $this->transform($this->value());
}
/**
* Validate the input.
*/
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,149 @@
<?php
namespace Laravel\Prompts;
use Closure;
use InvalidArgumentException;
class SearchPrompt extends Prompt
{
use Concerns\HasInfo;
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,
public ?Closure $transform = null,
public string|Closure $info = '',
) {
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(),
});
}
/**
* Get the value of the highlighted option.
*/
public function highlightedValue(): int|string|null
{
return $this->value();
}
/**
* 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,122 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
use InvalidArgumentException;
class SelectPrompt extends Prompt
{
use Concerns\HasInfo;
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,
public ?Closure $transform = null,
public string|Closure $info = '',
) {
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 !== null) {
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 value of the highlighted option.
*/
public function highlightedValue(): int|string|null
{
if (array_is_list($this->options)) {
return $this->options[$this->highlighted] ?? null;
}
return array_keys($this->options)[$this->highlighted] ?? 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();
}
}

123
vendor/laravel/prompts/src/Stream.php vendored Normal file
View File

@@ -0,0 +1,123 @@
<?php
namespace Laravel\Prompts;
use Laravel\Prompts\Themes\Default\Concerns\InteractsWithStrings;
class Stream extends Prompt
{
use InteractsWithStrings;
protected int $minWidth = 0;
protected string $message = '';
/** @var array<int, string> */
protected array $currentlyFading = [];
protected int $maxWidth = 0;
/** @var array<int, \Closure(string): string> */
protected array $fadingOutColors = [];
/**
* Create a new Stream instance.
*/
public function __construct()
{
$this->maxWidth = static::terminal()->cols() - 20;
$this->hideCursor();
$this->fadingOutColors = $this->fadeOut();
}
public function append(string $message): self
{
$this->currentlyFading[] = $message;
while (count($this->currentlyFading) > count($this->fadingOutColors)) {
$this->message .= array_shift($this->currentlyFading);
}
$this->render();
return $this;
}
public function close(): void
{
try {
while (count($this->currentlyFading) > 0) {
$this->message .= array_shift($this->currentlyFading);
$this->render();
usleep(25_000);
}
} finally {
$this->showCursor();
}
}
/** @return array<int, string> */
public function lines(): array
{
$toFadeIn = [];
foreach ($this->currentlyFading as $index => $message) {
$toFadeIn[] = $this->fadingOutColors[$index]($message);
}
$lines = explode(PHP_EOL, $this->message.implode('', $toFadeIn));
$finalLines = [];
foreach ($lines as $line) {
$finalLines = array_merge(
$finalLines,
$this->ansiWordwrap($line, $this->maxWidth),
);
}
return $finalLines;
}
public function prompt(): mixed
{
throw new \RuntimeException('Stream cannot be prompted');
}
/**
* Get the value of the prompt.
*/
public function value(): string
{
return $this->message.implode('', $this->currentlyFading);
}
/**
* Get an array of closures that progressively fade text from full color to nearly invisible.
*
* @return array<int, \Closure(string): string>
*/
protected function fadeOut(int $steps = 10): array
{
if (! static::terminal()->supportsTrueColor()) {
return [
fn (string $text) => $text,
fn (string $text) => $this->dim($text),
];
}
$fg = static::terminal()->foregroundColor();
$bg = static::terminal()->backgroundColor();
return array_map(
function (int $step) use ($fg, $bg, $steps) {
$factor = 1 - ($step / $steps);
$r = (int) ($bg[0] + ($fg[0] - $bg[0]) * $factor);
$g = (int) ($bg[1] + ($fg[1] - $bg[1]) * $factor);
$b = (int) ($bg[2] + ($fg[2] - $bg[2]) * $factor);
return fn (string $text) => "\e[38;2;{$r};{$g};{$b}m{$text}\e[0m";
},
range(0, $steps - 1),
);
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
class SuggestPrompt extends Prompt
{
use Concerns\HasInfo;
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 = '',
public ?Closure $transform = null,
public string|Closure $info = '',
) {
$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 value of the highlighted option.
*/
public function highlightedValue(): ?string
{
if ($this->highlighted === null) {
return null;
}
return $this->matches()[$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];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Laravel\Prompts\Support;
class Logger
{
/**
* Create a new Logger instance.
*
* @param resource|null $socket
*/
public function __construct(protected string $identifier, protected $socket = null)
{
//
}
/**
* The buffer for streaming text.
*/
protected string $streamBuffer = '';
/**
* Log a line to the process log.
*/
public function line(string $message): void
{
$this->write(rtrim($message));
}
/**
* Append a chunk of text, accumulating on the current line(s).
*/
public function partial(string $chunk): void
{
$this->streamBuffer .= $chunk;
$this->write($this->streamBuffer, 'partial');
}
/**
* Commit the accumulated partial text and start fresh.
*/
public function commitPartial(): void
{
$this->streamBuffer = '';
$this->write('', 'commitpartial');
}
/**
* Log a success message to the process log.
*/
public function success(string $message): void
{
$this->write($message, 'success');
}
/**
* Log a warning message to the process log.
*/
public function warning(string $message): void
{
$this->write($message, 'warning');
}
/**
* Log an error message to the process log.
*/
public function error(string $message): void
{
$this->write($message, 'error');
}
/**
* Update the label of the process log.
*/
public function label(string $message): void
{
$this->write($message, 'label');
}
/**
* Update the sub-label of the process log. Pass an empty string to clear.
*/
public function subLabel(string $message): void
{
$this->write($message, 'sublabel');
}
/**
* Write a message to the socket.
*/
protected function write(string $message, ?string $type = null): void
{
if ($type !== null) {
fwrite($this->socket, $this->prefix($type, $message).PHP_EOL);
} else {
fwrite($this->socket, $message.PHP_EOL);
}
}
/**
* Prefix a message with the identifier and type.
*/
protected function prefix(string $type, string $message): string
{
return $this->identifier.'_'.$type.':'.rtrim($message, PHP_EOL);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Laravel\Prompts\Support;
/**
* Result.
*
* This is a 'sentinel' value. It wraps a return value, which can
* allow us to differentiate between a `null` return value and
* a `null` return value that's intended to continue a loop.
*/
final class Result
{
public function __construct(public readonly mixed $value)
{
//
}
public static function from(mixed $value): self
{
return new self($value);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Laravel\Prompts\Support;
use Closure;
/**
* @internal
*/
class Utils
{
/**
* Determine if all items in an array match a truth test.
*
* @param array<array-key, mixed> $values
*/
public static function allMatch(array $values, Closure $callback): bool
{
foreach ($values as $key => $value) {
if (! $callback($value, $key)) {
return false;
}
}
return true;
}
/**
* Get the last item from an array or null if it doesn't exist.
*
* @param array<array-key, mixed> $array
*/
public static function last(array $array): mixed
{
return array_reverse($array)[0] ?? null;
}
/**
* Returns the key of the first element in the array that satisfies the callback.
*
* @param array<array-key, mixed> $array
*/
public static function search(array $array, Closure $callback): int|string|false
{
foreach ($array as $key => $value) {
if ($callback($value, $key)) {
return $key;
}
}
return false;
}
}

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

390
vendor/laravel/prompts/src/Task.php vendored Normal file
View File

@@ -0,0 +1,390 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Laravel\Prompts\Support\Logger;
use Laravel\Prompts\Themes\Default\Concerns\InteractsWithStrings;
use RuntimeException;
class Task extends Prompt
{
use InteractsWithStrings;
/**
* The minimum width for the longest line calculation.
*/
protected int $minWidth = 0;
/**
* How long to wait between rendering each frame.
*/
public int $interval = 100;
/**
* The number of times the task has been rendered.
*/
public int $count = 0;
/**
* Whether the task can only be rendered once.
*/
public bool $static = false;
/**
* The process ID after forking.
*/
protected int $pid;
/**
* The socket for IPC communication.
*
* @var resource|null
*/
protected $socket;
/**
* Pre-wrapped log lines for the scrolling output area.
*
* @var array<int, string>
*/
public array $logs = [];
/**
* Stable status messages (success, warning, error).
*
* @var list<array{type: string, message: string}>
*/
public array $stableMessages = [];
/**
* The maximum number of stable messages to display.
*/
public int $maxStableMessages = 10;
/**
* The identifier for the task.
*/
public string $identifier = '';
/**
* Whether the task has finished.
*/
public bool $finished = false;
/**
* Buffer for incomplete lines from non-blocking socket reads.
*/
protected string $buffer = '';
/**
* The log index where the current partial started, or null if not streaming.
*/
protected ?int $partialStartIndex = null;
/**
* Create a new Task instance.
*/
public function __construct(
public string $label = '',
public int $limit = 10,
public bool $keepSummary = false,
public ?string $subLabel = null,
) {
$this->identifier = uniqid();
}
/**
* Render the task and execute the callback.
*
* @template TReturn of mixed
*
* @param Closure(Logger): TReturn $callback
* @return TReturn
*/
public function run(Closure $callback): mixed
{
$this->limit = min($this->limit, $this->terminal()->lines() - 10);
$this->recalculateMaxStableMessages();
$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();
$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
if ($sockets === false) {
return $this->renderStatically($callback);
}
$this->pid = pcntl_fork();
if ($this->pid === 0) {
fclose($sockets[1]);
$childSocket = $sockets[0];
stream_set_blocking($childSocket, false);
while (true) { // @phpstan-ignore-line
$this->receiveMessages($childSocket);
if (! $this->finished) {
$this->render();
$this->count++;
}
usleep($this->interval * 1000);
}
} else {
fclose($sockets[0]);
$this->socket = $sockets[1];
$logger = new Logger($this->identifier, $this->socket);
$result = $callback($logger);
if ($this->socket !== null) {
// Send a reset message to the parent process to reset the terminal.
fwrite($this->socket, $this->identifier.'_'.'reset:'.($originalAsync ? 1 : 0).PHP_EOL);
usleep($this->interval * 2000);
}
return $result;
}
} catch (\Throwable $e) {
$this->resetTerminal($originalAsync);
throw $e;
}
}
/**
* Receive and process messages from the parent process.
*
* @param resource $socket
*/
protected function receiveMessages($socket): void
{
$prefix = preg_quote($this->identifier, '/');
while (($data = fgets($socket)) !== false) {
// Buffer incomplete lines from non-blocking reads.
if (! str_ends_with($data, PHP_EOL)) {
$this->buffer .= $data;
continue;
}
$line = rtrim($this->buffer.$data, PHP_EOL);
$this->buffer = '';
if ($line === '') {
continue;
}
// Check for typed messages: {id}_{type}:{content}
if (preg_match('/^'.$prefix.'_(success|warning|error|label|sublabel|reset|partial|commitpartial):(.*)/', $line, $matches)) {
$type = $matches[1];
$content = $matches[2];
if ($type === 'reset') {
$this->resetTerminal((bool) $content);
continue;
}
if ($type === 'partial') {
$this->replacePartialLines($content);
continue;
}
if ($type === 'commitpartial') {
$this->partialStartIndex = null;
continue;
}
if ($type === 'label') {
$this->label = $content;
} elseif ($type === 'sublabel') {
$this->subLabel = $content;
$this->recalculateMaxStableMessages();
} else {
$this->stableMessages[] = ['type' => $type, 'message' => $content];
$this->logs = [];
$this->partialStartIndex = null;
}
while (count($this->stableMessages) > $this->maxStableMessages) {
array_shift($this->stableMessages);
}
continue;
}
// Regular log line — strip cursor-reset control sequences.
$line = preg_replace('/\e\[(?:1)?G\e\[2K/', '', $line);
// Wrap and add to ring buffer.
$this->addLogLines($line);
}
}
/**
* Wrap a log line and append to the ring buffer, trimming to the limit.
*/
protected function addLogLines(string $line): void
{
$width = $this->terminal()->cols() - 10;
$plainText = $this->stripEscapeSequences($line);
if (mb_strwidth($plainText) > $width) {
$wrapped = $this->ansiWordwrap($line, $width);
} else {
$wrapped = [$line];
}
array_push($this->logs, ...$wrapped);
while (count($this->logs) > $this->limit) {
array_shift($this->logs);
}
}
/**
* Replace the in-progress partial lines with the full accumulated text.
*/
protected function replacePartialLines(string $text): void
{
if ($this->partialStartIndex === null) {
$this->partialStartIndex = count($this->logs);
}
// Truncate back to where the partial started.
$this->logs = array_slice($this->logs, 0, $this->partialStartIndex);
// Wrap and append the full accumulated partial text.
$width = $this->terminal()->cols() - 10;
$plainText = $this->stripEscapeSequences($text);
if (mb_strwidth($plainText) > $width) {
$wrapped = $this->ansiWordwrap($text, $width);
} else {
$wrapped = [$text];
}
array_push($this->logs, ...$wrapped);
while (count($this->logs) > $this->limit) {
array_shift($this->logs);
$this->partialStartIndex = max(0, $this->partialStartIndex - 1);
}
}
/**
* Recompute the stable-message budget based on the current sub-label state.
*/
protected function recalculateMaxStableMessages(): void
{
$reserved = 2 + ($this->subLabel !== null && $this->subLabel !== '' ? 1 : 0);
$this->maxStableMessages = max(0, $this->terminal()->lines() - 10 - $this->limit - $reserved);
}
/**
* Reset the terminal.
*/
protected function resetTerminal(bool $originalAsync): void
{
$this->finished = true;
pcntl_async_signals($originalAsync);
pcntl_signal(SIGINT, SIG_DFL);
if ($this->socket !== null) {
fclose($this->socket);
$this->socket = null;
}
if ($this->keepSummary && count($this->stableMessages) > 0) {
$this->render();
return;
}
$this->eraseRenderedLines();
}
/**
* Render a static version of the task.
*
* @template TReturn of mixed
*
* @param Closure(Logger): TReturn $callback
* @return TReturn
*/
protected function renderStatically(Closure $callback): mixed
{
$this->static = true;
try {
$this->hideCursor();
$this->render();
$logger = new Logger($this->identifier);
$result = $callback($logger);
} finally {
$this->eraseRenderedLines();
}
return $result;
}
/**
* Disable prompting for input.
*
* @throws RuntimeException
*/
public function prompt(): never
{
throw new RuntimeException('Task cannot be prompted.');
}
/**
* Get the current value of the prompt.
*/
public function value(): bool
{
return true;
}
/**
* Clear the lines rendered by the task.
*/
protected function eraseRenderedLines(): void
{
$lines = explode(PHP_EOL, $this->prevFrame);
$this->moveCursor(-999, -count($lines) + 1);
$this->eraseDown();
}
/**
* Clean up after the task.
*/
public function __destruct()
{
if (! empty($this->pid)) {
posix_kill($this->pid, SIGHUP);
}
parent::__destruct();
}
}

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

@@ -0,0 +1,212 @@
<?php
namespace Laravel\Prompts;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\Console\Terminal as SymfonyTerminal;
class Terminal
{
/**
* The initial TTY mode.
*/
protected ?string $initialTtyMode;
/**
* Whether the terminal supports true color.
*/
protected static ?bool $trueColorSupport = null;
/**
* The terminal's foreground color as an RGB array.
*
* @var array{int, int, int}|null
*/
protected static ?array $foregroundColor = null;
/**
* The terminal's background color as an RGB array.
*
* @var array{int, int, int}|null
*/
protected static ?array $backgroundColor = null;
/**
* 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;
}
/**
* Determine if the terminal supports true color (24-bit).
*/
public function supportsTrueColor(): bool
{
return static::$trueColorSupport ??= in_array(getenv('COLORTERM'), ['truecolor', '24bit']);
}
/**
* Get the terminal's foreground color as an RGB array.
*
* @return array{int, int, int}
*/
public function foregroundColor(): array
{
if (static::$foregroundColor === null) {
$this->queryColors();
}
return static::$foregroundColor;
}
/**
* Get the terminal's background color as an RGB array.
*
* @return array{int, int, int}
*/
public function backgroundColor(): array
{
if (static::$backgroundColor === null) {
$this->queryColors();
}
return static::$backgroundColor;
}
/**
* Query the terminal for foreground and background colors in a single shot.
*/
protected function queryColors(): void
{
$savedStty = trim((string) shell_exec('stty -g < /dev/tty'));
shell_exec('stty raw -echo min 0 time 1 < /dev/tty');
fwrite(STDOUT, "\e]10;?\e\\\e]11;?\e\\");
fflush(STDOUT);
$ttyIn = fopen('/dev/tty', 'r');
if ($ttyIn === false) {
static::$foregroundColor = [204, 204, 204];
static::$backgroundColor = [0, 0, 0];
return;
}
$response = fread($ttyIn, 200);
fclose($ttyIn);
shell_exec("stty {$savedStty} < /dev/tty");
preg_match_all('/rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i', $response ?: '', $matches, PREG_SET_ORDER);
$parse = fn (array $m) => [
(int) (hexdec($m[1]) / (strlen($m[1]) === 4 ? 257 : 1)),
(int) (hexdec($m[2]) / (strlen($m[2]) === 4 ? 257 : 1)),
(int) (hexdec($m[3]) / (strlen($m[3]) === 4 ? 257 : 1)),
];
static::$foregroundColor = isset($matches[0]) ? $parse($matches[0]) : [204, 204, 204];
static::$backgroundColor = isset($matches[1]) ? $parse($matches[1]) : [0, 0, 0];
}
}

View File

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

View File

@@ -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,53 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\AutoCompletePrompt;
class AutoCompletePromptRenderer extends Renderer
{
use Concerns\DrawsBoxes;
/**
* Render the text prompt.
*/
public function __invoke(AutoCompletePrompt $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,14 @@
<?php
namespace Laravel\Prompts\Themes\Default;
class ClearRenderer extends Renderer
{
/**
* Clear the terminal.
*/
public function __invoke(): string
{
return "\033[H\033[J";
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Laravel\Prompts\Themes\Default\Concerns;
use Laravel\Prompts\Prompt;
trait DrawsBoxes
{
use InteractsWithStrings;
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 = explode(PHP_EOL, $body);
$footerLines = array_filter(explode(PHP_EOL, $footer));
$width = $this->longest(array_merge($bodyLines, $footerLines, [$title]));
$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.'┐')}");
foreach ($bodyLines as $line) {
$this->line("{$this->{$color}(' │')} {$this->pad($line, $width)} {$this->{$color}('│')}");
}
if (count($footerLines) > 0) {
$this->line($this->{$color}(' ├'.str_repeat('─', $width + 2).'┤'));
foreach ($footerLines as $line) {
$this->line("{$this->{$color}(' │')} {$this->pad($line, $width)} {$this->{$color}('│')}");
}
}
if ($info) {
$info = $this->truncate($info, $width - 1);
}
$this->line($this->{$color}(' └'.str_repeat(
'─', $info ? ($width - mb_strwidth($this->stripEscapeSequences($info))) : ($width + 2)
).($info ? " {$info} " : '').'┘'));
return $this;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Laravel\Prompts\Themes\Default\Concerns;
use Illuminate\Support\Collection;
trait DrawsScrollbars
{
/**
* Render a scrollbar beside the visible items.
*
* @template T of array<int, string>|\Illuminate\Support\Collection<int, string>
*
* @param T $visible
* @return T
*/
protected function scrollbar(array|Collection $visible, int $firstVisible, int $height, int $total, int $width, string $color = 'cyan'): array|Collection
{
if ($height >= $total) {
return $visible;
}
$scrollPosition = $this->scrollPosition($firstVisible, $height, $total);
$lines = $visible instanceof Collection ? $visible->all() : $visible;
$result = array_map(fn ($line, $index) => match ($index) {
$scrollPosition => preg_replace('/.$/', $this->{$color}('┃'), $this->pad($line, $width)) ?? '',
default => preg_replace('/.$/', $this->gray('│'), $this->pad($line, $width)) ?? '',
}, array_values($lines), range(0, count($lines) - 1));
return $visible instanceof Collection ? new Collection($result) : $result; // @phpstan-ignore return.type (https://github.com/phpstan/phpstan/issues/11663)
}
/**
* 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,260 @@
<?php
namespace Laravel\Prompts\Themes\Default\Concerns;
trait InteractsWithStrings
{
/**
* Get the length of the longest line.
*
* @param array<string> $lines
*/
protected function longest(array $lines, int $padding = 0): int
{
return max(
$this->minWidth,
count($lines) > 0 ? max(array_map(fn ($line) => mb_strwidth($this->stripEscapeSequences($line)) + $padding, $lines)) : null
);
}
/**
* Pad text ignoring ANSI escape sequences.
*/
protected function pad(string $text, int $length, string $char = ' '): string
{
$rightPadding = str_repeat($char, max(0, $length - mb_strwidth($this->stripEscapeSequences($text))));
return "{$text}{$rightPadding}";
}
/**
* Strip ANSI escape sequences from the given text.
*/
protected function stripEscapeSequences(string $text): string
{
// Strip ANSI escape sequences.
$text = preg_replace("/\e[^m]*m/", '', $text);
// Strip Symfony named style tags.
$text = preg_replace("/<(info|comment|question|error)>(.*?)<\/\\1>/", '$2', $text);
// Strip Symfony inline style tags.
return preg_replace("/<(?:(?:[fb]g|options)=[a-z,;]+)+>(.*?)<\/>/i", '$1', $text);
}
/**
* 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);
}
/**
* Word wrap text while preserving ANSI escape sequences.
*
* @return array<int, string>
*/
protected function ansiWordwrap(string $text, int $width): array
{
// Parse segments and build character array with codes
$segments = $this->parseAnsiText($text);
$plainText = $this->stripEscapeSequences($text);
$chars = [];
foreach ($segments as $segment) {
$segmentChars = mb_str_split($segment['text']);
foreach ($segmentChars as $char) {
$chars[] = ['char' => $char, 'codes' => $segment['codes']];
}
}
// Word wrap the plain text
$wrappedLines = $this->mbWordwrap($plainText, $width, "\n", false);
$plainLines = explode("\n", $wrappedLines);
// Rebuild each wrapped line with ANSI codes
$result = [];
$charIndex = 0;
foreach ($plainLines as $plainLine) {
$line = '';
$lastCodes = '';
$lineChars = mb_str_split($plainLine);
foreach ($lineChars as $lineChar) {
// Find matching character in original (handling spaces removed by wordwrap)
while ($charIndex < count($chars) && $chars[$charIndex]['char'] !== $lineChar) {
// Skip spaces that wordwrap removed
if ($chars[$charIndex]['char'] === ' ') {
$charIndex++;
} else {
break;
}
}
if ($charIndex < count($chars)) {
$codes = $chars[$charIndex]['codes'];
if ($codes !== $lastCodes) {
if ($lastCodes !== '') {
$line .= "\e[0m";
}
if ($codes !== '') {
$line .= $codes;
}
$lastCodes = $codes;
}
$line .= $lineChar;
$charIndex++;
} else {
$line .= $lineChar;
}
}
// Close any open ANSI codes
if ($lastCodes !== '' && ! str_ends_with($line, "\e[0m")) {
$line .= "\e[0m";
}
$result[] = $line;
}
return $result;
}
/**
* Parse text into segments with their associated ANSI codes.
*
* @return array<int, array{text: string, codes: string}>
*/
protected function parseAnsiText(string $text): array
{
$segments = [];
$currentCodes = '';
$currentText = '';
$i = 0;
$textLength = strlen($text);
while ($i < $textLength) {
if ($text[$i] === "\e" && ($i + 1 < $textLength) && $text[$i + 1] === '[') {
// Save current segment if it has text
if ($currentText !== '') {
$segments[] = ['text' => $currentText, 'codes' => $currentCodes];
$currentText = '';
}
// Extract ANSI escape sequence
$escapeSequence = '';
while ($i < $textLength) {
$escapeSequence .= $text[$i];
$i++;
if (preg_match('/^\\e\\[[0-9;]*m$/', $escapeSequence)) {
// Update current codes
if ($escapeSequence === "\e[0m") {
$currentCodes = '';
} else {
$currentCodes = $escapeSequence;
}
break;
}
}
continue;
}
$currentText .= $text[$i];
$i++;
}
// Add final segment
if ($currentText !== '') {
$segments[] = ['text' => $currentText, 'codes' => $currentCodes];
}
return $segments;
}
}

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,472 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\DataTablePrompt;
use Laravel\Prompts\Themes\Contracts\Scrolling;
use Laravel\Prompts\Themes\Default\Concerns\DrawsBoxes;
use Laravel\Prompts\Themes\Default\Concerns\DrawsScrollbars;
class DataTableRenderer extends Renderer implements Scrolling
{
use DrawsBoxes;
use DrawsScrollbars;
/**
* Render the data table.
*/
public function __invoke(DataTablePrompt $prompt): string
{
$maxWidth = $prompt->terminal()->cols() - 6;
return match ($prompt->state) {
'submit' => $this->renderSubmit($prompt, $maxWidth),
'cancel' => $this->renderCancel($prompt, $maxWidth),
default => $this->renderActive($prompt, $maxWidth),
};
}
/**
* Render the submit state.
*/
protected function renderSubmit(DataTablePrompt $prompt, int $maxWidth): string
{
$row = $prompt->selectedRow();
$display = $row ? $this->truncate(implode(', ', $row), $maxWidth) : '';
return $this
->box(
$this->dim($this->truncate($prompt->label, $maxWidth)),
$display,
);
}
/**
* Render the cancel state.
*/
protected function renderCancel(DataTablePrompt $prompt, int $maxWidth): string
{
$filtered = $prompt->filteredRows();
$visible = $prompt->visible();
$numCols = ! empty($prompt->headers)
? count($prompt->headers)
: max(array_map('count', $prompt->rows));
$widths = $this->computeColumnWidths($prompt->headers, $prompt->rows, $numCols, $maxWidth);
$innerWidth = array_sum($widths) + ($numCols * 2) + ($numCols - 1) + 2;
// Top border (red)
$titleText = $this->truncate($prompt->label, $maxWidth);
$titleLength = mb_strwidth($this->stripEscapeSequences($titleText));
$topBorderFill = max(0, $innerWidth - $titleLength - 2);
$this->line($this->red(' ┌')." {$titleText} ".$this->red(str_repeat('─', $topBorderFill).'┐'));
// Search line (dimmed, to prevent layout shift)
$searchContent = $this->renderSearchLine($prompt, $innerWidth - 2);
$this->line($this->red(' │').' '.$this->dim($this->pad($searchContent, $innerWidth - 2)).' '.$this->red('│'));
// Column separator
$this->line(' '.$this->renderBorder('├', '┬', '┤', $widths, 'red'));
// Header cells (strikethrough + dim)
if (! empty($prompt->headers)) {
$headerCells = [];
foreach ($widths as $i => $w) {
$header = $prompt->headers[$i] ?? '';
$text = is_array($header) ? implode(' ', $header) : $header;
$headerCells[] = $this->dim(' '.$this->pad($this->strikethrough($this->truncate($text, $w)), $w).' ');
}
$headerLine = implode($this->red('│'), $headerCells).' ';
$this->line($this->red(' │').$this->pad($headerLine, $innerWidth).$this->red('│'));
$this->line(' '.$this->renderBorder('├', '┼', '┤', $widths, 'red'));
}
// Data rows (strikethrough + dim)
$dataLines = $this->renderDataRows($prompt, $filtered, $visible, $widths, $numCols, $innerWidth, strikethrough: true);
foreach ($dataLines as $dataLine) {
$this->line($this->red(' │').$this->pad($dataLine, $innerWidth).$this->red('│'));
}
// Bottom border (red)
$this->line(' '.$this->renderBorder('└', '┴', '┘', $widths, 'red'));
return $this->error($prompt->cancelMessage);
}
/**
* Render the active/browse/search state.
*/
protected function renderActive(DataTablePrompt $prompt, int $maxWidth): string
{
$filtered = $prompt->filteredRows();
$total = count($filtered);
$visible = $prompt->visible();
$numCols = ! empty($prompt->headers)
? count($prompt->headers)
: max(array_map('count', $prompt->rows));
// Compute column widths from ALL rows (not filtered) to prevent layout shift when searching
$widths = $this->computeColumnWidths($prompt->headers, $prompt->rows, $numCols, $maxWidth);
// Inner width between the outer │ chars:
// cells (sum of w+2 padding each) + separators (numCols-1) + 2 (scrollbar area)
$innerWidth = array_sum($widths) + ($numCols * 2) + ($numCols - 1) + 2;
// Top border: ┌ Title ───┐
$titleText = $this->cyan($this->truncate($prompt->label, $maxWidth));
$titleLength = mb_strwidth($this->stripEscapeSequences($titleText));
$topBorderFill = max(0, $innerWidth - $titleLength - 2);
$this->line($this->gray(' ┌')." {$titleText} ".$this->gray(str_repeat('─', $topBorderFill).'┐'));
// Search line: │ / Search │
$searchContent = $this->renderSearchLine($prompt, $innerWidth - 2);
$this->line($this->gray(' │').' '.$this->pad($searchContent, $innerWidth - 2).' '.$this->gray('│'));
if ($total === 0) {
// No results: simple box without column separators
$this->line(' '.$this->renderSimpleBorder('├', '┤', $innerWidth));
$message = $prompt->searchValue() !== '' ? 'No results found.' : 'No rows.';
$emptyLine = $this->pad(' '.$this->dim($message), $innerWidth);
$this->line($this->gray(' │').$this->pad($emptyLine, $innerWidth).$this->gray('│'));
$this->line(' '.$this->renderSimpleBorder('└', '┘', $innerWidth));
} else {
// Column separator: ├──────┬────────┤
$this->line(' '.$this->renderBorder('├', '┬', '┤', $widths));
// Header cells: │ Header │ Header │
if (! empty($prompt->headers)) {
$headerCells = [];
foreach ($widths as $i => $w) {
$header = $prompt->headers[$i] ?? '';
$text = is_array($header) ? implode(' ', $header) : $header;
$headerCells[] = $this->dim(' '.$this->pad($this->truncate($text, $w), $w).' ');
}
$headerLine = implode($this->gray('│'), $headerCells).' ';
$this->line($this->gray(' │').$this->pad($headerLine, $innerWidth).$this->gray('│'));
// Header separator: ├──────┼────────┤
$this->line(' '.$this->renderBorder('├', '┼', '┤', $widths));
}
// Data rows
$dataLines = $this->renderDataRows($prompt, $filtered, $visible, $widths, $numCols, $innerWidth);
foreach ($dataLines as $dataLine) {
$this->line($this->gray(' │').$this->pad($dataLine, $innerWidth).$this->gray('│'));
}
// Bottom border: └──────┴────────┘
$this->line(' '.$this->renderBorder('└', '┴', '┘', $widths));
// Info line below the box (only when not all rows are visible)
if ($total > $prompt->scroll) {
$firstRow = $prompt->firstVisible + 1;
$lastRow = min($prompt->firstVisible + $prompt->scroll, $total);
$suffix = $prompt->searchValue() !== '' ? ' results' : '';
$info = $this->dim(' Viewing ').$firstRow.'-'.$lastRow.$this->dim(' of ').$total.$suffix;
$this->line($info);
}
}
return $this
->when(
$prompt->state === 'error',
fn () => $this->warning($this->truncate($prompt->error, $prompt->terminal()->cols() - 5)),
fn () => $this->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine(),
),
);
}
/**
* Render a column-aware border line.
*
* @param array<int, int> $widths
*/
protected function renderBorder(string $left, string $mid, string $right, array $widths, string $color = 'gray'): string
{
$segments = array_map(fn ($w) => str_repeat('─', $w + 2), $widths);
return $this->{$color}($left.implode($mid, $segments).'──'.$right);
}
/**
* Render a simple border line without column separators.
*/
protected function renderSimpleBorder(string $left, string $right, int $innerWidth, string $color = 'gray'): string
{
return $this->{$color}($left.str_repeat('─', $innerWidth).$right);
}
/**
* Render the search line content.
*/
protected function renderSearchLine(DataTablePrompt $prompt, int $maxWidth): string
{
if ($prompt->state === 'search') {
return $this->cyan('/').' '.$prompt->searchWithCursor($maxWidth - 4);
}
if ($prompt->searchValue() !== '') {
return $this->dim('/').' '.$prompt->searchValue();
}
return $this->dim('/ Search');
}
/**
* Render data rows with scrollbar support.
*
* @param array<int|string, array<int, string>> $filtered
* @param array<int|string, array<int, string>> $visible
* @param array<int, int> $widths
* @return array<int, string>
*/
protected function renderDataRows(DataTablePrompt $prompt, array $filtered, array $visible, array $widths, int $numCols, int $innerWidth, bool $strikethrough = false): array
{
$total = count($filtered);
// Build an empty row template for padding
$emptyRow = implode($this->gray('│'), array_map(
fn ($w) => str_repeat(' ', $w + 2),
$widths,
)).' ';
$highlightedKey = array_keys($filtered)[$prompt->highlighted] ?? null;
$isSearching = $prompt->state === 'search';
$fixedHeight = $prompt->scroll;
// Render all visible logical rows into visual lines, tracking which
// logical row each visual line belongs to so we can clip intelligently.
$taggedLines = [];
foreach ($visible as $key => $row) {
$isHighlighted = ! $isSearching && ! $strikethrough && $key === $highlightedKey;
// Split each cell by newlines
$cellLines = [];
$maxSubRows = 1;
foreach ($widths as $i => $w) {
$text = $row[$i] ?? '';
$subLines = explode(PHP_EOL, $text);
$cellLines[$i] = $subLines;
$maxSubRows = max($maxSubRows, count($subLines));
}
// Render each sub-row
for ($subRow = 0; $subRow < $maxSubRows; $subRow++) {
$cells = [];
foreach ($widths as $i => $w) {
$text = $cellLines[$i][$subRow] ?? '';
$content = ' '.$this->pad($this->truncate($text, $w), $w).' ';
if ($strikethrough) {
$content = ' '.$this->pad($this->dim($this->strikethrough($this->truncate($text, $w))), $w).' ';
} elseif ($isHighlighted) {
$content = $this->inverse($content);
} elseif ($isSearching) {
$content = $this->dim($content);
}
$cells[] = $content;
}
$separator = $isHighlighted ? $this->inverse('│') : $this->gray('│');
$taggedLines[] = [
'line' => implode($separator, $cells).' ',
'highlighted' => $isHighlighted,
];
}
}
// Fixed visual height: always exactly `scroll` lines.
// The highlighted row must be fully visible. If multiline rows cause
// overflow, clip partial rows at the top or bottom edge.
$totalVisual = count($taggedLines);
if ($totalVisual <= $fixedHeight) {
$dataLines = array_column($taggedLines, 'line');
} else {
// Find the highlighted row's visual line range
$hlStart = null;
$hlEnd = null;
foreach ($taggedLines as $i => $tagged) {
if ($tagged['highlighted']) {
$hlStart ??= $i;
$hlEnd = $i;
}
}
// Pick a window of fixedHeight lines that includes the full highlighted row.
// Prefer keeping the highlighted row near the bottom (natural scroll feel).
if ($hlStart !== null) {
$startLine = max(0, $hlEnd - $fixedHeight + 1);
$startLine = min($startLine, $hlStart);
} else {
$startLine = 0;
}
$startLine = min($startLine, $totalVisual - $fixedHeight);
$startLine = max(0, $startLine);
$dataLines = array_column(array_slice($taggedLines, $startLine, $fixedHeight), 'line');
}
while (count($dataLines) < $fixedHeight) {
$dataLines[] = $emptyRow;
}
// Apply scrollbar to data lines.
// We can't use the trait's scrollbar() directly because it compares visual
// line count against logical row count — multiline rows inflate visual lines
// beyond $total, causing the scrollbar to disappear. Instead, determine
// scrollability from logical counts and map the indicator to visual space.
$shouldScroll = $total > $prompt->scroll;
if ($shouldScroll) {
$numVisual = count($dataLines);
$maxFirst = $total - $prompt->scroll;
if ($prompt->firstVisible === 0) {
$visualPos = 0;
} elseif ($prompt->firstVisible >= $maxFirst) {
$visualPos = $numVisual - 1;
} elseif ($numVisual <= 2) {
$visualPos = -1;
} else {
$percent = $prompt->firstVisible / $maxFirst;
$visualPos = (int) round($percent * ($numVisual - 3)) + 1;
}
$dataLines = array_map(fn ($line, $index) => match ($index) {
$visualPos => preg_replace('/.$/', $this->cyan('┃'), $this->pad($line, $innerWidth)) ?? '',
default => preg_replace('/.$/', $this->gray('│'), $this->pad($line, $innerWidth)) ?? '',
}, array_values($dataLines), range(0, $numVisual - 1));
}
return $dataLines;
}
/**
* Compute column widths that fit within maxWidth.
*
* Columns get their natural (P85) width. Only shrink proportionally
* if the total exceeds available terminal space.
*
* @param array<int, string|array<int, string>> $headers
* @param array<int|string, array<int, string>> $allRows
* @return array<int, int>
*/
protected function computeColumnWidths(array $headers, array $allRows, int $numCols, int $maxWidth): array
{
// Header widths serve as the floor for each column
$headerWidths = array_fill(0, $numCols, 0);
foreach ($headers as $i => $header) {
$headerText = is_array($header) ? implode(' ', $header) : $header;
$headerWidths[$i] = mb_strwidth($headerText);
}
// Collect all cell widths per column (excluding blank cells)
$columnWidths = array_fill(0, $numCols, []);
foreach ($allRows as $row) {
foreach ($row as $i => $cell) {
$cellMax = 0;
foreach (explode(PHP_EOL, $cell) as $line) {
$cellMax = max($cellMax, mb_strwidth($line));
}
if ($cellMax > 0) {
$columnWidths[$i][] = $cellMax;
}
}
}
// Per-column width strategy:
// - Uniform columns (max <= P90 * 2): use max — all values are reasonable
// - Outlier columns (max > P90 * 2): use P90 — ignore extreme values
$natural = array_fill(0, $numCols, 0);
foreach ($columnWidths as $i => $widths) {
if (empty($widths)) {
$natural[$i] = $headerWidths[$i];
continue;
}
sort($widths);
$p90Index = (int) ceil(count($widths) * 0.90) - 1;
$p90 = $widths[max(0, $p90Index)];
$colMax = end($widths);
$natural[$i] = max($headerWidths[$i], $colMax <= $p90 * 2 ? $colMax : $p90);
}
// Available width for cell content:
// Each column has 1 space padding on each side = 2 per column
// Columns separated by │ = numCols - 1 separators
// Scrollbar area = 2 chars on the right
// Outer frame = 4 chars (` │` left + ` │` right)
$overhead = ($numCols * 2) + ($numCols - 1) + 2 + 4;
$available = $maxWidth - $overhead;
if ($available <= 0) {
return array_fill(0, $numCols, 1);
}
$totalNatural = array_sum($natural);
// If natural widths fit, use them directly (comfortable width)
if ($totalNatural <= $available) {
return $natural;
}
// Otherwise, shrink proportionally
$widths = array_fill(0, $numCols, 0);
foreach ($natural as $i => $w) {
$widths[$i] = max($headerWidths[$i], (int) floor($available * $w / $totalNatural));
}
// Distribute any remaining pixels from rounding
$remainder = $available - array_sum($widths);
if ($remainder > 0) {
$order = range(0, $numCols - 1);
usort($order, fn ($a, $b) => $natural[$b] <=> $natural[$a]);
foreach ($order as $i) {
if ($remainder <= 0) {
break;
}
$widths[$i]++;
$remainder--;
}
}
return $widths;
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 10;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Grid;
use Laravel\Prompts\Output\BufferedConsoleOutput;
use Symfony\Component\Console\Helper\Table as SymfonyTable;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Helper\TableStyle;
class GridRenderer extends Renderer
{
use Concerns\InteractsWithStrings;
protected int $minWidth = 60;
/**
* Render the grid.
*/
public function __invoke(Grid $grid): string
{
if (empty($grid->items)) {
return $this;
}
$maxWidth = $grid->maxWidth - 2;
$cellWidth = max(array_map(fn ($item) => mb_strwidth($this->stripEscapeSequences($item)), $grid->items)) + 4;
$maxColumns = max(1, (int) floor(($maxWidth - 1) / ($cellWidth + 1)));
$columnCount = max(1, $this->balancedColumnCount(count($grid->items), $maxColumns));
$rows = $this->buildRowsWithSeparators($grid->items, $columnCount);
$tableStyle = (new TableStyle)
->setHorizontalBorderChars('─')
->setVerticalBorderChars('│', '│')
->setCellRowFormat('<fg=default>%s</>')
->setCrossingChars('┼', '', '', '', '┤', '┘', '┴', '└', '├', '┌', '┬', '┐');
$buffered = new BufferedConsoleOutput;
(new SymfonyTable($buffered))
->setRows($rows)
->setStyle($tableStyle)
->render();
foreach (explode(PHP_EOL, trim($buffered->content(), PHP_EOL)) as $line) {
$this->line(' '.$line);
}
return $this;
}
/**
* Calculate a balanced column count for even row distribution.
*/
protected function balancedColumnCount(int $itemCount, int $maxColumns): int
{
if ($itemCount <= $maxColumns) {
return $itemCount;
}
for ($cols = $maxColumns; $cols >= 1; $cols--) {
$remainder = $itemCount % $cols;
if ($remainder === 0 || $remainder >= (int) ceil($cols / 2)) {
return $cols;
}
}
return $maxColumns;
}
/**
* Build rows with separators between them.
*
* @param array<int, string> $items
* @param int<1, max> $columnCount
* @return array<int, array<int, string>|TableSeparator>
*/
protected function buildRowsWithSeparators(array $items, int $columnCount): array
{
$chunks = array_chunk($items, $columnCount);
$rows = [];
foreach ($chunks as $index => $chunk) {
if ($index > 0) {
$rows[] = new TableSeparator;
}
$rows[] = array_pad($chunk, $columnCount, '');
}
return $rows;
}
}

View File

@@ -0,0 +1,180 @@
<?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 implode(PHP_EOL, $this->scrollbar(
array_map(function ($label, $key) use ($prompt) {
$label = $this->truncate($label, $prompt->terminal()->cols() - 12);
$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->visible(), array_keys($prompt->visible())),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->matches()),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6)
));
}
/**
* 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
{
$selected = count($prompt->value()).' selected';
$hiddenCount = count($prompt->value()) - count(array_filter(
$prompt->matches(),
fn ($label, $key) => in_array($prompt->isList() ? $label : $key, $prompt->value()),
ARRAY_FILTER_USE_BOTH
));
if ($hiddenCount > 0) {
$selected .= " ($hiddenCount hidden)";
}
$parts = array_filter([$prompt->infoText(), $selected]);
return implode(' · ', $parts);
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 7;
}
}

View File

@@ -0,0 +1,133 @@
<?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: $this->getInfoText($prompt),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
),
};
}
/**
* Render the options.
*/
protected function renderOptions(MultiSelectPrompt $prompt): string
{
return implode(PHP_EOL, $this->scrollbar(
array_values(array_map(function ($label, $key) use ($prompt) {
$label = $this->truncate($label, $prompt->terminal()->cols() - 12);
$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)} ",
};
}, $visible = $prompt->visible(), array_keys($visible))),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->options),
min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
));
}
/**
* 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()
));
}
/**
* Render the info text.
*/
protected function getInfoText(MultiSelectPrompt $prompt): string
{
$parts = array_filter([
$prompt->infoText(),
count($prompt->options) > $prompt->scroll ? (count($prompt->value()).' selected') : '',
]);
return implode(' · ', $parts);
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 5;
}
}

View File

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

View File

@@ -0,0 +1,95 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\NumberPrompt;
class NumberPromptRenderer extends Renderer
{
use Concerns\DrawsBoxes;
protected string $upArrow = '▲';
protected string $downArrow = '▼';
/**
* Render the number prompt.
*/
public function __invoke(NumberPrompt $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((string) $prompt->value(), $maxWidth),
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->strikethrough($this->dim($this->truncate((string) $prompt->value() ?: $prompt->placeholder, $maxWidth))),
color: 'red',
)
->error('Cancelled.'),
'error' => $this
->box(
$this->truncate($prompt->label, $prompt->terminal()->cols() - 6),
$this->withArrows($prompt, $prompt->valueWithCursor($maxWidth), 'yellow'),
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->withArrows($prompt, $prompt->valueWithCursor($maxWidth)),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
)
};
}
protected function withArrows(NumberPrompt $prompt, int|string $value, ?string $color = null): string
{
$arrows = $this->getArrows($prompt, $color);
$valueLength = mb_strwidth($this->stripEscapeSequences((string) $value));
$padding = $this->minWidth - $valueLength - mb_strwidth($this->stripEscapeSequences($arrows));
return $value.str_repeat(' ', $padding).$arrows;
}
protected function getArrows(NumberPrompt $prompt, ?string $color = null): string
{
$upArrow = $this->upArrow;
$downArrow = $this->downArrow;
if ($color) {
$upArrow = $this->{$color}($upArrow);
$downArrow = $this->{$color}($downArrow);
}
if (is_numeric($prompt->value())) {
if ((int) $prompt->value() === $prompt->min) {
$downArrow = $this->dim($downArrow);
}
if ((int) $prompt->value() === $prompt->max) {
$upArrow = $this->dim($upArrow);
}
return $upArrow.$downArrow;
}
if ($prompt->value() === '') {
return $upArrow.$downArrow;
}
return $this->dim($upArrow).$this->dim($downArrow);
}
}

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,26 @@
<?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
{
$lines = explode(PHP_EOL, $prompt->message);
$color = $prompt->state === 'submit' ? 'green' : 'gray';
foreach ($lines as $line) {
$this->line(" {$this->{$color}($line)}");
}
return $this;
}
}

View File

@@ -0,0 +1,71 @@
<?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: $this->fractionCompleted($progress),
),
'error' => $this
->box(
$this->truncate($progress->label, $progress->terminal()->cols() - 6),
$this->dim($filled),
color: 'red',
info: $this->fractionCompleted($progress),
),
'cancel' => $this
->box(
$this->truncate($progress->label, $progress->terminal()->cols() - 6),
$this->dim($filled),
color: 'red',
info: $this->fractionCompleted($progress),
)
->error($progress->cancelMessage),
default => $this
->box(
$this->cyan($this->truncate($progress->label, $progress->terminal()->cols() - 6)),
$this->dim($filled),
info: $this->fractionCompleted($progress),
)
->when(
$progress->hint,
fn () => $this->hint($progress->hint),
fn () => $this->newLine() // Space for errors
)
};
}
/**
* @param Progress<int|iterable<mixed>> $progress
*/
protected function fractionCompleted(Progress $progress): string
{
return number_format($progress->progress).' / '.number_format($progress->total);
}
}

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,135 @@
<?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),
info: $prompt->infoText(),
)
->hint($prompt->hint),
default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$prompt->valueWithCursor($maxWidth),
$this->renderOptions($prompt),
info: $prompt->infoText(),
)
->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 implode(PHP_EOL, $this->scrollbar(
array_values(array_map(function ($label, $key) use ($prompt) {
$label = $this->truncate($label, $prompt->terminal()->cols() - 10);
$index = array_search($key, array_keys($prompt->matches()));
return $prompt->highlighted === $index
? "{$this->cyan('')} {$label} "
: " {$this->dim($label)} ";
}, $visible = $prompt->visible(), array_keys($visible))),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->matches()),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6)
));
}
/**
* 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),
info: $prompt->infoText(),
)
->when(
$prompt->hint,
fn () => $this->hint($prompt->hint),
fn () => $this->newLine() // Space for errors
),
};
}
/**
* Render the options.
*/
protected function renderOptions(SelectPrompt $prompt): string
{
return implode(PHP_EOL, $this->scrollbar(
array_values(array_map(function ($label, $key) use ($prompt) {
$label = $this->truncate($label, $prompt->terminal()->cols() - 12);
$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)} ";
}, $visible = $prompt->visible(), array_keys($visible))),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->options),
min($this->longest($prompt->options, padding: 6), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
));
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 5;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Concerns\HasSpinner;
use Laravel\Prompts\Spinner;
class SpinnerRenderer extends Renderer
{
use HasSpinner;
/**
* 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;
return $this->line(" {$this->cyan($this->spinnerFrame($spinner->count))} {$spinner->message}");
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Stream;
class StreamRenderer extends Renderer
{
/**
* Render the stream.
*/
public function __invoke(Stream $stream): string
{
foreach ($stream->lines() as $line) {
$this->line(" {$line}");
}
return $this;
}
}

View File

@@ -0,0 +1,124 @@
<?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),
info: $prompt->infoText(),
)
->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 implode(PHP_EOL, $this->scrollbar(
array_map(function ($label, $key) use ($prompt) {
$label = $this->truncate($label, $prompt->terminal()->cols() - 12);
return $prompt->highlighted === $key
? "{$this->cyan('')} {$label} "
: " {$this->dim($label)} ";
}, $visible = $prompt->visible(), array_keys($visible)),
$prompt->firstVisible,
$prompt->scroll,
count($prompt->matches()),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6),
$prompt->state === 'cancel' ? 'dim' : 'cyan'
));
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 7;
}
}

View File

@@ -0,0 +1,43 @@
<?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();
foreach (explode(PHP_EOL, trim($buffered->content(), PHP_EOL)) as $line) {
$this->line(' '.$line);
}
return $this;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Concerns\HasSpinner;
use Laravel\Prompts\Task;
class TaskRenderer extends Renderer
{
use HasSpinner;
/**
* Render the task.
*/
public function __invoke(Task $task): string
{
$maxWidth = $task->terminal()->cols() - 6;
$labelMaxWidth = $maxWidth - 3;
$leadPadding = str_repeat(' ', 3);
$stableLineMaxWidth = $maxWidth - strlen($leadPadding) - 2; // symbol + space
if ($task->static) {
return $this->line(" {$this->cyan($this->staticFrame)} {$this->truncate($task->label, $maxWidth)}");
}
$task->interval = $this->interval;
$stableMessages = array_slice($task->stableMessages, -$task->maxStableMessages);
if ($task->finished && $task->keepSummary && count($stableMessages) > 0) {
$this->line(" {$this->cyan('•')} {$this->truncate($task->label, $labelMaxWidth)}");
foreach ($stableMessages as $stableMessage) {
$this->line($leadPadding.$this->stableMessageSymbol($stableMessage['type']).' '.$this->truncate($stableMessage['message'], $stableLineMaxWidth));
}
$this->newLine();
return $this;
}
$this->line(" {$this->cyan($this->spinnerFrame($task->count))} {$this->truncate($task->label, $labelMaxWidth)}");
if ($task->subLabel !== null && $task->subLabel !== '') {
$this->line($leadPadding.$this->dim($this->truncate($task->subLabel, $stableLineMaxWidth)));
}
foreach ($stableMessages as $stableMessage) {
$this->line($leadPadding.$this->stableMessageSymbol($stableMessage['type']).' '.$this->truncate($stableMessage['message'], $stableLineMaxWidth));
}
if (count($task->stableMessages) > 0 || count($task->logs) > 0) {
$this->line($this->gray(' '.str_repeat('─', $maxWidth)));
} else {
$this->newLine();
}
$logs = array_slice($task->logs, -$task->limit);
foreach ($logs as $log) {
$this->line(' '.$this->dim($log));
}
$remaining = $task->limit - count($task->logs);
while ($remaining > 0) {
$this->line('');
$remaining--;
}
return $this;
}
protected function stableMessageSymbol(string $type): string
{
return match ($type) {
'success' => $this->green('✔'),
'error' => $this->red('✘'),
'warning' => $this->yellow('⚠'),
default => '',
};
}
}

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)),
implode(PHP_EOL, $prompt->lines()),
),
'cancel' => $this
->box(
$this->truncate($prompt->label, $prompt->width),
implode(PHP_EOL, array_map(fn ($line) => $this->strikethrough($this->dim($line)), $prompt->lines())),
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 = $prompt->visible();
while (count($visible) < $prompt->scroll) {
$visible[] = '';
}
$longest = $this->longest($prompt->lines()) + 2;
return implode(PHP_EOL, $this->scrollbar(
$visible,
$prompt->firstVisible,
$prompt->scroll,
count($prompt->lines()),
min($longest, $prompt->width + 2),
));
}
/**
* The number of lines to reserve outside of the scrollable area.
*/
public function reservedLines(): int
{
return 5;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Laravel\Prompts\Themes\Default;
use Laravel\Prompts\Title;
class TitleRenderer extends Renderer
{
/**
* Render the title.
*/
public function __invoke(Title $title): string
{
return "\033]0;{$title->title}\007";
}
}

37
vendor/laravel/prompts/src/Title.php vendored Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace Laravel\Prompts;
class Title extends Prompt
{
public function __construct(public string $title)
{
//
}
/**
* Update the title of the terminal.
*/
public function prompt(): bool
{
$this->writeDirectly($this->renderTheme());
return true;
}
/**
* Update the title of the terminal.
*/
public function display(): void
{
$this->prompt();
}
/**
* Get the value of the prompt.
*/
public function value(): bool
{
return true;
}
}

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

@@ -0,0 +1,463 @@
<?php
namespace Laravel\Prompts;
use Closure;
use Illuminate\Support\Collection;
if (! function_exists('\Laravel\Prompts\text')) {
/**
* Prompt the user for text input.
*/
function text(
string $label,
string $placeholder = '',
string $default = '',
bool|string $required = false,
mixed $validate = null,
string $hint = '',
?Closure $transform = null,
): string {
return (new TextPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\autocomplete')) {
/**
* Prompt the user for text input with auto-completion.
*
* @param array<string>|Collection<int, string>|Closure(string): (array<string>|Collection<int, string>) $options
*/
function autocomplete(
string $label,
array|Collection|Closure $options = [],
string $placeholder = '',
string $default = '',
bool|string $required = false,
mixed $validate = null,
string $hint = '',
?Closure $transform = null,
): string {
return (new AutoCompletePrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\number')) {
/**
* Prompt the user for number input.
*/
function number(string $label, string $placeholder = '', string $default = '', bool|string $required = false, mixed $validate = null, string $hint = '', ?int $min = null, ?int $max = null, ?int $step = null): int|string
{
return (new NumberPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\textarea')) {
/**
* Prompt the user for multiline text input.
*/
function textarea(
string $label,
string $placeholder = '',
string $default = '',
bool|string $required = false,
mixed $validate = null,
string $hint = '',
int $rows = 5,
?Closure $transform = null,
): string {
return (new TextareaPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\password')) {
/**
* Prompt the user for input, hiding the value.
*/
function password(
string $label,
string $placeholder = '',
bool|string $required = false,
mixed $validate = null,
string $hint = '',
?Closure $transform = null,
): string {
return (new PasswordPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\select')) {
/**
* Prompt the user to select an option.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param true|string $required
*/
function select(
string $label,
array|Collection $options,
int|string|null $default = null,
int $scroll = 5,
mixed $validate = null,
string $hint = '',
bool|string $required = true,
?Closure $transform = null,
string|Closure $info = '',
): int|string {
return (new SelectPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\multiselect')) {
/**
* Prompt the user to select multiple options.
*
* @param array<int|string, string>|Collection<int|string, string> $options
* @param array<int|string>|Collection<int, int|string> $default
* @return array<int|string>
*/
function multiselect(
string $label,
array|Collection $options,
array|Collection $default = [],
int $scroll = 5,
bool|string $required = false,
mixed $validate = null,
string $hint = 'Use the space bar to select options.',
?Closure $transform = null,
string|Closure $info = '',
): array {
return (new MultiSelectPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\confirm')) {
/**
* Prompt the user to confirm an action.
*/
function confirm(
string $label,
bool $default = true,
string $yes = 'Yes',
string $no = 'No',
bool|string $required = false,
mixed $validate = null,
string $hint = '',
?Closure $transform = null,
): bool {
return (new ConfirmPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\pause')) {
/**
* Prompt the user to continue or cancel after pausing.
*/
function pause(string $message = 'Press enter to continue...'): bool
{
return (new PausePrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\clear')) {
/**
* Clear the terminal.
*/
function clear(): void
{
(new Clear)->display();
}
}
if (! function_exists('\Laravel\Prompts\suggest')) {
/**
* Prompt the user for text input with auto-completion.
*
* @param array<string>|Collection<int, string>|Closure(string): array<string> $options
*/
function suggest(
string $label,
array|Collection|Closure $options,
string $placeholder = '',
string $default = '',
int $scroll = 5,
bool|string $required = false,
mixed $validate = null,
string $hint = '',
?Closure $transform = null,
string|Closure $info = '',
): string {
return (new SuggestPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\search')) {
/**
* Allow the user to search for an option.
*
* @param Closure(string): array<int|string, string> $options
* @param true|string $required
*/
function search(
string $label,
Closure $options,
string $placeholder = '',
int $scroll = 5,
mixed $validate = null,
string $hint = '',
bool|string $required = true,
?Closure $transform = null,
string|Closure $info = '',
): int|string {
return (new SearchPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\multisearch')) {
/**
* Allow the user to search for multiple option.
*
* @param Closure(string): array<int|string, string> $options
* @return array<int|string>
*/
function multisearch(
string $label,
Closure $options,
string $placeholder = '',
int $scroll = 5,
bool|string $required = false,
mixed $validate = null,
string $hint = 'Use the space bar to select options.',
?Closure $transform = null,
string|Closure $info = '',
): array {
return (new MultiSearchPrompt(...get_defined_vars()))->prompt();
}
}
if (! function_exists('\Laravel\Prompts\spin')) {
/**
* Render a spinner while the given callback is executing.
*
* @template TReturn of mixed
*
* @param Closure(): TReturn $callback
* @return TReturn
*/
function spin(Closure $callback, string $message = ''): mixed
{
return (new Spinner($message))->spin($callback);
}
}
if (! function_exists('\Laravel\Prompts\note')) {
/**
* Display a note.
*/
function note(string $message, ?string $type = null): void
{
(new Note($message, $type))->display();
}
}
if (! function_exists('\Laravel\Prompts\error')) {
/**
* Display an error.
*/
function error(string $message): void
{
(new Note($message, 'error'))->display();
}
}
if (! function_exists('\Laravel\Prompts\warning')) {
/**
* Display a warning.
*/
function warning(string $message): void
{
(new Note($message, 'warning'))->display();
}
}
if (! function_exists('\Laravel\Prompts\alert')) {
/**
* Display an alert.
*/
function alert(string $message): void
{
(new Note($message, 'alert'))->display();
}
}
if (! function_exists('\Laravel\Prompts\info')) {
/**
* Display an informational message.
*/
function info(string $message): void
{
(new Note($message, 'info'))->display();
}
}
if (! function_exists('\Laravel\Prompts\intro')) {
/**
* Display an introduction.
*/
function intro(string $message): void
{
(new Note($message, 'intro'))->display();
}
}
if (! function_exists('\Laravel\Prompts\outro')) {
/**
* Display a closing message.
*/
function outro(string $message): void
{
(new Note($message, 'outro'))->display();
}
}
if (! function_exists('\Laravel\Prompts\notify')) {
/**
* Send a notification to the user. (macOS and Linux only)
*
* The icon option is Linux only. The subtitle and sound options are macOS only.
*
* @param string $subtitle macOS only
* @param string $sound macOS only
* @param string $icon Linux only
*/
function notify(string $title, string $body = '', string $subtitle = '', string $sound = '', string $icon = ''): void
{
(new NotifyPrompt(...get_defined_vars()))->display();
}
}
if (! function_exists('\Laravel\Prompts\table')) {
/**
* Display a table.
*
* @param array<int, string|array<int, string>>|Collection<int, string|array<int, string>> $headers
* @param array<int, array<int, string>>|Collection<int, array<int, string>> $rows
*/
function table(array|Collection $headers = [], array|Collection|null $rows = null): void
{
(new Table($headers, $rows))->display();
}
}
if (! function_exists('\Laravel\Prompts\grid')) {
/**
* Display a grid.
*
* @param array<int, string>|Collection<int, string> $items
*/
function grid(array|Collection $items = [], ?int $maxWidth = null): void
{
(new Grid($items, $maxWidth))->display();
}
}
if (! function_exists('\Laravel\Prompts\progress')) {
/**
* Display a progress bar.
*
* @template TSteps of iterable<mixed>|int
* @template TReturn
*
* @param TSteps $steps
* @param ?Closure((TSteps is int ? int : value-of<TSteps>), Progress<TSteps>): TReturn $callback
* @return ($callback is null ? Progress<TSteps> : array<TReturn>)
*/
function progress(
string $label,
iterable|int $steps,
?Closure $callback = null,
string $hint = '',
): array|Progress {
$progress = new Progress($label, $steps, $hint);
if ($callback !== null) {
return $progress->map($callback);
}
return $progress;
}
}
if (! function_exists('\Laravel\Prompts\form')) {
function form(): FormBuilder
{
return new FormBuilder;
}
}
if (! function_exists('\Laravel\Prompts\title')) {
/**
* Update the title of the terminal.
*/
function title(string $title): void
{
(new Title($title))->display();
}
}
if (! function_exists('\Laravel\Prompts\stream')) {
/**
* Display a stream of text.
*/
function stream(): Stream
{
return new Stream;
}
}
if (! function_exists('\Laravel\Prompts\task')) {
/**
* Display a task with a spinner and live output.
*
* @template TReturn of mixed
*
* @param Closure(Support\Logger): TReturn $callback
* @return TReturn
*/
function task(string $label, Closure $callback, ?int $limit = null, bool $keepSummary = false, ?string $subLabel = null): mixed
{
return (new Task($label, $limit ?? 10, $keepSummary, $subLabel))->run($callback);
}
}
if (! function_exists('\Laravel\Prompts\datatable')) {
/**
* Display an interactive data table.
*
* @param array<int, string|array<int, string>>|Collection<int, string|array<int, string>> $headers
* @param array<int|string, array<int, string>>|Collection<int|string, array<int, string>>|null $rows
*/
function datatable(
array|Collection $headers = [],
array|Collection|null $rows = null,
int $scroll = 10,
string $label = '',
string $hint = '',
bool|string $required = false,
mixed $validate = null,
?Closure $transform = null,
?Closure $filter = null,
): mixed {
return (new DataTablePrompt(
headers: $headers,
rows: $rows,
scroll: $scroll,
label: $label,
hint: $hint,
required: $required,
validate: $validate,
transform: $transform,
filter: $filter,
))->prompt();
}
}