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) };
}
@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