A way to distinguish `\e` from escaped keys like `\e[A` in C++

694 Views Asked by At

I'm writing a readline replacement in C++, and I want to process terminal input in raw mode, including special/escaped keys like "up arrow" \e[A. However, I also want to be able to distinguish between a single press of the escape key \e followed by a press of [ and a press of A vs a press of the up arrow.

I assume that the primary difference between those two situations is that when up arrow is pressed, the input characters come in within less than a millisecond, so I thought I could do something like:

#include <termios.h>
#include <absl/strings/escaping.h>
#include <iostream>

termios enter_raw() {
    termios orig;
    termios raw;
    tcgetattr(STDOUT_FILENO, &orig);
    tcgetattr(STDOUT_FILENO, &raw);
    raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
    raw.c_oflag &= ~OPOST;
    raw.c_cflag |= CS8;
    raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
    raw.c_cc[VMIN]  = 1;
    raw.c_cc[VTIME] = 0;
    tcsetattr(STDOUT_FILENO, TCSAFLUSH, &raw);
    return orig;
}

int main() {
    termios orig = enter_raw();
    while(true) {
        char buf[10];
        memset(buf, 0, sizeof(buf));
        std::cin >> buf[0];
        usleep(1000);
        int actual = std::cin.readsome(buf + 1, sizeof(buf) - 2);
        std::cout << "Got string: \"" << absl::CEscape(buf) << "\"\n";
        if(buf[0] == 35) { break; } // received ctrl-c
    }
    tcsetattr(STDOUT_FILENO, TCSAFLUSH, &orig);
    return 0;
}

However, the output of this is not Got string: "\033[A" as I hoped; instead, it does Got string three times, as it would if it was just a naive loop over characters. Varying the number of microseconds for which it sleeps does not seem to affect anything.

Is there a way to implement this kind of thing easily in C++? Is it portable to most terminals? I don't care about supporting Windows. The answer need not use <iostream>; it can use C-style terminal IO as long as it gets the job done.

2

There are 2 best solutions below

0
taktoa On BEST ANSWER

Seems like the key is to put the termios in nonblocking mode and then poll with usleep. Mixing std::cin with read also seems to break this; stick to read.

termios enter_raw() { /* ... */ }

int main() {
    termios orig = enter_raw();
    while(true) {
        termios block; tcgetattr(STDOUT_FILENO, &block);
        termios nonblock = block;
        nonblock.c_cc[VMIN] = 0;
        
        char c0;
        read(STDIN_FILENO, &c0, 1);
        if(std::isprint(c0)) {
            std::cout << "Pressed: " << c0 << "\r\n";
        } else if(c0 == '\e') {
            tcsetattr(STDOUT_FILENO, TCSANOW, &nonblock);
            std::string result;
            result.push_back('\e');
            for(int i = 0; i < 20; i++) {
                char c;
                if(read(STDIN_FILENO, &c, 1) == 1) {
                    result.push_back(c);
                }
                usleep(5);
            }
            tcsetattr(STDOUT_FILENO, TCSANOW, &block);
            std::cout << "Pressed: " << absl::CEscape(result) << "\r\n";
        } else if(c0 == 35) {
            break; // received ctrl-c
        }
    }
    tcsetattr(STDOUT_FILENO, TCSAFLUSH, &orig);
    return 0;
}
2
Sam Varshavchik On
raw.c_cc[VMIN]  = 1;
raw.c_cc[VTIME] = 0;

This is a blocking read. From the termios manual page:

   MIN > 0, TIME == 0 (blocking read)
          read(2)  blocks until MIN bytes are available, and returns up to
          the number of bytes requested.

There is guarantee that a multi-character key, such as \033[A is generated "automatically". The terminal might as well receive the \033 key from the underlying device by itself. The conditions for the blocking read are met, and the escape is returned. The next character arrives soon thereafter, but it's too late.

What you seem to want is:

   MIN > 0, TIME > 0 (read with interbyte timeout)
          TIME specifies the limit for a timer  in  tenths  of  a  second.
          Once  an  initial  byte of input becomes available, the timer is
          restarted after each further byte is received.  read(2)  returns
          when any of the following conditions is met:

          *  MIN bytes have been received.

          *  The interbyte timer expires.

          *  The  number  of bytes requested by read(2) has been received.
             (POSIX does not specify this termination  condition,  and  on
             some  other  implementations  read(2) does not return in this
             case.)

Pick a reasonable maximum number of characters you can expect from a key sequence. 6 characters is a reasonable proposal. Use 6 for MIN. Now, you need some kind of a timeout. Maybe 2/10th of a second.

So, now when the \033 key arrives the terminal layer will wait another 2/10th of a second to see if something comes in. Lather, rinse repeat. When the timer times out, everything that came in gets returned.

Note that you have no guarantees, whatsoever, that the second character of a multi-character sequence will arrive with 2/10th of a second. Or 3/10th of a second. Or a minute.

And even if another character comes in before the timer expires there is no guarantee it's a multi-character key sequence. Even with a short interval like 2/10th seconds, you could probably generate a few characters within this time limit by well-aimed slap at your keyboard with your palm.

This is not exact science.