writing command line scripts in php: part 4; key-down input
Tags: php
Published: 2022-05-18 19:26:39

php doesn't get a lot of attention as a command-line scripting language, which is a shame. php has a lot of powerful features, and knowing how to use them can help you leverage your existing web code on the cli.  this series of posts is designed to cover the basic constructs we will need to write effective interactive scripts in php.

this installment focuses on key-down input: everything from 'hit any key' to a simple menu

## previous installments
this is the fourth installment in the series. previously, we have covered arguments and preflighting, and reading from both piped and interactive user input.

the articles that compose this series (so far) are:

- [pt 1. arguments, preflights and more](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-1-3jpb)
- [pt 2. handling STDIN input](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-2-reading-stdin-2enf)
- [pt 3. interactive input](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-3-interactive-input-34)


## the flyover
we will be looking at trapping user 'key down' events in this installment, specifically:

- building a basic 'hit any key to continue' feature
- prompting the user to choose one of a set of keydowns, ie. "hit 'y' or 'n'"
- building a basic menu system with a default option

as usual, all the examples here should be preceeed by the php 'shebang':

```php
#!/usr/bin/env php
```
## hit any key
'hit any key to continue' is a common feature in interactive command line scripts. all it does is halt execution and wait for the user to hit... any key.

we can implement this feature with a function like this:

```php
/**
 * Hit any key to continue
 *
 * @param  String  $prompt  Text of the prompt to display
 * @return void
 */
function any_key($prompt = null)
{
    /**
     * Set a default prompt
     */
    $prompt = $prompt ? $prompt : "Hit any key to continue: ";

    /**
     * Read one keystroke from the user
     */
    readline_callback_handler_install($prompt, function() {});
    $keystroke = stream_get_contents(STDIN, 1);
}
```

most of the functionality here is pretty straightforward; the interesting stuff happens in the last two lines.

if we remember back to part 3 of this series, reading ['interactive input'](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-3-interactive-input-34), we made a dots-echo password input by reading keyboard input directly from the `STDIN` stream and replacing the default echo to the terminal with an asterisk. we're basically doing the same thing here.

observe the last line:

```php
$keystroke = stream_get_contents(STDIN, 1);
```

the `stream_get_contents()` function here reads input from a stream and takes two arguments. the first argument is the stream we want to read. since we are polling for keyboard input, this is the 'standard input' stream, or `STDIN`. the second argument is the number of chars we want to read, in this case 1. the function returns the one character that was read and assigns it to the variable `$keystroke`.

unfortunately, the default behaviour of `stream_get_contents()` is to immediately echo that character back to the terminal so the user can see it. we don't want that to happen.  to fix that, we first call `readline_callback_handler_install()`. 

the `readline_callback_handler_install()` takes two arguments here: the text to print *before* reading from the stream, and the function to run *after* reading from the stream. here we pass the prompt we want to display as the first arg, and an empty function as the second. this empty function essentially clobbers the default behaviour of `stream_get_contents()`, which turns off the screen echo.

we've seen stream reading before in both pt. 3 ['interactive input'](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-3-interactive-input-34) and pt. 2 ['reading from STDIN'](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-2-reading-stdin-2enf). being able to read from and write to streams is a common technique used in building interactive command-line scripts, and we'll see more of it in the future.


## prompt for a simple choice
once we have 'hit any key' working, it's a short step to being able to give the user a short list of options and have them make a choice by pressing the corresponding key. let's see what a function that does that looks like:

```php
/**
 * Prompt user to choose one value from $options array
 *
 * @param  String  $prompt  Text of prompt to display
 * @param  Array   $options Array of valid options. Must be chars.
 * @return String  The valid char selected by the user
 */
function prompt_choice($prompt = "Choose One", $options = ['yes', 'no'])
{
    /**
     * Force options values to single chars
     */
    $options = array_unique(array_map(fn ($o) => (string)$o[0], $options));

    /**
     * Prompt user to choose an option until they select a valid value
     */
    while (true) {

        /**
         * Create a prompt that lists the options
         */
        $showPrompt = "$prompt [".implode(', ', $options)."]".PHP_EOL;

        /**
         * Read one keystroke from the user
         */
        readline_callback_handler_install($showPrompt.PHP_EOL, function() {});
        $keystroke = stream_get_contents(STDIN, 1);

        /**
         * Return selected value if valid
         */
        if (in_array($keystroke, $options)) {
            return $keystroke;
        }

        /**
         * No valid choice. Show menu again
         */
        print PHP_EOL;
    }
}

$user_choice = prompt_choice();
print PHP_EOL."user has $user_choice";
```

