laravel / prompts

Beautiful and user-friendly forms for your command-line PHP applications.
https://laravel.com/docs/prompts
MIT License
502 stars 88 forks source link

Feature request: Assign keyboard key to select() choices #144

Closed Mika56 closed 4 months ago

Mika56 commented 4 months ago

Hello,

To speed up using select(), I would like to see the possibility to assign some keyboard key to each choice.

It could look something like one of these:

<?php
$choice = select('What\'s your favorite color?', [
    'blue' => ['Blue', 'b'],
    'red' => ['Red', 'r'],
]);

$choice = select('What\'s your favorite color?', [
    'blue' => new Choice(label: 'Blue', key: 'b'),
    'red' => new Choice(label: 'Red', key: 'r'),
]);

$choice = select('What\'s your favorite color?', [
    'blue' => '_B_lue',
    'red' => '_R_ed',
]);

$choice = select('What\'s your favorite color?', [
    'blue' => '[B]lue',
    'red' => '[R]ed',
]);

I think the last two ones are the ones that requires the least modification but may not be the most readable.

As for the implementation, I feel this is mostly updating this listener: https://github.com/laravel/prompts/blob/main/src/SelectPrompt.php#L51

On the display side, it would be nice if we could underline the assigned key.

The constructor should make sure that every key is unique.

Mika56 commented 4 months ago

Here's a quick and dirty class that extends SelectPrompt. Note that k, h, j and l keys had to be removed:

<?php

declare(strict_types=1);

namespace App\Console;

use Illuminate\Support\Collection;
use InvalidArgumentException;
use Laravel\Prompts\Key;
use Laravel\Prompts\SelectPrompt;
use Laravel\Prompts\Themes\Default\SelectPromptRenderer;

class SelectQuickPrompt extends SelectPrompt
{
    public function __construct(
        public string $label,
        array|Collection $options,
        public int|string|null $default = null,
        public int $scroll = 5,
        public mixed $validate = null,
        public string $hint = '',
        public bool|string $required = true,
    ) {
        if ($this->required === false) {
            throw new InvalidArgumentException('Argument [required] must be true or a string.');
        }

        $this->options = $options instanceof Collection ? $options->all() : $options;

        // This is new
        $keys = [];
        $i = 0;
        foreach ($this->options as &$option) {
            if(1===preg_match('`\[(\w)]`', $option, $matches)) {
                $option = str_replace($matches[0], self::underline($matches[1]), $option);
                if(array_key_exists(strtolower($matches[1]), $keys)) {
                    throw new InvalidArgumentException('Key '.strtolower($matches[1]).' is defined more than once');
                }
                $keys[strtolower($matches[1])] = $i;
            }
            $i++;
        }
        // End of new

        if ($this->default) {
            if (array_is_list($this->options)) {
                $this->initializeScrolling(array_search($this->default, $this->options) ?: 0);
            } else {
                $this->initializeScrolling(array_search($this->default, array_keys($this->options)) ?: 0);
            }

            $this->scrollToHighlighted(count($this->options));
        } else {
            $this->initializeScrolling(0);
        }

        $this->on('key', fn ($key) => match ($key) {
            Key::UP, Key::UP_ARROW, Key::LEFT, Key::LEFT_ARROW, Key::SHIFT_TAB, Key::CTRL_P, Key::CTRL_B => $this->highlightPrevious(count($this->options)),
            Key::DOWN, Key::DOWN_ARROW, Key::RIGHT, Key::RIGHT_ARROW, Key::TAB, Key::CTRL_N, Key::CTRL_F => $this->highlightNext(count($this->options)),
            Key::oneOf([Key::HOME, Key::CTRL_A], $key) => $this->highlight(0),
            Key::oneOf([Key::END, Key::CTRL_E], $key) => $this->highlight(count($this->options) - 1),
            // This is new
            array_key_exists($key, $keys) ? $key : null => $this->highlight($keys[$key]),
            // End of new
            Key::ENTER => $this->submit(),
            default => null,
        });
    }

    protected function getRenderer(): callable
    {
        return new SelectPromptRenderer($this);
    }

    public static function select(): int|string
    {
        return (new self(...func_get_args()))->prompt();
    }

}
jessarcher commented 4 months ago

This is an interesting idea. I've often thought about adding a Laravel\Prompts\option function that could be passed to select/multiselect/search/multisearch to enable various new features like disabling options, adding descriptions/hints, and having more control over the return value. Similar to the objects you can pass to terkelg/prompts and clack/prompts.

One of the biggest challenges is that we need to be able to use Symfony's console components as a fallback for Windows users without WSL and maintain any critical behaviour (e.g. disabled options should still be disabled in some way, which Symfony doesn't handle natively). In this case, the keyboard functionality wouldn't be possible with Symfony (as far as I'm aware), but it's not a critical behaviour, so we'd just need to transform the options before passing them to Symfony.

An alternative would be to add this functionality automatically, i.e., automatically choose the first unique character in each option.

image

(I'd imagine it would be case insensitive)

We could potentially skip characters with existing behaviour like hjkl, and we'd probably want to limit it to a-z0-9.

For consistency, the behaviour would need to at least work on select and multiselect, which raises the question of whether the key should just move to the option, or whether it should also select it (or toggle it in the case of multiselect). It also makes me wonder whether it should work in search and multisearch. In that scenario, the functionality would need to be conditional based on whether you're focused on the text input or the options (and it would be cool if the underline only appeared when focused on the options).

I don't have the bandwidth to take this on right now, but I'm open to a PR. I'd personally lean towards the automatic version, which also has the benefit of not impacting the Symfony fallback, as the options array would remain unchanged. I don't think it's essential that the search and multisearch get the behaviour as they already have a built-in way to get to an option quickly, and it would be a lot more complex because the options array is constantly changing.