This commit is contained in:
TiclemFR
2023-12-29 16:00:02 +01:00
parent 9d79d7c0c6
commit 884eb3011a
8361 changed files with 1160554 additions and 4 deletions

View File

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

View File

@@ -0,0 +1,63 @@
<?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);
}
}

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,91 @@
<?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);
foreach ($keys as $key) {
$mock->shouldReceive('read')->once()->andReturn($key);
}
static::$terminal = $mock;
self::setOutput(new BufferedConsoleOutput());
}
/**
* Assert that the output contains the given string.
*/
public static function assertOutputContains(string $string): void
{
Assert::assertStringContainsString($string, static::content());
}
/**
* Assert that the output doesn't contain the given string.
*/
public static function assertOutputDoesntContain(string $string): void
{
Assert::assertStringNotContainsString($string, static::content());
}
/**
* Assert that the stripped output contains the given string.
*/
public static function assertStrippedOutputContains(string $string): void
{
Assert::assertStringContainsString($string, static::strippedContent());
}
/**
* Assert that the stripped output doesn't contain the given string.
*/
public static function assertStrippedOutputDoesntContain(string $string): void
{
Assert::assertStringNotContainsString($string, static::strippedContent());
}
/**
* Get the buffered console output.
*/
public static function content(): string
{
if (! static::output() instanceof BufferedConsoleOutput) {
throw new RuntimeException('Prompt must be faked before accessing content.');
}
return static::output()->content();
}
/**
* Get the buffered console output, stripped of escape sequences.
*/
public static function strippedContent(): string
{
return preg_replace("/\e\[[0-9;?]*[A-Za-z]/", '', static::content());
}
}

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Themes\Contracts\Scrolling as ScrollingRenderer;
trait Scrolling
{
/**
* The number of items to display before scrolling.
*/
public int $scroll;
/**
* The index of the highlighted option.
*/
public ?int $highlighted;
/**
* The index of the first visible option.
*/
public int $firstVisible = 0;
/**
* Initialize scrolling.
*/
protected function initializeScrolling(?int $highlighted = null): void
{
$this->highlighted = $highlighted;
$this->reduceScrollingToFitTerminal();
}
/**
* Reduce the scroll property to fit the terminal height.
*/
protected function reduceScrollingToFitTerminal(): void
{
$reservedLines = ($renderer = $this->getRenderer()) instanceof ScrollingRenderer ? $renderer->reservedLines() : 0;
$this->scroll = 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,111 @@
<?php
namespace Laravel\Prompts\Concerns;
use InvalidArgumentException;
use Laravel\Prompts\ConfirmPrompt;
use Laravel\Prompts\MultiSearchPrompt;
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Note;
use Laravel\Prompts\PasswordPrompt;
use Laravel\Prompts\Progress;
use Laravel\Prompts\SearchPrompt;
use Laravel\Prompts\SelectPrompt;
use Laravel\Prompts\Spinner;
use Laravel\Prompts\SuggestPrompt;
use Laravel\Prompts\Table;
use Laravel\Prompts\TextPrompt;
use Laravel\Prompts\Themes\Default\ConfirmPromptRenderer;
use Laravel\Prompts\Themes\Default\MultiSearchPromptRenderer;
use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer;
use Laravel\Prompts\Themes\Default\NoteRenderer;
use Laravel\Prompts\Themes\Default\PasswordPromptRenderer;
use Laravel\Prompts\Themes\Default\ProgressRenderer;
use Laravel\Prompts\Themes\Default\SearchPromptRenderer;
use Laravel\Prompts\Themes\Default\SelectPromptRenderer;
use Laravel\Prompts\Themes\Default\SpinnerRenderer;
use Laravel\Prompts\Themes\Default\SuggestPromptRenderer;
use Laravel\Prompts\Themes\Default\TableRenderer;
use Laravel\Prompts\Themes\Default\TextPromptRenderer;
trait Themes
{
/**
* The name of the active theme.
*/
protected static string $theme = 'default';
/**
* The available themes.
*
* @var array<string, array<class-string<\Laravel\Prompts\Prompt>, class-string<object&callable>>>
*/
protected static array $themes = [
'default' => [
TextPrompt::class => TextPromptRenderer::class,
PasswordPrompt::class => PasswordPromptRenderer::class,
SelectPrompt::class => SelectPromptRenderer::class,
MultiSelectPrompt::class => MultiSelectPromptRenderer::class,
ConfirmPrompt::class => ConfirmPromptRenderer::class,
SearchPrompt::class => SearchPromptRenderer::class,
MultiSearchPrompt::class => MultiSearchPromptRenderer::class,
SuggestPrompt::class => SuggestPromptRenderer::class,
Spinner::class => SpinnerRenderer::class,
Note::class => NoteRenderer::class,
Table::class => TableRenderer::class,
Progress::class => ProgressRenderer::class,
],
];
/**
* Get or set the active theme.
*
* @throws \InvalidArgumentException
*/
public static function theme(?string $name = null): string
{
if ($name === null) {
return static::$theme;
}
if (! isset(static::$themes[$name])) {
throw new InvalidArgumentException("Prompt theme [{$name}] not found.");
}
return static::$theme = $name;
}
/**
* Add a new theme.
*
* @param array<class-string<\Laravel\Prompts\Prompt>, class-string<object&callable>> $renderers
*/
public static function addTheme(string $name, array $renderers): void
{
if ($name === 'default') {
throw new InvalidArgumentException('The default theme cannot be overridden.');
}
static::$themes[$name] = $renderers;
}
/**
* Get the renderer for the current prompt.
*/
protected function getRenderer(): callable
{
$class = get_class($this);
return new (static::$themes[static::$theme][$class] ?? static::$themes['default'][$class])($this);
}
/**
* Render the prompt using the active theme.
*/
protected function renderTheme(): string
{
$renderer = $this->getRenderer();
return $renderer($this);
}
}

View File

@@ -0,0 +1,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,120 @@
<?php
namespace Laravel\Prompts\Concerns;
use Laravel\Prompts\Key;
trait TypedValue
{
/**
* The value that has been typed.
*/
protected string $typedValue = '';
/**
* The position of the virtual cursor.
*/
protected int $cursorPosition = 0;
/**
* Track the value as the user types.
*/
protected function trackTypedValue(string $default = '', bool $submit = true, ?callable $ignore = null): void
{
$this->typedValue = $default;
if ($this->typedValue) {
$this->cursorPosition = mb_strlen($this->typedValue);
}
$this->on('key', function ($key) use ($submit, $ignore) {
if ($key[0] === "\e" || in_array($key, [Key::CTRL_B, Key::CTRL_F, Key::CTRL_A, Key::CTRL_E])) {
if ($ignore !== null && $ignore($key)) {
return;
}
match ($key) {
Key::LEFT, Key::LEFT_ARROW, Key::CTRL_B => $this->cursorPosition = max(0, $this->cursorPosition - 1),
Key::RIGHT, Key::RIGHT_ARROW, Key::CTRL_F => $this->cursorPosition = min(mb_strlen($this->typedValue), $this->cursorPosition + 1),
Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->cursorPosition = 0,
Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->cursorPosition = mb_strlen($this->typedValue),
Key::DELETE => $this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).mb_substr($this->typedValue, $this->cursorPosition + 1),
default => null,
};
return;
}
// Keys may be buffered.
foreach (mb_str_split($key) as $key) {
if ($ignore !== null && $ignore($key)) {
return;
}
if ($key === Key::ENTER && $submit) {
$this->submit();
return;
} elseif ($key === Key::BACKSPACE || $key === Key::CTRL_H) {
if ($this->cursorPosition === 0) {
return;
}
$this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition - 1).mb_substr($this->typedValue, $this->cursorPosition);
$this->cursorPosition--;
} elseif (ord($key) >= 32) {
$this->typedValue = mb_substr($this->typedValue, 0, $this->cursorPosition).$key.mb_substr($this->typedValue, $this->cursorPosition);
$this->cursorPosition++;
}
}
});
}
/**
* Get the value of the prompt.
*/
public function value(): string
{
return $this->typedValue;
}
/**
* Add a virtual cursor to the value and truncate if necessary.
*/
protected function addCursor(string $value, int $cursorPosition, int $maxWidth): string
{
$before = mb_substr($value, 0, $cursorPosition);
$current = mb_substr($value, $cursorPosition, 1);
$after = mb_substr($value, $cursorPosition + 1);
$cursor = mb_strlen($current) ? $current : ' ';
$spaceBefore = $maxWidth - mb_strwidth($cursor) - (mb_strwidth($after) > 0 ? 1 : 0);
[$truncatedBefore, $wasTruncatedBefore] = mb_strwidth($before) > $spaceBefore
? [$this->trimWidthBackwards($before, 0, $spaceBefore - 1), true]
: [$before, false];
$spaceAfter = $maxWidth - ($wasTruncatedBefore ? 1 : 0) - mb_strwidth($truncatedBefore) - mb_strwidth($cursor);
[$truncatedAfter, $wasTruncatedAfter] = mb_strwidth($after) > $spaceAfter
? [mb_strimwidth($after, 0, $spaceAfter - 1), true]
: [$after, false];
return ($wasTruncatedBefore ? $this->dim('…') : '')
.$truncatedBefore
.$this->inverse($cursor)
.$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)));
}
}