17 releases (10 breaking)
Uses new Rust 2024
| new 0.12.0 | May 22, 2026 |
|---|---|
| 0.9.0 | May 2, 2026 |
| 0.5.0 | Mar 29, 2026 |
#158 in Command-line interface
235KB
4.5K
SLoC
ratatui-which-key
An application-level input handler and focus manager for ratatui applications with an API and popup widget inspired by folke's which-key.nvim.

ratatui-which-key is designed to handle all of the keyboard input for your application. You route input events to it from the main event loop, and it returns an Action to perform based on your configured keybinds.
Check out the docs for more info.
There is also a sample application that you can run with cargo run --example demo which shows how to perform bindings and set up ratatui-which-key for usage in an app.
How It Works
ratatui-which-key requires three data types be defined in your application.
Scopes
The scope is what part of your application is currently "in focus":
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Scope {
Normal,
Insert,
SearchPanel,
// ....
}
// To change focus to another pane/window/section/area/etc:
app.which_key.set_scope(Scope::Insert)
Actions
ratatui-which-key returns an Action when a keybind is triggered:
// Must implement Display to show the names in the which-key popup.
#[derive(derive_more::Display, Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
#[display("quit")]
Quit,
#[display("toggle help")]
ToggleHelp,
#[display("move up")]
MoveUp,
#[display("save")]
Save,
// ...
}
Categories
The ratatui-which-key popup displays a header for each category available for a scope and then lists the associated keybinds under that category.
// Must implement Display to show the category names in the which-key popup.
#[derive(derive_more::Display, Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
General,
Navigation,
Search,
// ...
}
Keymap Configuration
You'll need to put a WhichKeyState<KeyEvent, Scope, Action, Category> at the top-level of your application (like in App). Then at program start, configure your keybinds by creating a new Keymap. The code comments explain the different ways of performing keybindings.
use crossterm::event::KeyEvent;
struct App {
// `Scope`, `Action`, and `Category` are all types defined in your application.
which_key: WhichKeyState<KeyEvent, Scope, Action, Category>,
}
let mut keymap = Keymap::new();
keymap
// Keys can be bound individually by specifying both the category and scope.
.bind("?", Action::ToggleHelp, Category::General, Scope::Global)
// Sequences are supported. This binds to sequence "sg".
.bind("sg", Action::SearchGrep, Category::General, Scope::Global)
// "describe_group" adds a description to groups. Display will default to "..." if
// no group description is found.
.describe_group("<space>", "<leader>") // (key sequence, description)
.describe_group("<leader>g", "general")
// Bindings can be added to a specific group while also providing a description.
.group("s", "search", |g| {
// "sf" binding
g.bind("f", Action::SearchFiles, Category::General, Scope::SearchPanel)
// "sb" binding
.bind("b", Action::SearchBuffers, Category::General, Scope::SearchPanel);
})
// However, using `.scope` is recommended in most cases since scopes represent whatever is
// currently "in focus" for your app.
.scope(Scope::Global, |global| {
global
.bind("?", Action::ToggleHelp, Category::General)
.bind("j", Action::MoveDown, Category::Navigation)
// control keys supported
.bind("<c-c>", Action::Quit, Category::General)
// f-keys supported
.bind("<F1>", Action::ToggleHelp, Category::General)
// sequences supported
.bind("<leader>w", Action::Save, Category::General)
// sequences can start with any key
.bind("gof", Action::OpenFile, Category::General)
// Group descriptions can differ per scope. "t" shows "text operations"
// in Global scope and "tab management" in Insert scope (see below).
.describe_group("t", "text operations")
.bind("tw", Action::ToggleWhitespace, Category::General);
})
.scope(Scope::Insert, |insert| {
// Same "t" prefix, different group name in Insert scope.
insert
.describe_group("t", "tab management")
.bind("tn", Action::NewTab, Category::General);
// While in the `Insert` scope, all keys will be routed to this handler.
insert.catch_all(|key: KeyEvent| {
use crossterm::event::{KeyCode, KeyModifiers};
// You can filter the keys here
match key.code {
KeyCode::Char(ch) => Some(Action::InsertModePrintableChar(ch)),
KeyCode::Esc => Some(Action::ToNormalMode),
_ => None
}
});
})
// Helper method if you want to bind based on category.
.category(Category::Navigation, |nav| {
nav
.bind("k", Action::MoveUp, Scope::Global)
.bind("j", Action::MoveDown, Scope::Global);
})
// Helper method if you want to bind based on both scope and category.
.scope_and_category(Scope::Global, Category::Navigation, |g| {
g.bind("<leader>gg", Action::MoveUp)
.bind("<leader>gd", Action::MoveDown);
});
// Create new state with a keymap and initial scope.
app.which_key = WhichKeyState::new(keymap, Scope::Global);
Input Handling
To route events to ratatui-which-key:
use ratatui_which_key::CrosstermStateExt;
if let Some(action) = app.which_key.handle_event(event).into_action() {
match action {
Action::Quit => app.should_quit = true,
Action::ToggleHelp => app.which_key.toggle(),
Action::MoveUp => (), // whatever you want to happen
Action::Save => (),
// ...
}
}
Mouse and Terminal Event Handlers
You can register handlers for mouse, resize, and focus events on your keymap. The current Scope is provided as part of the handler, so you can choose to return actions globally or confine them to specific scopes:
use ratatui_which_key::CrosstermKeymapExt;
let mut keymap = Keymap::new();
keymap
// `on_mouse` receives a `crossterm::event::MouseEvent` and the current scope
.on_mouse(|event, scope| {
use crossterm::event::{MouseButton, MouseEventKind};
if let MouseEventKind::Down(MouseButton::Left) = event.kind {
Some(Action::MouseClick(event.column, event.row))
} else {
None
}
})
// `on_resize` receives the new terminal dimensions (cols, rows) and the current scope
.on_resize(|cols, rows, scope| {
Some(Action::Resized(cols, rows))
})
// `on_focus_gained` and `on_focus_lost` receive the current scope
.on_focus_gained(|scope| {
Some(Action::Focused)
})
.on_focus_lost(|scope| {
Some(Action::Unfocused)
});
Rendering
To render:
// (in your top-level render function)
if app.which_key.active {
let widget = WhichKey::new().border_style(Style::default().fg(Color::Green));
widget.render(frame.buffer_mut(), &mut app.which_key);
}
License
Dependencies
~13–20MB
~305K SLoC