Skip to content

Commit

Permalink
Merge branch 'search'
Browse files Browse the repository at this point in the history
  • Loading branch information
jessarcher committed Jul 25, 2023
2 parents 0e77329 + 745bf8b commit 1785618
Show file tree
Hide file tree
Showing 6 changed files with 418 additions and 0 deletions.
37 changes: 37 additions & 0 deletions playground/search.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use function Laravel\Prompts\search;

require __DIR__.'/../vendor/autoload.php';

$model = search(
label: 'Which user should receive the email?',
placeholder: 'Search...',
options: function ($value) {
if (strlen($value) === 0) {
return [];
}

usleep(100 * 1000);

$count = max(0, 10 - strlen($value));

if ($count === 0) {
return [];
}

return array_map(
fn ($id) => "User $id",
range(0, $count)
);
},
validate: function ($value) {
if ($value === '0') {
return 'User 0 is not allowed to receive emails.';
}
}
);

var_dump($model);

echo str_repeat(PHP_EOL, 6);
3 changes: 3 additions & 0 deletions src/Concerns/Themes.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Note;
use Laravel\Prompts\PasswordPrompt;
use Laravel\Prompts\SearchPrompt;
use Laravel\Prompts\SelectPrompt;
use Laravel\Prompts\Spinner;
use Laravel\Prompts\SuggestPrompt;
Expand All @@ -15,6 +16,7 @@
use Laravel\Prompts\Themes\Default\MultiSelectPromptRenderer;
use Laravel\Prompts\Themes\Default\NoteRenderer;
use Laravel\Prompts\Themes\Default\PasswordPromptRenderer;
use Laravel\Prompts\Themes\Default\SearchPromptRenderer;
use Laravel\Prompts\Themes\Default\SelectPromptRenderer;
use Laravel\Prompts\Themes\Default\SpinnerRenderer;
use Laravel\Prompts\Themes\Default\SuggestPromptRenderer;
Expand All @@ -39,6 +41,7 @@ trait Themes
SelectPrompt::class => SelectPromptRenderer::class,
MultiSelectPrompt::class => MultiSelectPromptRenderer::class,
ConfirmPrompt::class => ConfirmPromptRenderer::class,
SearchPrompt::class => SearchPromptRenderer::class,
SuggestPrompt::class => SuggestPromptRenderer::class,
Spinner::class => SpinnerRenderer::class,
Note::class => NoteRenderer::class,
Expand Down
153 changes: 153 additions & 0 deletions src/SearchPrompt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Laravel\Prompts;

use Closure;
use InvalidArgumentException;

class SearchPrompt extends Prompt
{
use Concerns\TypedValue;

/**
* The index of the highlighted option.
*/
public int|null $highlighted = null;

/**
* The cached matches.
*
* @var array<int|string, string>|null
*/
protected array|null $matches = null;

/**
* Create a new SuggestPrompt instance.
*
* @param Closure(string): array<int|string, string> $options
*/
public function __construct(
public string $label,
public Closure $options,
public string $placeholder = '',
public int $scroll = 5,
public ?Closure $validate = null,
) {
$this->trackTypedValue(submit: false);

$this->on('key', fn ($key) => match ($key) {
Key::UP, Key::SHIFT_TAB => $this->highlightPrevious(),
Key::DOWN, Key::TAB => $this->highlightNext(),
Key::ENTER => $this->highlighted !== null ? $this->submit() : $this->search(),
Key::LEFT, Key::RIGHT => $this->highlighted = null,
default => $this->search(),
});
}

protected function search(): void
{
$this->state = 'searching';
$this->highlighted = null;
$this->render();
$this->matches = null;
$this->state = 'active';
}

/**
* Get the entered value with a virtual cursor.
*/
public function valueWithCursor(int $maxWidth): string
{
if ($this->highlighted !== null) {
return $this->typedValue === ''
? $this->dim($this->truncate($this->placeholder, $maxWidth))
: $this->truncate($this->typedValue, $maxWidth);
}

if ($this->typedValue === '') {
return $this->dim($this->addCursor($this->placeholder, 0, $maxWidth));
}

return $this->addCursor($this->typedValue, $this->cursorPosition, $maxWidth);
}

/**
* Get options that match the input.
*
* @return array<string>
*/
public function matches(): array
{
if (is_array($this->matches)) {
return $this->matches;
}

return $this->matches = ($this->options)($this->typedValue);
}

/**
* Highlight the previous entry, or wrap around to the last entry.
*/
protected function highlightPrevious(): void
{
if ($this->matches === []) {
$this->highlighted = null;
} elseif ($this->highlighted === null) {
$this->highlighted = count($this->matches) - 1;
} elseif ($this->highlighted === 0) {
$this->highlighted = null;
} else {
$this->highlighted = $this->highlighted - 1;
}
}

/**
* Highlight the next entry, or wrap around to the first entry.
*/
protected function highlightNext(): void
{
if ($this->matches === []) {
$this->highlighted = null;
} elseif ($this->highlighted === null) {
$this->highlighted = 0;
} else {
$this->highlighted = $this->highlighted === count($this->matches) - 1 ? null : $this->highlighted + 1;
}
}

public function searchValue(): string
{
return $this->typedValue;
}

public function value(): int|string|null
{
if ($this->matches === null || $this->highlighted === null) {
return null;
}

return array_is_list($this->matches)
? $this->matches[$this->highlighted]
: array_keys($this->matches)[$this->highlighted];
}

/**
* Get the selected label.
*/
public function label(): string
{
return $this->matches[array_keys($this->matches)[$this->highlighted]] ?? null;
}

/**
* Truncate a value with an ellipsis if it exceeds the given length.
*/
protected function truncate(string $value, int $length): string
{
if ($length <= 0) {
throw new InvalidArgumentException("Length [{$length}] must be greater than zero.");
}

return mb_strlen($value) <= $length ? $value : (mb_substr($value, 0, $length - 1).'');
}
}
116 changes: 116 additions & 0 deletions src/Themes/Default/SearchPromptRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace Laravel\Prompts\Themes\Default;

