Input routing
A frame usually has more than one place an event could go: a composer, a picker over it, a modal that just opened, a transcript that scrolls behind everything. Deciding which one receives this event is policy, and tuika owns it the way it owns layout and focus.
FocusRegistry
already answers who is active — an overlay owner beats the base focus ring —
and Scene::sync_focus resolves overlay z-order into that owner.
Router is the
step after it: given that state, it hands the event to the right surface’s state,
for every event kind at once.
API
Why it is a toolkit concern
Written by hand, the step is one if focus.is_active("composer") per surface,
per event kind. That scales badly in a specific and dangerous way: keys go
through one function and pastes through another, only the key path learns about
a new overlay, and a pasted secret lands in the composer behind an open
prompt. The overlay was correct, the registry was correct, and the event still
went to the wrong place — because the route was host code written twice.
One route removes the second path:
- Ownership gates delivery.
targetreaches only the active surface. - One route per event kind. Stages take the whole
Event, so a new variant cannot miss a surface. - Precedence is declared, not re-derived. It comes from the registry the scene already synchronized.
- Global chords compose. They are stages, not a position in an
ifchain. - Fallthrough is observable.
Deliverysays who received what. - It tests without a terminal, like layout.
The stages
A Router is built per update, and stages run eagerly in this order. Each is
skipped once something has consumed the event; calling them out of order trips a
debug assertion.
| Stage | Reaches | Typical use |
|---|---|---|
pre_fn |
everything, first | interrupt and quit chords |
always / always_fn |
a named surface, focused or not | a transcript that keeps scrolling; a picker that claims its navigation keys and leaves the rest |
target / target_fn |
the active surface only | the composer, the open dialog |
fallback_fn |
everything, last | navigation and help, when nothing claimed the event |
always is the deliberate hole in ownership. It is a named call rather than an
implied exception, so “what reaches a surface that does not own input” is
greppable.
Routing an event
use tuika::prelude::*;
fn update(app: &mut App, event: &Event) {
// 1. Declare the frame's ownership. A host whose modal is an overlay gets
// this from `Scene::sync_focus`; declare it directly otherwise.
app.focus.begin_frame();
app.focus.register("composer");
if app.dialog.is_some() {
app.focus.set_owner("dialog");
} else {
app.focus.clear_owner();
}
// 2. Route. One registration per surface, covering every event kind.
let mut quit = false;
let mut router = Router::new(&app.focus, event);
router.pre_fn(|event| match event {
Event::Key(key) if key.ctrl && key.code == KeyCode::Char('c') => {
quit = true;
InputOutcome::Cancelled
}
_ => InputOutcome::Ignored,
});
router.always_fn("transcript", |event| {
app.scroll.handle(event, app.content_h, app.viewport_h)
});
router.target_fn("dialog", |event| app.handle_dialog(event));
router.target("composer", &mut app.composer);
let delivery = router.finish();
// 3. Apply what the stages decided.
if quit { app.quit(); }
if !delivery.consumed() {
// Nobody claimed it — a state a host can log instead of losing.
}
}Router::new reads the registry and does not hold it, so a stage closure is free
to take &mut on the same host struct the registry lives on.
Targets
target and always take any
InputTarget
— implemented for tuika’s single-Event input states (TextInputState,
SingleLineInputState, CompletionState, ConfirmDialogState,
InputDialogState, SliderState). A state whose handle needs frame context
(ScrollState wants the content and viewport heights) or a host type that is not
a tuika state routes through the *_fn form instead, so adopting the router
never requires rewriting a surface first.
Stages decide, the host applies
Keep a stage closure to the surface it routes to, and apply anything wider — quit,
submit, opening another surface — after finish(). It keeps a stage from needing
a borrow of the whole application mid-route, and keeps the route readable as a
list of surfaces.
What a Delivery reports
let delivery = router.finish();
delivery.target; // Some("dialog") — the last receiver
delivery.stage; // RouteStage::Target
delivery.outcome; // InputOutcome::Changed
delivery.consumed(); // true
delivery.reached("composer"); // falseRouteStage::Undelivered with no target means the event reached nothing at all.
That is the case worth logging: an event that vanishes is how a routing hole
announces itself.
Testing a route
Routing asserts like layout does — no terminal, no rendering:
let mut composer = SingleLineInputState::new();
let mut prompt = SingleLineInputState::new();
let mut focus = FocusRegistry::new();
focus.begin_frame();
focus.register("composer");
focus.set_owner("prompt");
let event = Event::Paste("secret".into());
let mut router = Router::new(&focus, &event);
router.target("prompt", &mut prompt);
router.target("composer", &mut composer);
assert!(router.finish().reached("prompt"));
assert_eq!(prompt.text(), "secret");
assert!(composer.text().is_empty());Mouse
The pointer still resolves by geometry through
HitMap: keys
route by ownership, the pointer routes by position. A host that wants a click to
move focus resolves the id from the hit map, calls FocusRegistry::focus, and
routes from there.
See also
- Keymap — turning key presses into named commands, which a
pre_fnorfallback_fnstage dispatches. examples/codex— a full host: a modal, a picker, a composer, and a transcript on one route.