Core Concepts
Five things to understand: the generic types, PageSpec, TuiEffect,
ActionOutcome, and modes. Get these and the rest is detail.
The generic types
TuiPages is generic over your application's types. It knows nothing concrete —
you supply the nouns.
#![allow(unused)] fn main() { TuiPages<V, A, S, Pages, Handler, O, M> // │ │ │ │ │ │ └─ M: modal payload (default ()) // │ │ │ │ │ └──── O: overlay id (default ()) // │ │ │ │ └───────────── Handler: your TuiActionHandler // │ │ │ └──────────────────── Pages: your page provider (a fn) // │ │ └─────────────────────── S: your app state // │ └────────────────────────── A: your action enum // └───────────────────────────── V: your view/page enum }
A concrete example of each:
| example | |
|---|---|
V | Home, Settings, Editor |
A | Save, FocusNext, Quit |
S | a database handle, a document, or () for none |
O | Overlay::CommandBar |
M | DialogData<Purpose> |
O and M default to (). An app with no overlays and no modals ignores them
entirely — you only see five of these.
Use the TuiApp alias
Spelling all seven slots out is noisy, and the Pages slot forces you to repeat
V and S. The TuiApp alias collapses the common case (pages are a plain
function):
#![allow(unused)] fn main() { pub type TuiApp<V, A, S, Handler, O = (), M = ()> = TuiPages<V, A, S, PageFn<V, S, O>, Handler, O, M>; // so your app type is just: pub type App = TuiApp<View, Action, AppState, Handler, Overlay>; }
Build it with TuiPages::builder(...).page_fn(page_spec) — .page_fn pins the
function-pointer type the alias expects.
PageSpec — what a page is
For each view, your page function returns a PageSpec: the focusable elements
and the input modes that are live.
#![allow(unused)] fn main() { pub struct PageSpec<O = ()> { pub focus_targets: Vec<FocusTarget<O>>, // what you can move focus between pub modes: Vec<ModeId>, // which keymaps are active here pub accepts_text_input: bool, // does plain typing flow to a field? // (section item counts are filled in by PageFocusBuilder; see Focus) } }
Build the focus targets with PageFocusBuilder rather than by hand:
#![allow(unused)] fn main() { fn page_spec(view: &View, _state: &State, _focus: Option<&FocusTarget>) -> PageSpec { let focus = match view { View::Home => PageFocusBuilder::new().button(0).button(1), View::Notes => PageFocusBuilder::new().section_with_items(0, items.len()).button(0), }; PageSpec::new() .focus(focus) // .focus() consumes the builder .modes(vec![modes::GENERAL, modes::GLOBAL]) } }
Use .focus(builder) (not .focus_targets(builder.build())) whenever a section
declares an item count — that count travels with the section so the runtime can
step through its items on its own. See Focus.
TuiEffect — the things you can ask for
Your handler returns effects. This is the complete vocabulary:
#![allow(unused)] fn main() { pub enum TuiEffect<V, O = (), M = ()> { None, Focus(FocusIntent<O, M>), // move/open/close focus — see Focus Navigate(V), // go to a view (pushes buffer history) NextBuffer, // cycle open buffers PreviousBuffer, CloseBuffer, SplitPane(PaneSplit), // split the active pane ClosePane, NextPane, // move between panes PreviousPane, RefreshPage, // re-read the current page spec Quit, } }
The runtime applies each one for you. You never call into the focus manager or the buffer state directly — you return one of these.
ActionOutcome — wrapping effects
Your handler returns an ActionOutcome, which is just a list of effects applied
in order:
#![allow(unused)] fn main() { ActionOutcome::none() // do nothing ActionOutcome::effect(TuiEffect::Quit) // one effect ActionOutcome::effects([ // several, in order TuiEffect::Focus(FocusIntent::Next), TuiEffect::RefreshPage, ]) }
Modes
A mode is a named keymap. The same key can mean different things in different
modes — that's how you get vim-style modal input. A PageSpec lists which modes
are active, and .bind(mode, key, action) registers a key in a mode.
The runtime ships these names and reasons about a few of them itself:
#![allow(unused)] fn main() { modes::GENERAL // default page navigation (Tab, arrows, Enter on buttons) modes::NORMAL // read-only navigation inside fields ("nor") modes::INSERT // typing into a text field ("ins") modes::SELECT // selection / highlighting ("sel") modes::COMMAND // command bar (`:`) is open modes::COMMON // shared bindings, active alongside NORMAL and SELECT modes::GLOBAL // always active, regardless of mode }
But a ModeId is just a string key — nothing is hardcoded to these names.
Define a mode for any component you build and bind to it the same way:
#![allow(unused)] fn main() { const PICKER: ModeId = ModeId::borrowed("picker"); builder .bind(PICKER, "j", Action::PickerDown) .bind(PICKER, "k", Action::PickerUp); // activate it on the page where the picker is open: PageSpec::new().modes(vec![modes::GLOBAL, PICKER]) }
The library gives you the mechanism; you name the modes.
FocusWrap
Whether navigation stops at the ends of a list or wraps around. Set once on the builder; applies everywhere — page focus, section items, buffers, panes.
#![allow(unused)] fn main() { pub enum FocusWrap { Clamp, // stop at the first/last (default) Wrap, // cycle around } TuiPages::builder(view).focus_wrap(FocusWrap::Wrap) }
Coming from the old pipeline?
If you read the project's README, you saw the legacy flow: InputPipeline → InputOrchestrator → FocusManager → CommandPipeline → ActionDecider → Executor,
where the system reached out and called a function living in your page. The
concerns survived; the wiring changed direction.
The old design pushed: the library called into your code. This one pulls: your code returns values the library interprets. Concretely:
InputPipelineis still here, same job — key into typed action.InputOrchestrator,ActionDecider, andCommandPipelinecollapse into your oneHandler::handle_actionmatch. You are the decider now.FocusManageris still here, but you don't call it — you returnFocusIntentand the runtime applies it.- The
Executor(the library calling your page's function) is gone. Your handler runs the side effect itself and returns only coordination effects (Navigate,Focus,Quit).
So the one-way flow key → action → your handler → effects → runtime applies is
the whole machine.