the core functionality here is still `stream_get_contents()` combined with `readline_callback_handler_install()`, but now we actually pay attention to the returned key value and test to see if it's an a list of pre-set options. let's go over this function.

the first thing we notice is this function takes two arguments. we accept a `$prompt`, which is the text we display to the user, and an array of valid `$options` they can choose form.

since we're polling the user for a single key down event, all of the options in our array need to be one character. a key down event is only one character, after all. on the first line of the function we enforce that. observe the line:

```php
$options = array_unique(array_map(fn ($o) => (string)$o[0], $options));
```

the outcome of this line is that the array of values passed as an argument is converted into an array of the unique first characters of each element. so, for instance, the array `['yes', 'no']` becomes `['y', 'n']`.

we do this by using php's built-in command [`array_map()`](https://www.php.net/manual/en/function.array-map.php), which takes as arguments an array and a function to apply to each element of that array, returning a modified array. the function we pass to `array_map()` is written as an [arrow function](https://www.php.net/manual/en/functions.arrow.php) for brevity. all this function does is cast the element of the array to a string and return the first character. finally, we call `array_unique()` to eliminate any duplicate values. the result we get back is an array of characters.

the next part of our `prompt_choice()` function is an infinite loop. we have this loop because we want to keep polling the user until they hit a key that is in our `$options` list. if we offer the user the choice of either 'y' or 'n' and they hit 'j', we want to ask them again. 

inside the loop we build a prompt string to display to the user. the prompt lists the valid options, separated by commas. then we read one character from the stream; the key they pressed. if the `$keystroke` is a character that is in our `$options` list, we return it, breaking the loop. otherwise, we go back to the beginning and ask them again.

## build a simple menu
we can build on our `prompt_choice()` function now to create a basic menu with a default option. in future installments, we will be creating a menu that allows the user to navigate the choices with the up and down arrow keys and select by hitting `<RETURN>`, but that's for later. right now, our menu is just going to display a list of options, keyed by a letters, and then read the user's selection. our menu will also have a 'default' choice that is selected if the user simply hits `<RETURN>`.

the function looks like this:

```php
/**
 * Prompt user to choose from a menu made from the $options array
 *
 * @param  String  $prompt
 * @param  Array   $options
 * @param  String  $default
 * @return Array
 */
function prompt_menu($prompt = "Choose One", $options = ['yes', 'no'], $default = 'yes')
{
    /**
     * Create list of options to show user
     * If default is not in the list, add it and put at end of array
     */
    $options = array_merge(array_diff($options, [$default]), [$default]);

    /**
     * Key list of options by sequential letters, starting at 'a'
     */
    $lastLetter =  chr(ord('a')+count($options)-1);
    $selectOptions = range('a', $lastLetter);
    $options = array_combine($selectOptions, $options);

    /**
     * Prompt user to choose an option until they select either a valid value
     * or accept the default by hitting <RETURN>
     */
    while (true) {

        /**
         * Output the list of options with an asterisk beside the default value
         */
        foreach ($options as $select => $option) {
            print "$select) $option".($option == $default ? "*" : null).PHP_EOL;
        }

        /**
         * Read one keystroke from the user
         */
        readline_callback_handler_install($prompt.PHP_EOL, function() {});
        $keystroke = stream_get_contents(STDIN, 1);

        /**
         * Return selected value if valid
         */
        if (in_array($keystroke, array_keys($options))) {
            return $options[$keystroke];
        }

        /**
         * Return default if keystroke <RETURN>
         */
        if (ord($keystroke) == 10) {
            return $default;
        }

        /**
         * No valid choice. Show menu again
         */
        print PHP_EOL;
    }
} // prompt_menu

$prompt = "Select your current operating system";
$options = [
    'Pop! Os',
    'Fedora',
    'Plan 9',
];
$default = "Pop! Os";

$user_choice = prompt_menu($prompt, $options, $default);
print PHP_EOL."user chose $user_choice";
```

