Table of Contents
To deal with hardcoded or configurable key events in a cross-platform terminal application written in Rust, you'll probably need to
- determine what keybindings you can really use,
- use convenient hardcoded keybindings,
- and read keybindings from a configuration file
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 https://github.com/Canop/keybindings-example.
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)?;
w.flush()?;
let e = event::read();
match e {
Ok(Event::Key(key_event)) => match key_event.into() {
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
- waiting for events with
event::read
which uses Crossterm's read poll function - matching either for key events or for resize events, and disregarding other events (mouse events)
- concise matching of key combinations with crokey's
key!
macro, likekey!(ctrl-c)
- that such a loop can be straightforward and easy to read or evolve
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
[keybindings]
alt-i = "Increment"
d = "Decrement"
ctrl-q = "Quit"
We use serde to parse it into the following structure:
#[derive(Deserialize)]
struct Config {
keybindings: HashMap<KeyCombination, 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
if let Ok(Event::Key(key)) = e {
let key = key.into();
println!("You've hit {} ", fmt.to_string(key).yellow());
if key == key!(ctrl-c) { // hardcoding a security
break;
}
match config.keybindings.get(&key) {
Some(Action::Increment) => {
hit_points += 1;
}
Some(Action::Decrement) => {
hit_points -= 1;
}
Some(Action::Quit) => {
println!("bye!");
break;
}
None => {}
}
println!(" You have {hit_points} hit points left");
if hit_points == 0 {
println!(" {}", "You die!".red());
break;
}
}
The key parts are
- still using a hard-coded key,
key!(ctrl-c)
, just in case, for quitting the application - converting a
KeyEvent
into aKeyCombination
- getting an
Option<Action>
from the key withconfig.keybindings.get(&key)
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!