Skip to content

Instantly share code, notes, and snippets.

@osa1
Last active March 26, 2025 19:07
Show Gist options
  • Save osa1/86015faeb92f840f9328080dd9276bd9 to your computer and use it in GitHub Desktop.
Save osa1/86015faeb92f840f9328080dd9276bd9 to your computer and use it in GitHub Desktop.
ncurses alt, ctrl etc. key events
// It turns out people don't really know how to handle Alt+ch, or F[1, 12] keys
// etc. in ncurses apps. Even StackOverflow is full of wrong answers and ideas.
// The key idea is to skip ncurses' key handling and read stuff from the stdin
// buffer manually. Here's a demo. Run this and start typing. ESC to exit.
//
// To compile:
//
// $ gcc demo.c -o demo -lncurses -std=gnu11
#include <ncurses.h>
#include <signal.h> // sigaction, sigemptyset etc.
#include <stdlib.h> // exit()
#include <string.h> // memset()
#include <unistd.h> // read()
static volatile sig_atomic_t got_sigwinch = 0;
static void sigwinch_handler(int sig)
{
(void)sig;
got_sigwinch = 1;
}
int read_stdin();
int main()
{
// Register SIGWINCH signal handler to handle resizes: select() fails on
// resize, but we want to know if it was a resize because don't want to
// abort on resize.
struct sigaction sa;
sa.sa_handler = sigwinch_handler;
sa.sa_flags = SA_RESTART;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGWINCH, &sa, NULL) == -1)
{
fprintf(stderr, "Can't register SIGWINCH action.\n");
exit(1);
}
// Initialize ncurses
initscr();
curs_set(1);
noecho();
nodelay(stdscr, TRUE);
raw();
// select() setup. You usually want to add more stuff here (sockets etc.).
fd_set readfds_orig;
memset(&readfds_orig, 0, sizeof(fd_set));
FD_SET(0, &readfds_orig);
int max_fd = 0;
fd_set* writefds = NULL;
fd_set* exceptfds = NULL;
struct timeval* timeout = NULL;
// sigwinch counter, just to show how many SIGWINCHs caught.
int sigwinchs = 0;
// Main loop
for (;;)
{
fd_set readfds = readfds_orig;
if (select(max_fd + 1, &readfds, writefds, exceptfds, timeout) == -1)
{
// Handle errors. This is probably SIGWINCH.
if (got_sigwinch)
{
endwin();
clear();
char sigwinch_msg[100];
sprintf(sigwinch_msg, "got sigwinch (%d)", ++sigwinchs);
mvaddstr(0, 0, sigwinch_msg);
refresh();
}
else
{
break;
}
}
else if (FD_ISSET(0, &readfds))
{
// stdin is ready for read()
clear();
int quit = read_stdin();
if (quit)
break;
refresh();
}
}
endwin();
return 0;
}
static char* input_buffer_text = "input buffer: [";
static int input_buffer_text_len = 15; // ugh
int read_stdin()
{
char buffer[1024];
int size = read(0, buffer, sizeof(buffer) - 1);
if (size == -1)
{
// Error on read(), this shouldn't really happen as it was ready for
// reading before calling this.
return 1;
}
else
{
// Check for ESC
if (size == 1 && buffer[0] == 0x1B)
return 1;
// Show the buffer contents in hex
mvaddstr(0, 0, input_buffer_text);
char byte_str_buf[2];
for (int i = 0; i < size; ++i)
{
sprintf(byte_str_buf, "%02X\0", buffer[i]);
int x = input_buffer_text_len + (i * 4);
mvaddnstr(0, x, byte_str_buf, 2);
if (i != size - 1)
mvaddch(0, x + 2, ',');
}
mvaddch(0, input_buffer_text_len + (size * 4) - 2, ']');
// No errors so far
return 0;
}
}
extern crate libc;
/// Read stdin contents if it's ready for reading. Returns true when it was able
/// to read. Buffer is not modified when return value is 0.
fn read_input_events(buf : &mut Vec<u8>) -> bool {
let mut bytes_available : i32 = 0; // this really needs to be a 32-bit value
let ioctl_ret = unsafe { libc::ioctl(libc::STDIN_FILENO, libc::FIONREAD, &mut bytes_available) };
// println!("ioctl_ret: {}", ioctl_ret);
// println!("bytes_available: {}", bytes_available);
if ioctl_ret < 0 || bytes_available == 0 {
false
} else {
buf.clear();
buf.reserve(bytes_available as usize);
let buf_ptr : *mut libc::c_void = buf.as_ptr() as *mut libc::c_void;
let bytes_read = unsafe { libc::read(libc::STDIN_FILENO, buf_ptr, bytes_available as usize) };
debug_assert!(bytes_read == bytes_available as isize);
unsafe { buf.set_len(bytes_read as usize); }
true
}
}
fn main() {
// put the terminal in non-buffering, no-enchoing mode
let mut old_term : libc::termios = unsafe { std::mem::zeroed() };
unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut old_term); }
let mut new_term : libc::termios = old_term.clone();
new_term.c_lflag &= !(libc::ICANON | libc::ECHO);
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &new_term) };
// Set up the descriptors for select()
let mut fd_set : libc::fd_set = unsafe { std::mem::zeroed() };
unsafe { libc::FD_SET(libc::STDIN_FILENO, &mut fd_set); }
loop {
let mut fd_set_ = fd_set.clone();
let ret =
unsafe {
libc::select(1,
&mut fd_set_, // read fds
std::ptr::null_mut(), // write fds
std::ptr::null_mut(), // error fds
std::ptr::null_mut()) // timeval
};
if unsafe { ret == -1 || libc::FD_ISSET(0, &mut fd_set_) } {
let mut buf : Vec<u8> = vec![];
if read_input_events(&mut buf) {
println!("{:?}", buf);
}
}
}
// restore the old settings
// (FIXME: This is not going to work as we have no way of exiting the loop
// above)
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &old_term) };
}
@MonkeyToiletLadder
Copy link

