Manage keybindings in a Rust terminal application

4 minute read Published: 2022-07-28

To deal with hardcoded or configurable key events in a cross-platform terminal application written in Rust, you'll probably need to

In this article, I'll use the crossterm library to deal with events and render stylized strings

The main example with configurable keybindings is available at

Determine what key combinations are really available

This may come as an unpleasant surprise when writing your first TUI: many key combinations just don't work, because of your terminal, or of your system.

You'll notice on first try that you can't use some of them like alt-tab. But some other ones are either silently intercepted, or because of some old conventions are coming to your application in another form. For example there's no way for your application to know whether the user pressed space or shiftspace.

To determine if a key combination is available, and how, I made a small utility: print_key.

Here's what a session looks like:


You'll also need to be able to understand what happens when a user with a different system has a problem (it may just be a buggy terminal multiplexer, or a system-wide plugin, etc.). So you should ensure your users can enable a log of the key events.

In a TUI, logging can't just be on screen, so you'll need to log to a file. I use cli-log because it needs no setup but there are various solution, just know you'll need to log key events (there will be examples in this article).

A basic event loop with hardcoded keybindings

In the mazter game, you move in a maze with the arrow keys.

Here's the main event loop:

while !(maze.is_won() || maze.is_lost()) {
    renderer.write(w, &maze)?;
    let e = event::read();
    debug!("event: {:?}", e);
    match e {
        Ok(Event::Key(key_event)) => match key_event {
            key!(q) | key!(ctrl-c) | key!(ctrl-q) => {
                return Ok(());
            key!(up) => maze.try_move_up(),
            key!(right) => maze.try_move_right(),
            key!(down) => maze.try_move_down(),
            key!(left) => maze.try_move_left(),
            key!(a) => maze.give_up(),
            _ => {}
        Ok(Event::Resize(w, h)) => {
            renderer.display = Display::Alternate(Dim::new(w as usize, h as usize));
        _ => {}

It should be about self-explanatory. You should notice

Read keybindings from a configuration file

Now to a complete example with keybindings defined in a TOML file (it could be JSON or Hjson with the same number of lines).

A session in this "game" looks like this:


Here's the config.toml configuration file we want to read:

# This config file is read when you launch the "game" with cargo run
alt-i = "Increment"
d = "Decrement"
ctrl-q = "Quit"

We use serde to parse it into the following structure:

struct Config {
    keybindings: HashMap<CroKey, Action>,

Serde does all the work, here, so our config reading code is just

let toml = fs::read_to_string(file_path)?;
let config: Config = toml::from_str(&toml)?;

With this keybindings map read, handling an event is just

match e {
    Ok(Event::Key(key)) => {
        println!("You've hit {} ", fmt.to_string(key).yellow());
        if key == key!(ctrl-c) { // hardcoding a security
        match config.keybindings.get(&key.into()) {
            Some(Action::Increment) => {
                hit_points += 1;
            Some(Action::Decrement) => {
                hit_points -= 1;
            Some(Action::Quit) => {
            None => {}
        println!(" You have {hit_points} hit points left");
        if hit_points == 0 {
            println!(" {}", "You die!".red());
    _ => {}

The key parts are

And that's all. You'll refer to the source in case you encounter a problem but that's really all you need to handle key events in a simple crossplatform TUI application.

Go Further

That was for simple applications. If you build complex ones like broot or bacon, you may want to deal with additional concerns. Tell me if you want me to address some problem like parallelism or text inputs!