use Laravel\Prompts\SearchPrompt;

class SearchPromptRenderer extends Renderer
{
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('Cancelled'),

'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),
),

default => $this
->box(
$this->cyan($this->truncate($prompt->label, $prompt->terminal()->cols() - 6)),
$prompt->valueWithCursor($maxWidth),
$this->renderOptions($prompt),
)
->spaceForDropdown($prompt)
->newLine(), // Space for errors
};
}

/**
* Render the value with the cursor and a search icon.
*/
protected function valueWithCursorAndSearchIcon(SearchPrompt $prompt, int $maxWidth): string
{
return preg_replace(
'/\s$/',
$this->cyan(''),
$this->pad($prompt->valueWithCursor($maxWidth - 1).' ', min($this->longest($prompt->matches(), padding: 2), $maxWidth))
);
}

/**
* Render a spacer to prevent jumping when the suggestions are displayed.
*/
protected function spaceForDropdown(SearchPrompt $prompt): self
{
if ($prompt->searchValue() !== '') {
return $this;
}

$this->newLine(max(
0,
min($prompt->scroll, $prompt->terminal()->lines() - 7) - count($prompt->matches()),
));

if ($prompt->matches() === []) {
$this->newLine();
}

return $this;
}

/**
* Render the options.
*/
protected function renderOptions(SearchPrompt $prompt): string
{
if ($prompt->searchValue() !== '' && empty($prompt->matches())) {
return $this->gray(' '.($prompt->state === 'searching' ? 'Searching...' : 'No results.'));
}

return $this->scroll(
collect($prompt->matches())
->values()
->map(fn ($label) => $this->truncate($label, $prompt->terminal()->cols() - 10))
->map(fn ($label, $i) => $prompt->highlighted === $i
? "{$this->cyan('')} {$label} "
: " {$this->dim($label)} "
),
$prompt->highlighted,
min($prompt->scroll, $prompt->terminal()->lines() - 7),
min($this->longest($prompt->matches(), padding: 4), $prompt->terminal()->cols() - 6)
)->implode(PHP_EOL);
}
}
10 changes: 10 additions & 0 deletions src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ function suggest(string $label, array|Closure $options, string $placeholder = ''
return (new SuggestPrompt($label, $options, $placeholder, $default, $scroll, $required, $validate))->prompt();
}

/**
* Allow the user to search for an option.
*
* @param Closure(string): array<int|string, string> $options
*/
function search(string $label, Closure $options, string $placeholder = '', int $scroll = 5, ?Closure $validate = null): int|string
{
return (new SearchPrompt($label, $options, $placeholder, $scroll, $validate))->prompt();
}

/**
* Render a spinner while the given callback is executing.
*
Expand Down
Loading

0 comments on commit 1785618

Please sign in to comment.