Hi, How did you acquire the knowledge and skill to write such a program? Im interested in making games with c++ and ncurses. I do get simliar results when reading in characters with getch just they are separate values but they match the hex that your program outputs. Im just having difficulty understanding your program. Are there any resources you would recomend?

@RaphGL
Copy link

RaphGL commented Mar 5, 2025

For other people trying to understand what is going on here and found the code too complicated because it touches stuff outside of the core topic (polling io to avoid wasting CPU cycles), I stripped down the file to its core concept. You can just read the read_stdin function, the rest is just helpers so you can run it and test it on your machine, hope it helps:

#include <ncurses.h>
#include <stdbool.h>
#include <string.h> // strlen()
#include <unistd.h> // read()

void pretty_print_buffer(char buffer[static 1024], const size_t bufsize);

// ====== THE CORE CONCEPT =====
// Essentially the stdin is a buffered stream file, so it'll read as many bytes
// from the file as there are there and you'll get the amount of bytes read from it
//
// When you type something, the length that encodes the key might vary
// so you create a buffer and you attempt to `read` into it that will give you the input
// but certain keys and key combos have more than a single byte so you'll have to decode it
//
// Check the ANSI escape code wikipedia page for more info:
// https://en.wikipedia.org/wiki/ANSI_escape_code#Terminal_input_sequences
bool read_stdin(void) {
  char buffer[1024] = {0};
  int size = read(0, buffer, sizeof(buffer) - 1);
  if (size == -1) {
    return true;
  }
  // Check for ESC
  if (size == 1 && buffer[0] == 0x1B) {
    return true;
  }

  pretty_print_buffer(buffer, size);

  return false;
}

