| title | Keymap | ||
|---|---|---|---|
| description | Declare, dispatch, and query host-agnostic key bindings, chords, sequences, and mode-gated layers. | ||
| sidebar |
|
A host-agnostic binding engine: declare an application's key bindings once, as data, and resolve incoming key presses to named commands. It sits beside the component gallery and terminal features as a third kind of tuika primitive — not a widget and not an escape sequence, but the routing layer between input and behavior.
It follows the register → dispatch → query shape of
OpenTUI's keymap, adapted to
idiomatic Rust and to tuika's own Key events. Because it consumes the
already-translated Key — never a terminal type — the whole engine runs and
unit-tests without a PTY.
API
Four types, smallest to largest:
Chord— one key press plus its modifiers (ctrl+r,alt+shift+tab,?,A,space, the literalctrl++). Character tokens are exact logical text, after the active keyboard layout. Parse a chord from a string withChord::parse, or build it from a live event withChord::from_key.KeySequence— one or more chords typed in order, written space-separated (g g,ctrl+x s). A single chord is a one-element sequence, so every binding is a sequence underneath.Layer— a named, prioritized group of bindings. A layer may be gated on runtime data (when("mode", "search")) so it is active only in a given application mode; an ungated layer is always active.Keymap<C>— the engine. It owns the layers, a small runtime-data store used to gate them, and the pending multi-stroke state.Cis your command type — usually an enum.
Bindings are built declaratively and resolve to a command value of your choosing:
use tuika::keymap::{Keymap, Layer};
#[derive(Clone, Debug, PartialEq)]
enum Action { Search, Quit, Top, Close }
let mut keymap = Keymap::new()
.layer(
Layer::new("global")
.bind_labeled("ctrl+r", "search", Action::Search)
.bind("ctrl+d", Action::Quit)
// A two-stroke sequence, vim-style.
.bind("g g", Action::Top),
);bind (and bind_labeled, which also carries a help string) panics on a
malformed spec, because bindings are authored as static literals — a bad spec is
a programmer error caught on first run, like a malformed regex literal. For
config-sourced specs, use Chord::parse / Layer::try_bind, which return a
Result.
Feed each translated Key to dispatch. It returns one of three outcomes:
use tuika::event::{Key, KeyCode};
use tuika::keymap::Dispatch;
let ctrl_r = Key { code: KeyCode::Char('r'), ctrl: true, alt: false, shift: false };
match keymap.dispatch(ctrl_r) {
Dispatch::Command(action) => { /* run it */ }
Dispatch::Pending => { /* a multi-stroke sequence is mid-flight */ }
Dispatch::Unmatched => { /* no binding — handle the key yourself */ }
}- Single-stroke bindings resolve immediately.
- Multi-stroke sequences accumulate: the first
gofg greturnsPending, the second returnsCommand(Top). - An exact match wins immediately even when it is also the prefix of a
longer binding — a bound
gfires without waiting to see whetherg gfollows, so author overlapping bindings with that in mind. - A sequence that dead-ends is dropped, and its final stroke is retried on
its own so it can begin a fresh sequence.
Keymap::resetabandons a pending sequence explicitly (e.g. on a focus change or a timeout tick — the engine keeps no clock of its own).
A key that no active binding matches returns Unmatched, so the host stays in
control of everything the keymap does not claim (typing into a text field,
say).
Matching is on a normalized chord, so a binding matches the event a terminal
actually delivers. A character key is the exact logical Unicode character
produced by the active keyboard layout: ? arrives as Char('?'), not
Shift+Char('/'). Bind ?, A, or ctrl+R directly. This is portable to
non-US layouts because the keymap never guesses which physical key plus Shift
produces that text.
Accordingly, shift+character specs are rejected instead of silently losing
Shift; Chord::parse and Layer::try_bind return KeyParseError, while static
Layer::bind fails fast as usual. Shift remains a distinct modifier for
non-character keys (Shift+Enter). Shift+Tab folds to the distinct
BackTab key terminals report. Chord::from_key drops any separately reported
Shift flag from character events because its effect is already present in the
character. Parsed bindings and live events therefore share one layout-neutral
identity.
set_data drives which layers are active, mirroring an application's modes. A
layer's when clauses must all match the current data for it to be active;
changing data recomputes activation and clears any now-impossible pending
sequence.
let mut keymap = Keymap::new()
.layer(Layer::new("global").bind("ctrl+d", Action::Quit))
.layer(
Layer::new("panel")
.when("mode", "panel")
.bind("q", Action::Close),
);
// `q` does nothing until the app enters panel mode…
keymap.set_data("mode", "panel");
// …now it closes the panel, while ctrl+d still quits in every mode.When two active layers bind the same sequence, the higher priority wins, so an
overlay layer can shadow a global binding while it is up.
hints() lists the currently-active bindings — key label, optional help text,
command, layer name, and layer priority. The component adapters consume the same
declarations directly, so dispatch, a responsive footer, and a complete help
screen cannot drift apart:
use tuika::components::{KeyHints, KeymapHelp};
let footer = KeyHints::from_keymap(&keymap);
let help = KeymapHelp::from_keymap(&keymap);KeyHints fits only complete hints: it keeps higher-priority layer bindings
first on narrow screens and never clips halfway through a key/action pair.
KeymapHelp::offset supports a vertically scrollable help overlay. Because both
query active bindings, changing keymap runtime data updates the displayed mode.
The engine never touches the terminal, so behavior is tested by feeding
synthetic Keys and asserting the Dispatch outcome — no raw mode, no PTY:
let mut keymap = Keymap::new().layer(Layer::new("nav").bind("g g", Action::Top));
let g = Key::new(KeyCode::Char('g'));
assert_eq!(keymap.dispatch(g), Dispatch::Pending);
assert_eq!(keymap.dispatch(g), Dispatch::Command(Action::Top));