C I/O: Handling keyboard input on the Windows console

182 views Asked by At

I am creating an interactive console application that should run on both Linux and Windows operating systems. Within the program, there are prompts which ask for keyboard input from stdin. At each prompt, the user can only enter specific ASCII characters, as well as a specific number of characters. The prompts also have special behavior if the <Escape> or <Enter> keys are pressed; <Enter> submits the prompt to be evaluated by the internal program state, and <Escape> triggers a command which exits the program without requiring the prompt to be submitted. At the moment, these prompts only work on Linux because I do not know how to implement them for Windows.

The input prompt is currently implemented via two functions: the generic handler function prompt_user_input, and the platform-layer function platform_console_read_key, which only has a working Linux implementation at the moment--not Windows.

platform_console_read_key works like this:

  1. Immediately read up to six bytes from stdin into a buffer (multiple bytes allows for parsing ANSI escape codes for non-ASCII keys).
  2. If the first byte read is not an escape sequence indicator, the first byte is returned as a regular ASCII character.
  3. If the first byte is an escape sequence indicator, bytes 2-6 of the buffer are parsed.
    1. If the rest of the buffer is empty, then the character is standalone, so do step 2.
    2. If there is something in the latter part of the buffer, then it is actually an escape sequence. Attempt to parse and return it as an "extended key code" (which is just a #define for an integer > 255).
    3. If there is something in the latter part of the buffer, and it is an escape code which is not handled by the function, return the special value 0.
  4. If there is any kind of critical I/O error, return the special value KEY_COUNT.

The reason I read the characters like this is because I handle each key code returned by platform_console_read_key separately in prompt_user_input to determine how the program should proceed. prompt_user_input works like this:

  1. Take an argument which is the maximum number of characters which may be input.
  2. Read keys via platform_console_read_key in an infinite loop.
  3. If last key matches <Enter> or <Escape>, break from the loop.
  4. Otherwise, see if it is a valid key using a filter_user_input predicate.
    1. If it is valid, add it to an input buffer, as long as the number of the characters written to the buffer does not exceed that allowed by the function argument. Also print it to the screen (i.e. "echo" functionality).
    2. If max writes reached, or key is invalid, continue (i.e. goto step 2).

My question is how I would implement platform_console_read_key for the Windows platform layer. How can I immediately read 6 bytes of keyboard input at the Windows terminal? Also, how do I determine how these 6 bytes will be formatted, so that I can map the Windows key codes to the function's return values such that the behavior of the function matches that of the Linux version?

Below is the working Linux code, where the platform layer includes <termios.h> and <unistd.h>. I omitted some irrelevant code, as well as added some printf statements for clarity and ease of testing. Note also that in prompt_user_input there are references to a global ( *state ).in, which is the input buffer; assume it is always big enough for the supplied char_count.

bool
prompt_user_input
(   const u8 char_count
)
{
    printf ( "Type your response: " );

    // Clear any previous user input.
    memset ( ( *state ).in , 0 , sizeof ( ( *state ).in ) );

    KEY key;
    u8 indx = 0;
    for (;;)
    {
        key = platform_console_read_key ();
        
        if ( key == KEY_COUNT )
        {
            // ERROR
            return false;
        }
        
        // Submit response? Y/N
        if ( key == KEY_ENTER )
        {
            return true;
        }
        
        // Quit signal? Y/N
        if ( key == KEY_ESCAPE )
        {
            //...
            return true;
        }

        // Handle backspace.
        if ( key == KEY_BACKSPACE )
        {
            if ( !indx )
            {
                continue;
            }
            indx -= 1;
            ( *state ).in[ indx ] = 0;
        }

        // Response too long? Y/N
        else if ( indx >= char_count )
        {
            indx = char_count;
            continue;
        }

        // Invalid keycode? Y/N
        else if ( !filter_user_input ( key ) )
        {
            continue;
        }

        // Write to input buffer.
        else
        {
            ( *state ).in[ indx ] = key;
            indx += 1;
        }

        // Render the character.
        printf ( "%s"
               , ( key == KEY_BACKSPACE ) ? "\b \b"
                                          : ( char[] ){ key , 0 }
               );
    }
}

KEY
platform_console_read_key
( void )
{
    KEY key = KEY_COUNT;

    // Configure terminal for non-canonical input.
    struct termios tty;
    struct termios tty_;
    tcgetattr ( STDIN_FILENO , &tty );
    tty_ = tty;
    tty_.c_lflag &= ~( ICANON | ECHO );
    tcsetattr ( STDIN_FILENO , TCSANOW , &tty_ );
    fflush ( stdout ); // In case echo functionality desired.
    
    // Read the key from the input stream.
    char in[ 6 ]; // Reserve up to six bytes to handle special keys.
    memset ( in , 0 , sizeof ( in ) );
    i32 result = read ( STDIN_FILENO , in , sizeof ( in ) );
    
    // I/O error.
    if ( result < 0 )
    {
        key = KEY_COUNT;
        goto platform_console_read_key_end;
    }

    // End of transmission (I/O error).
    if (   in[ 0 ] == 4
        || in[ 1 ] == 4
        || in[ 2 ] == 4
        || in[ 3 ] == 4
        || in[ 4 ] == 4
        || in[ 5 ] == 4
        )
    {
        key = KEY_COUNT;
        goto platform_console_read_key_end;
    }

    // ANSI escape sequence.
    if ( *in == '\033' )
    {
        // Standalone keycode.
        if ( !in[ 1 ] )
        {
            key = KEY_ESCAPE;
            goto platform_console_read_key_end;
        }

        // Composite keycode.
        else
        {
            if ( in[ 1 ] == '[' )
            {
                // ...
            }
            else
            {
                key = 0;
            }
            goto platform_console_read_key_end;
        }
    }

    // Standalone ASCII character.
    else
    {
        // Backspace key is mapped to ASCII 'delete' (for some reason).
        key = ( *in == KEY_DELETE ) ? KEY_BACKSPACE : *in;
        goto platform_console_read_key_end;
    }

    // Reset terminal to canonical input mode.
    platform_console_read_key_end:
        tcsetattr ( STDIN_FILENO , TCSANOW , &tty );
        fflush ( stdout ); // In case echo functionality desired.
        return key;
}

EDIT

Solved, thanks to the accepted answer. Here is the minimal Windows implementation of platform_read_console_key that keeps the behavior of prompt_user_input the same. It uses the <conio.h> header.

KEY
platform_console_read_key
( void )
{
    i32 getch;

    getch = _getch ();

    // Error.
    if ( getch < 0 )
    {
        return KEY_COUNT;
    }

    // Standalone ASCII keycode.
    if ( getch != 0 && getch != 224 && getch < 0x100 )
    {
        return newline ( getch ) ? KEY_ENTER
                                 : ( getch == '\033' ) ? KEY_ESCAPE
                                                       : getch
                                                       ;
    }

    // Extended keycode.
    getch = _getch ();
    switch ( getch )
    {
        default: return 0;  // Unknown keycode.
    }
}
1

There are 1 answers

1
greg spears On BEST ANSWER

Here is a simple solution user Weather Vane hinted at, and that I've used for years. Running this code will also allow you to discover and hence define many more F-keys -- you know, F1, F2, also arrow keys, and the like.

NOTE: you want that add of 256 to _getch() for the extended keys in GetKey(). This places extended key values outside the range of common keys like A thru Z....noting that F10 by itself would only be value 68 -- and that matches with the key value for SHIFT + 'D'. No good, see?

This code will also work for CTRL + key combos. Also works for ALT key combos within limits. ALT key combos get a little hairy because ALT+TAB key, for example, switches between applications in MS Windows. Hence, when your app starts competing with the OS for keystroke actions, behavior might become undefined. And -- per experience -- you can add 100s of lines of code to remedy these things but only increase performance and scope nominally.

Code was compiled in Visual Studio and tested on Win10 before posting to SO.

#include <stdio.h>
#include <conio.h>

#define KEY_ESCAPE  27
#define KEY_BACKSPACE 8  
#define KEY_ENTER 13
#define KEY_F10  324


/*---------------------------------------------

    GetKey()

    Thanks John Wagner
*---------------------------------------------*/
int GetKey(void) 
{
    int c = _getch();
    if(c ==0 || c == 224)
        c = 256 + _getch(); /* If extended key (like F10), add 256. */
    return c;
}

int main () 
{
    int key;

    printf("Hit a key -- ESC key quits\n");

    do{
        key = GetKey();

        switch(key)
        {
            case KEY_ENTER:
                printf("ENTER key detected.\n");
            break;

            case KEY_BACKSPACE:
                printf("BACKSPACE key detected.\n");
            break;

            case KEY_F10:
                printf("MENU(F10) key detected.\n");
            break;

            case 'y':
            case 'Y':
                printf("User says yes!\n");
            break;

            case 'n':
            case 'N':
                printf("User says No!\n");
            break;

            default:
                printf("Key value: %d\n", key);
            break;
        }

    }while (key != KEY_ESCAPE);

    return 0;
}