Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
VHome, Settings, Editor
ASave, FocusNext, Quit
Sa database handle, a document, or () for none
OOverlay::CommandBar
MDialogData<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:

  • InputPipeline is still here, same job — key into typed action.
  • InputOrchestrator, ActionDecider, and CommandPipeline collapse into your one Handler::handle_action match. You are the decider now.
  • FocusManager is still here, but you don't call it — you return FocusIntent and 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.