glastree_on_gitea
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 : '');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user