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:
on_override— fires regardless of focus. For Ctrl+C-tier chords only.in_scope(&handle, …)— active while thatFocusHandleis focused.on— global bindings.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.