---
date: 2026-04-11T21:12:49-0700
title: WhichKey in Ratatui
slug: "project-ratatui-which-key"
description: A crate for Ratatui that enables WhichKey-like functionality
author: jayson
tags: rust, projects
---

This is a writeup for a Rust crate I've recently released: [https://crates.io/crates/ratatui-which-key](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](https://github.com/folke/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 `crossterm` feature, this is `crossterm::event::KeyEvent`.

`S` — Scope
: An enum representing the current mode or focus context. For example, `Normal` and `Insert` modes. 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:

```rust
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:

```rust
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:

```rust
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`:

```rust
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:

```rust
// (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:

```rust
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:

```rust
#[derive(Clone, Debug, PartialEq)]
pub enum Scope {
    List,
    Details,
}
```

Then register bindings for each scope using `.scope()`:

```rust
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`:

```rust
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:

```rust
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`, or `TopRight`
- `max_height()` — when bindings exceed this height, the popup auto-flows into multiple columns
- `border_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>`:

```rust
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`:

```rust
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:

```rust
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:

```rust
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:

```rust
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:

```rust
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](https://crates.io/crates/ratatui-which-key) and the source is on [GitHub](https://github.com/jayson-lennon/ratatui-which-key).
