Bash – How to Read Special Keys

bashkeyboard

I am playing with a script that, among other things, list a selection-list. As in:

1) Item 1              # (highlighted)
2) Item 2
3) Item 3              # (selected)
4) Item 4

  • When user press down-arrow next items is highlighted
  • When user press up-arrow previous items is highlighted
  • etc.
  • When user press tab item is selected
  • When user press shift+tab all items are selected / deselected
  • When user press ctrl+a all items are selected

This works fine as of current use, which is my personal use where input is filtered by my own setup.

Question is how to make this reliable across various terminals.


I use a somewhat hackish solution to read input:

while read -rsn1 k # Read one key (first byte in key press)
do
    case "$k" in
    [[:graph:]])
        # Normal input handling
        ;;
    $'\x09') # TAB
        # Routine for selecting current item
        ;;
    $'\x7f') # Back-Space
        # Routine for back-space
        ;;
    $'\x01') # Ctrl+A
        # Routine for ctrl+a
        ;;
    ...
    $'\x1b') # ESC
        read -rsn1 k
        [ "$k" == "" ] && return    # Esc-Key
        [ "$k" == "[" ] && read -rsn1 k
        [ "$k" == "O" ] && read -rsn1 k
        case "$k" in
        A) # Up
            # Routine for handling arrow-up-key
            ;;
        B) # Down
            # Routine for handling arrow-down-key
            ;;
        ...
        esac
        read -rsn4 -t .1 # Try to flush out other sequences ...
    esac
done

And so on.


As mentioned, question is how to make this reliable across various terminals: i.e. what byte sequences define a specific key. Is it even feasible in bash?

One thought was to use either tput or infocmp and filter by the result given by that. I am however in a snag there as both tput and infocmp differ from what I actually read when actually pressing keys. Same goes for example using C over bash.

for t in $(find /lib/terminfo -type f -printf "%f\n"); { 
    printf "%s\n" "$t:"; 
    infocmp -L1 $t | grep -E 'key_(left|right|up|down|home|end)';
}

Yield sequences read as defined for for example linux, but not xterm, which is what is set by TERM.

E.g. arrow left:

  • tput / infocmp: \x1 O D
  • read: \x1 [ D

What am I missing?

Best Answer

What you are missing is that most terminal descriptions (linux is in the minority here, owing to the pervasive use of hard-coded strings in .inputrc) use application mode for special keys. That makes cursor-keys as shown by tput and infocmp differ from what your (uninitialized) terminal sends. curses applications always initialize the terminal, and the terminal data base is used for that purpose.

dialog has its uses, but does not directly address this question. On the other hand, it is cumbersome (technically doable, rarely done) to provide a bash-only solution. Generally we use other languages to do this.

The problem with reading special keys is that they often are multiple bytes, including awkward characters such as escape and ~. You can do this with bash, but then you have to solve the problem of portably determining what special key this was.

dialog both handles input of special keys and takes over (temporarily) your display. If you really want a simple command-line program, that isn't dialog.

Here is a simple program in C which reads a special key and prints it in printable (and portable) form:

#include <curses.h>

int
main(void)
{   
    int ch;
    const char *result;
    char buffer[80];

    filter();
    newterm(NULL, stderr, stdin);
    keypad(stdscr, TRUE);
    noecho();
    cbreak();
    ch = getch();
    if ((result = keyname(ch)) == 0) {
        /* ncurses does the whole thing, other implementations need this */
        if ((result = unctrl((chtype)ch)) == 0) {
            sprintf(buffer, "%#x", ch);
            result = buffer;
        }
    }
    endwin();
    printf("%s\n", result);
    return 0;
}

Supposing this were called tgetch, you would use it in your script like this:

case $(tgetch 2>/dev/null) in
KEY_UP)
   echo "got cursor-up"
   ;;
KEY_BACKSPACE|"^H")
   echo "got backspace"
   ;;
esac

Further reading:

Related Question