WhichKey in Ratatui
This is a writeup for a Rust crate I've recently released: https://crates.io/crates/ratatui-which-key
Overview
Managing keybindings in a TUI application is tedious. Every project reinvents the same pattern: a big match statement, a help screen nobody reads, and a growing collection of key conflicts. I got tired of rebuilding this from scratch every time, so I wrote ratatui-which-key.
It's an application-level input handler, scope-based focus manager, and popup widget for ratatui — inspired by folke's which-key.nvim plugin for Neovim. The idea is simple: define your keymaps declaratively, let the crate handle input routing, and get a discoverable popup for free.
Basic Concepts
The crate's core types are generic over four type parameters. These are the building blocks you'll define for your application:
K— Key- The key type from your backend. With the
crosstermfeature, this iscrossterm::event::KeyEvent. S— Scope- An enum representing the current mode or focus context. For example,
NormalandInsertmodes. The same key can produce different actions depending on the active scope. A— Action- An enum representing the commands your app can perform. Each keybinding maps to an action variant.
C— Category- An enum for grouping actions in the popup display. Categories appear as section headers in the which-key popup.
These four types flow through every part of the API. A Keymap<K, S, A, C> holds your bindings, a WhichKeyState<K, S, A, C> handles input and drives the popup, and the WhichKey widget renders it. In practice, you'll use a type alias to keep things readable:
use crossterm::event::KeyEvent;
use ratatui_which_key::{Keymap, WhichKeyState};
// "Scope", "Action", "Category" are defined in the next section
pub type WhichKeyMap = Keymap<KeyEvent, Scope, Action, Category>;
pub type WhichKeyInstance = WhichKeyState<TermKeyEvent, Scope, Action, Category>;
struct App {
which_key: WhichKeyInstance,
should_quit: bool,
// ...your other app state
}
Simple Keybindings
Let's start with the types. Define an Action enum for the things your app can do, and a Category enum to group them in the popup display. Each Action needs a Display implementation, so I'll use derive_more::Display to make it easy:
use derive_more::Display;
#[derive(Clone, Debug, Display)]
pub enum Action {
Quit,
ToggleHelp,
GoDown,
GoUp,
}
#[derive(Clone, Debug, Display)]
pub enum Category {
General,
Navigation,
}
To create a keymap, we use Keymap::default() followed by the Scope we want the bindings to work with:
use ratatui_which_key::Keymap;
let keymap = Keymap::default()
.scope(Scope::Normal, |b| {
b.bind("q", Action::Quit, Category::General)
.bind("?", Action::ToggleHelp, Category::General)
.bind("j", Action::GoDown, Category::Navigation)
.bind("k", Action::GoUp, Category::Navigation);
});
The first argument to .scope() is the scope. Think of the scope as whatever currently has focus in the app. In the above example, every binding inside the closure is scoped to Scope::Normal. This means that we must be in "Normal" mode for these bindings to work. Note that the Scope enum is application-defined, so you create whatever works for your application.
To use the keymap, we wrap it in a WhichKeyState, set the starting scope, and throw it into the App:
use ratatui_which_key::WhichKeyState;
let which_key = WhichKeyState::new(keymap, Scope::Normal);
let app = App {
which_key,
should_quit: false,
};
And now in the event loop for the app, we send the event (the crossterm event) to the which-key state:
// (wherever your event processing happens)
if let Some(action) = app.which_key.handle_event(event).into_action() {
match action {
Action::Quit => should_quit = true,
Action::ToggleHelp => app.which_key.toggle(),
Action::GoDown => /* ... */,
Action::GoUp => /* ... */,
}
}
Multi-key Sequences
Single-key bindings only get you so far. Real applications need leader keys and prefix groups, like pressing Space then w to save, or g then g to go to the top. ratatui-which-key handles this with sequences.
By default, the leader key is Space. You can customize it with Keymap::with_leader(). Inside a binding string, use <leader> to reference it. Other special key names follow a familiar convention:
<enter>,<esc>,<tab>— named keys<c-x>— Ctrl combinations (e.g.<c-s>for Ctrl+S)<F1>through<F12>— function keys
Use describe_group() to give a human-readable name to a prefix group. This name appears in the popup when the prefix is pressed:
let keymap = Keymap::default()
.describe_group("<leader>", "<leader>")
.describe_group("g", "goto")
.scope(Scope::Normal, |b| {
b.bind("q", Action::Quit, Category::General)
.bind("<leader>w", Action::Save, Category::General)
.bind("<leader>q", Action::Quit, Category::General)
.bind("gg", Action::GoTop, Category::Navigation)
.bind("ge", Action::GoEnd, Category::Navigation);
});
Note
If you forget to describe_group(), then ... will be displayed as a fallback.
When the user presses <leader>, the popup appears showing all bindings that start with that prefix. In this case w Save and q Quit. Same thing for the g prefix: pressing g shows g GoTop and e GoEnd. The popup auto-dismisses when a full sequence is matched or when the sequence is invalid. You can also press the backspace key to go back while in the middle of a sequence.
Scopes
As briefly mentioned previously, scopes represent whatever currently has focus in your app.
To illustrate how scopes work, we can use a task manager example. The app could have a list panel with task titles and a detail panel explaining about the task. We want to use the same keys to do different things depending on which panel is active. For example, j moves the cursor down while the task list has focus so we can select a task, but j scrolls content when the detail panel has focus so we can read it all.
Define a Scope enum to represent your application's focus contexts:
#[derive(Clone, Debug, PartialEq)]
pub enum Scope {
List,
Details,
}
Then register bindings for each scope using .scope():
let keymap = Keymap::default()
.scope(Scope::List, |b| {
b.bind("q", Action::Quit, Category::General)
.bind("<enter>", Action::OpenDetails, Category::General)
.bind("j", Action::CursorDown, Category::Navigation)
.bind("k", Action::CursorUp, Category::Navigation);
})
.scope(Scope::Details, |b| {
b.bind("q", Action::Quit, Category::General)
.bind("<esc>", Action::CloseDetails, Category::General)
.bind("j", Action::ScrollDown, Category::Navigation)
.bind("k", Action::ScrollUp, Category::Navigation);
});
Notice that q quit is in both scopes. For bindings you want available everywhere, you'll need to define them in all scopes.
You can switch scopes at runtime by calling set_scope() on the WhichKeyState:
match action {
Action::OpenDetails => state.set_scope(Scope::Details),
Action::CloseDetails => state.set_scope(Scope::List),
// ...
}
When the scope changes, the active keymap changes with it. Pressing j in Scope::List produces Action::CursorDown. Pressing j in Scope::Details produces Action::ScrollDown. The input handler routes keys based on the current scope automatically.
The WhichKey Popup
The popup is a Ratatui widget that shows available keybindings, grouped by category. It renders automatically when a prefix key is pressed (showing possible continuations) and dismisses when a full sequence is matched or if no sequence is matched.
Create a WhichKey widget and customize it with builder methods:
use ratatui_which_key::{WhichKey, PopupPosition};
use ratatui::style::{Color, Style};
fn ui(frame: &mut Frame, app: &App) {
// ... render your app's widgets ...
let widget = WhichKey::new()
.position(PopupPosition::BottomRight)
.max_height(12)
.border_style(Style::default().fg(Color::Green));
widget.render(frame.buffer_mut(), &app.which_key);
}
The available customization options:
position()—BottomLeft,BottomRight,TopLeft, orTopRightmax_height()— when bindings exceed this height, the popup auto-flows into multiple columnsborder_style(),key_style(),description_style(),category_style()— fine-grained style control
You can also control the popup programmatically. Call which_key.toggle() to manually show or hide it, and which_key.dismiss() to close it immediately. The popup only renders when which_key.active is true or there are pending keys in a partial sequence, so it won't clutter your UI when idle.
Catch-all Handlers
It's not reasonable to bind every single key you might need. For example, if you have a text editor in insert mode, you need to handle any printable character for text input. You wouldn't write a bind() call for every Unicode code point. This is where .catch_all() comes in.
Inside a .scope() closure, call catch_all() with a closure that receives the raw key event and returns Option<Action>:
keymap.scope(Scope::Insert, |b| {
b.bind("<esc>", Action::EnterNormal, Category::General)
.catch_all(|key: KeyEvent| {
if let KeyCode::Char(ch) = key.code {
Some(Action::TypedChar(ch))
} else {
None
}
});
});
Returning Some(action) dispatches that action through the normal event pipeline. Returning None dismisses the popup without producing an action. This lets you filter by key type. In the example above, only Char keys produce an action and everything else is silently ignored.
Non-keyboard Events
The crossterm feature gate provides two extension traits: CrosstermKeymapExt and CrosstermStateExt. These add support for mouse, resize, and focus events beyond keyboard input.
Register handlers on the Keymap:
use ratatui_which_key::CrosstermKeymapExt;
use crossterm::event::{MouseEventKind, MouseButton};
keymap
.on_mouse(|event, _scope| {
if let MouseEventKind::Down(MouseButton::Left) = event.kind {
Some(Action::Click(event.column, event.row))
} else {
None
}
})
.on_resize(|cols, rows, _scope| Some(Action::Resized(cols, rows)));
The handle_event() method on state returns an EventResult enum with variants for each event type: Key(Option<A>), Mouse(Option<A>), Resize(Option<A>), and so on. Match on the variant to handle each kind:
use ratatui_which_key::CrosstermStateExt;
match state.handle_event(event) {
EventResult::Key(Some(action)) => { /* handle key action */ }
EventResult::Mouse(Some(action)) => { /* handle mouse action */ }
EventResult::Resize(Some(action)) => { /* handle resize */ }
_ => {}
}
Note
These handlers also receive the current scope as a parameter, so you can make mouse and resize behavior scope-aware. For example, a click might select an item in Normal mode but position the cursor in Insert mode.
Extension Traits
After using ratatui-which-key for a while, you'll notice that .scope() closures get verbose when every call specifies the category explicitly:
b.bind("q", Action::Quit, Category::General)
.bind("?", Action::ToggleHelp, Category::General)
.bind("j", Action::GoDown, Category::Navigation)
.bind("k", Action::GoUp, Category::Navigation);
You can achieve cleaner bindings this by defining an extension trait. Here's the pattern I use in my projects:
use ratatui_which_key::ScopeBuilder;
use crossterm::event::KeyEvent;
trait AutoCategoryBindExt {
fn general(&mut self, sequence: &str, action: Action) -> &mut Self;
fn navigation(&mut self, sequence: &str, action: Action) -> &mut Self;
}
impl AutoCategoryBindExt for ScopeBuilder<'_, KeyEvent, Scope, Action, Category> {
fn general(&mut self, sequence: &str, action: Action) -> &mut Self {
self.bind(sequence, action, Category::General)
}
fn navigation(&mut self, sequence: &str, action: Action) -> &mut Self {
self.bind(sequence, action, Category::Navigation)
}
}
Using this trait, the keymap definition becomes:
let keymap = Keymap::default()
.describe_group("<leader>", "<leader>")
.describe_group("g", "goto")
.scope(Scope::Normal, |b| {
b.general("q", Action::Quit)
.general("?", Action::ToggleHelp)
.navigation("j", Action::GoDown)
.navigation("k", Action::GoUp)
.navigation("gg", Action::GoTop)
.navigation("ge", Action::GoEnd)
.general("<leader>w", Action::Save)
.general("<leader>q", Action::Quit);
});
The category is implicit in the method name. When you add new categories, just add a new method to the trait. This is a small quality-of-life improvement that scales well as your keymap grows.
Conclusion
ratatui-which-key handles three things that every TUI application needs: input routing with multi-key sequences, scope-based focus management, and a discoverable popup that shows available bindings on demand. Instead of hand-rolling match statements and help screens, you define your keymaps declaratively and let the crate do the work.
The crate is available on crates.io and the source is on GitHub.
Comments