the first thing we notice about this function is that it takes an extra argument: `$default`. this is the value that is selected if the user just hits `<RETURN>`. the default value can either already be listed in `$options` or not. we handle ensuring the default is in our list of options with the first line:

```php
$options = array_merge(array_diff($options, [$default]), [$default]);
```

here we use `array_diff()` to remove the default value from the array of options if it's already in there, then add it back in with `array_merge()` so that it's at the bottom of the list, which is where we want the default option to be. 

next, we key our `$options` array by single letters starting at 'a' and going up. these letters represent the keys our user can hit to select a given option, so ie. they would press the 'a' key for the first option, the 'b' key for the second one, and so on.

let's look at this block of code:

```php
$lastLetter =  chr(ord('a')+count($options)-1);
$selectOptions = range('a', $lastLetter);
$options = array_combine($selectOptions, $options);
```

in order to key our array with letters, we are first going to build an array of keys starting at 'a' and going to the appropriate letter for the length of the `$options` array. so, for example, if we had an array with three options in it, we would need an array of keys that was `['a', 'b', 'c']`.

to do this, we first determine this last letter of this range. back in [part 3 of this series](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-3-interactive-input-34), we used the `ord()` command to get the ascii code of a character. we're going to use that again here. we get our `$lastLetter` by getting the ascii code of 'a', then add the number of elements in the array to it to get the ascii code of the final character. we then convert that new ascii code back into a character with `chr()`. if we have three elements in our `$options` array, this line will give us 'c'.

in the next line, we build our array of keys by using the `range()` command. we've all seen `range()` used on integers, ie `range(1, 3)` to give us the array `[1, 2, 3]`, but `range()` also works for characters: `range('a', 'c')` will give use `['a', 'b', 'c']`.

now that we have our array of `$options` and our array of keys in `$selectOptions`, all we have to do is set our keys as actually associative array keys on options. we do that with [`array_combine()`](https://www.php.net/manual/en/function.array-combine.php). our `$options` array is now associative and keyed by sequential letters.

once we have our keyed array of options, all we have to do is display it to the user and prompt them to press a key for their choice. as we did in `prompt_choice()`, we will be wrapping this in an infinite loop that breaks when the user has made a valid choice.

in our loop, the first thing we will need to do is build a prompt to show the user that contains all the options they can select from. we do that with a fairly straightforward `foreach` loop.

```php
foreach ($options as $select => $option) {
    print "$select) $option".($option == $default ? "*" : null).PHP_EOL;
}
```

note that we test each option to see if it is the default and print an asterisk next to it if it is, so our users know what hitting `<RETURN>` will get them.

we then read a keystroke from `STDIN` and test to see if it is one of the valid options, just as we did for prompt choice.

finally, we test if the key the user hit is `<RETURN>`. if it is, we return the default value.

```php
/**
 * Return default if keystroke <RETURN>
 */
if (ord($keystroke) == 10) {
    return $default;
}
```

we will recall that we used the [`ord()`](https://www.php.net/manual/en/function.ord.php) command back in pt. 3 ['interactive input'](https://dev.to/gbhorwood/writing-command-line-scripts-in-php-part-3-interactive-input-34) to test for specific keys that didn't have easy string representations, and we're doing the same thing here.

## putting it all together
ultimately, there are only two things we need to be able to do to read user key-down input and process it:

- read a single character from `STDIN` with `stream_get_contents()`, and
- determine the key that was pressed with `ord()`

once we have both of those tools in our belt, we can build all sorts of user interactions.

## next steps
for the last three installments, we've focused on user input: reading piped input from `STDIN`, accepting free-form text from our users, and reading and processing key-down events.

the next obvious stage is output, but first we'll be investigating file handling; first reading, then writing.