Input, Keymaps & Focus #

There are no event handlers in eye-declare. Keys resolve to messages through a Keymap — a plain value your app rebuilds from the model on every update:

fn keymap(&self) -> Keymap<Msg> {
let mut km = keymap()
.on_override(key(KeyCode::Char('c')).ctrl(), Msg::Quit)
.on(key(KeyCode::Esc), Msg::Cancel);
if !self.busy() && !self.input.is_blank() {
km = km.on(key(KeyCode::Enter), Msg::Submit);
}
km.fallthrough(&self.input_focus, Msg::Input)
}

Because the keymap is rebuilt from state, conditional bindings are just if statements. Enter means "send" only when sending is meaningful; Tab can mean "accept suggestion" only while a suggestion exists. Key conflicts between modes become structurally impossible — there's never a stale handler lying around from a state you've left.

Dispatch order #

For each key event, first match wins, in declaration order within each tier:

  1. on_override — fires regardless of focus. For Ctrl+C-tier chords only.
  2. in_scope(&handle, …) — active while that FocusHandle is focused.
  3. on — global bindings.
  4. fallthrough(&handle, mapper) — everything unclaimed (keys and pastes) becomes a message while the handle is focused. This is how a text input receives ordinary typing without the framework owning any editing logic.

One rule of thumb from building real apps on this: if a binding's condition ladder keeps growing, the policy belongs in update. Prefer Esc → Msg::Cancel with update deciding what cancelling means in the current state, over an Esc binding that re-derives the state machine in the keymap.

Focus is data #

let focus = Focus::new();
let input_focus = focus.handle(); // handles share one current-focus cell
input_focus.focus();

A Focus system hands out FocusHandles that share a single "currently focused" cell, so exactly one handle is focused at a time by construction. Handles live in your model; focus()/blur() are ordinary calls in update. "Press / to focus search" is one binding and one line. There's no registry, no autofocus lifecycle, no Tab order unless you bind Tab to a message that cycles.

Composing keymaps #

Sub-models bring their own keymaps; two combinators embed them:

// Re-target a child keymap's messages into the parent's Msg…
let child = SelectState::keymap().map(Msg::Select);
// …and append it. Within each tier, earlier declarations win, so a
// parent merging after its own bindings keeps priority on contested keys.
km = km.on(key(KeyCode::Enter), Msg::Confirm).merge(child);

See components for the full sub-model pattern.

Keyboard protocol #

Some chords — Shift+Enter above all — are indistinguishable in the legacy terminal keyboard protocol. Request the kitty protocol where available:

let options = RunOptions::default().keyboard(KeyboardProtocol::Enhanced);
driver_tokio::run_with(app, options).await?;

Enhanced silently falls back to legacy on terminals without support, so bind a fallback chord (Ctrl+J is the convention) alongside Shift+Enter.

For full control there is Custom { flags, probe }: an explicit kitty flag set (e.g. adding REPORT_ALTERNATE_KEYS when your keybinding layer depends on how modified keys arrive). With probe: true the terminal is asked first — one query round-trip — and unsupporting terminals fall back to legacy. With probe: false the flags are pushed blind: no round-trip, and terminals that ignore the protocol ignore the push and the matching pop at teardown. Blind pushing is the right call when you are matching the behavior of an app that always pushed, or when startup latency over SSH matters more than tidiness on ancient terminals.

use crossterm::event::KeyboardEnhancementFlags as Flags;
let options = RunOptions::default().keyboard(KeyboardProtocol::Custom {
flags: Flags::DISAMBIGUATE_ESCAPE_CODES | Flags::REPORT_ALTERNATE_KEYS,
probe: false,
});

Mouse #

With RunOptions::default().mouse_capture(true), mouse events arrive as InputEvent::Mouse through the keymap fallthrough — keymaps resolve key bindings only, so mouse handling is app logic like any other unclaimed event:

.fallthrough(&self.focus, |ev| match ev {
InputEvent::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => Msg::ScrollUp,
MouseEventKind::ScrollDown => Msg::ScrollDown,
_ => Msg::Noop,
},
// InputEvent is non-exhaustive; keep a wildcard arm.
_ => Msg::Noop,
})

Capture is off by default because it takes over the terminal's native mouse behavior (text selection, copy). Leave it off unless the events buy the user something.