DEV Community

Cover image for writing command line scripts in php: part 4; key-down input
grant horwood
grant horwood

Posted on

writing command line scripts in php: part 4; key-down input

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:

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':

#!/usr/bin/env php
Enter fullscreen mode Exit fullscreen mode

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:

/**
 * 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);
}
Enter fullscreen mode Exit fullscreen mode

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

$keystroke = stream_get_contents(STDIN, 1);
Enter fullscreen mode Exit fullscreen mode

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' and pt. 2 'reading from STDIN'. 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:

/**
 * 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";
Enter fullscreen mode Exit fullscreen mode

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:

$options = array_unique(array_map(fn ($o) => (string)$o[0], $options));
Enter fullscreen mode Exit fullscreen mode

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(), 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 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:

/**
 * 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";
Enter fullscreen mode Exit fullscreen mode

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:

$options = array_merge(array_diff($options, [$default]), [$default]);
Enter fullscreen mode Exit fullscreen mode

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:

$lastLetter =  chr(ord('a')+count($options)-1);
$selectOptions = range('a', $lastLetter);
$options = array_combine($selectOptions, $options);
Enter fullscreen mode Exit fullscreen mode

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, 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(). 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.

foreach ($options as $select => $option) {
    print "$select) $option".($option == $default ? "*" : null).PHP_EOL;
}
Enter fullscreen mode Exit fullscreen mode

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.

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

we will recall that we used the ord() command back in pt. 3 'interactive input' 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.

Discussion (0)