Skip to content

Add multisearch prompt #58

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions playground/multisearch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

use function Laravel\Prompts\multisearch;

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

$users = collect([
'taylor' => 'Taylor Otwell',
'dries' => 'Dries Vints',
'james' => 'James Brooks',
'nuno' => 'Nuno Maduro',
'mior' => 'Mior Muhammad Zaki',
'jess' => 'Jess Archer',
'guus' => 'Guus Leeuw',
'tim' => 'Tim MacDonald',
'joe' => 'Joe Dixon',
]);

$selected = multisearch(
label: 'Which users should receive the email?',
placeholder: 'Search...',
options: function ($value) use ($users) {
// Comment to show all results by default.
if (strlen($value) === 0) {
return [];
}

usleep(100 * 1000); // Simulate a DB query.

return $users->when(
strlen($value),
fn ($users) => $users->filter(fn ($name) => str_contains(strtolower($name), strtolower($value)))
)->all();
},
required: true,
validate: function ($values) {
if (in_array('jess', $values)) {
return 'Jess cannot receive emails';
}
},
);

var_dump($selected);
3 changes: 3 additions & 0 deletions src/Concerns/Themes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use InvalidArgumentException;
use Laravel\Prompts\ConfirmPrompt;
use Laravel\Prompts\MultiSearchPrompt;
use Laravel\Prompts\MultiSelectPrompt;
use Laravel\Prompts\Note;
use Laravel\Prompts\PasswordPrompt;
Expand All @@ -14,6 +15,7 @@
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;
Expand Down Expand Up @@ -44,6 +46,7 @@ trait Themes
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,
Expand Down
12 changes: 10 additions & 2 deletions src/Concerns/TypedValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ trait TypedValue
/**
* Track the value as the user types.
*/
protected function trackTypedValue(string $default = '', bool $submit = true): void
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) {
$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),
Expand All @@ -43,6 +47,10 @@ protected function trackTypedValue(string $default = '', bool $submit = true): v

// Keys may be buffered.
foreach (mb_str_split($key) as $key) {
if ($ignore !== null && $ignore($key)) {
return;
}

if ($key === Key::ENTER && $submit) {
$this->submit();

Expand Down
218 changes: 218 additions & 0 deletions src/MultiSearchPrompt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?php

namespace Laravel\Prompts;

use Closure;

class MultiSearchPrompt extends Prompt
{
use Concerns\ReducesScrollingToFitTerminal;
use Concerns\Truncation;
use Concerns\TypedValue;

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

/**
* The index of the first visible option.
*/
public int $firstVisible = 0;

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

/**
* The selected values.
*
* @var array<int|string, string>
*/
public array $values = [];

/**
* Create a new MultiSearchPrompt instance.
*
* @param Closure(string): array<int|string, string> $options
*/
public function __construct(
public string $label,
public Closure $options,
public string $placeholder = '',
public int $scroll = 5,
public bool|string $required = false,
public ?Closure $validate = null,
public string $hint = '',
) {
$this->trackTypedValue(submit: false, ignore: fn ($key) => $key === Key::SPACE && $this->highlighted !== null);

$this->reduceScrollingToFitTerminal();

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

/**
* Perform the search.
*/
protected function search(): void
{
$this->state = 'searching';
$this->highlighted = null;
$this->render();
$this->matches = null;
$this->firstVisible = 0;
$this->state = 'active';
}

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

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

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

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

if (strlen($this->typedValue) === 0) {
$matches = ($this->options)($this->typedValue);

return $this->matches = [
...array_diff($this->values, $matches),
...$matches,
];
}

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

/**
* The currently visible matches
*
* @return array<string>
*/
public function visible(): array
{
return array_slice($this->matches(), $this->firstVisible, $this->scroll, preserve_keys: true);
}

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

if ($this->highlighted < $this->firstVisible) {
$this->firstVisible--;
} elseif ($this->highlighted === count($this->matches) - 1) {
$this->firstVisible = count($this->matches) - min($this->scroll, count($this->matches));
}
}

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

if ($this->highlighted > $this->firstVisible + $this->scroll - 1) {
$this->firstVisible++;
} elseif ($this->highlighted === 0 || $this->highlighted === null) {
$this->firstVisible = 0;
}
}

/**
* Toggle the highlighted entry.
*/
protected function toggleHighlighted(): void
{
if (array_is_list($this->matches)) {
$label = $this->matches[$this->highlighted];
$key = $label;
} else {
$key = array_keys($this->matches)[$this->highlighted];
$label = $this->matches[$key];
}

if (array_key_exists($key, $this->values)) {
unset($this->values[$key]);
} else {
$this->values[$key] = $label;
}
}

/**
* Get the current search query.
*/
public function searchValue(): string
{
return $this->typedValue;
}

/**
* Get the selected value.
*
* @return array<int|string>
*/
public function value(): array
{
return array_keys($this->values);
}

/**
* Get the selected labels.
*
* @return array<string>
*/
public function labels(): array
{
return array_values($this->values);
}
}
1 change: 1 addition & 0 deletions src/SearchPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ protected function search(): void
$this->highlighted = null;
$this->render();
$this->matches = null;
$this->firstVisible = 0;
$this->state = 'active';
}

Expand Down
1 change: 1 addition & 0 deletions src/SuggestPrompt.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public function __construct(
default => (function () {
$this->highlighted = null;
$this->matches = null;
$this->firstVisible = 0;
})(),
});

Expand Down
Loading