void pretty_print_buffer(char buffer[static 1024], const size_t bufsize) {
  const char *input_buffer_text = "input buffer: [";
  const int input_buffer_text_len = strlen(input_buffer_text); // ugh

  // Show the buffer contents in hex
  mvaddstr(0, 0, input_buffer_text);
  char byte_str_buf[3] = {0};
  for (int i = 0; i < bufsize; ++i) {
    sprintf(byte_str_buf, "%02X", buffer[i]);
    int x = input_buffer_text_len + (i * 4);
    mvaddnstr(0, x, byte_str_buf, 2);
    if (i != bufsize - 1)
      mvaddch(0, x + 2, ',');
  }
  mvaddch(0, input_buffer_text_len + (bufsize * 4) - 2, ']');
}

int main(void) {
  // Initialize ncurses
  initscr();
  curs_set(1);
  noecho();
  nodelay(stdscr, TRUE);
  raw();
  printw("press a button to see it's byte representation");
  refresh();

  // Main loop
  for (;;) {
    clear();
    bool err = read_stdin();
    if (err) {
      break;
    }
    refresh();
  }

  endwin();
  return 0;
}

@daniilrozanov
Copy link

Is this crossplatform? I tested it on linux and pressed key up/down and it's codes looks like specific for each terminal/OS.
Is there any way to combine the raw asynchronous read approach with converting the received characters back to the values declared in ncurses (KEY_*)? And is it necessary? Your solution is very good in that you can use asynchronous input (even asio if I write in c++), but it seems to lose usability and portability. Maybe I'm wrong

@osa1
Copy link
Author

osa1 commented Mar 25, 2025

You got it right, this solution is not portable. I'm not aware of any standards that map e.g. ctrl + shift + F1 to a byte sequence for the terminal implementers to use. However I tested this kind of complex key sequences many years ago and what I found was that some (maybe most) terminals follow xterm, so in tiny I decided to use the byte sequences used by xterm, in the term_input library.

I've been maintaining tiny since 2017 and we have some users, and no one complained so far that e.g. alt + right_arrow isn't working in their terminal. It may be that we don't have too many users, or they all use the same few terminals, I'm not sure.

@daniilrozanov
Copy link

@osa1 Good. I figured out how to work with it. And yet, if I don't want to manually match sequences in the code, is there any library (possibly inside ncurses) that already knows how to do this? Thanks

@osa1
Copy link
Author

osa1 commented Mar 25, 2025

@daniilrozanov In tiny I have my own macro for this.

Usage: https://github.com/osa1/tiny/blob/54fecca31b6d10d41d8f54c4b806674ddc76740f/crates/term_input/src/lib.rs#L99-L210
Macro implementation: https://github.com/osa1/tiny/tree/54fecca31b6d10d41d8f54c4b806674ddc76740f/crates/term_input_macros

The macro generates matching code that checks each input byte once.

I'm not aware of any other libraries that do this.

@daniilrozanov
Copy link

daniilrozanov commented Mar 25, 2025

Hello again. I figured out how to do this portable.

#include <stdio.h>
#include <stdlib.h>
#include <termcap.h>

#define fatal(msg)                                                             \
  {                                                                            \
    printf(msg);                                                               \
    exit(1);                                                                   \
  }
#define fatall(msg, arg)                                                       \
  {                                                                            \
    printf(msg, arg);                                                          \
    exit(1);                                                                   \
  }

int main() {

#ifdef unix
  static char term_buffer[2048];
#else
#define term_buffer 0
#endif

  char *termtype = getenv("TERM");
  int success;

  success = tgetent(term_buffer, termtype);
  if (success < 0)
    fatal("Could not access the termcap data base.\n");
  if (success == 0)
    fatall("Terminal type `%s' is not defined.\n", termtype);

  char *DW;
  char *UP;

  DW = tgetstr("kd", 0); // kd means key down.
  UP = tgetstr("ku", 0); // ku - key up
  printf("%s\n", BC);
  printf("%s\n", UP);
  return 0;
}

termcap is the library that help to access to all sorts of terminal's capabilities. Access gets through 2 char alias for capability. For example tgetstr("ku", 0) will make UP contain [27, 79, 65] on my terminal (same as in your code)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment