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

tui-pages

You want to build a complex, keyboard-driven TUI app? So you start wiring key handlers to functions, those mutate some shared state, and a few hundred lines later you have a god object that every part of the app reaches into. Adding a page means touching everything. Thats why this crate was created.

tui-pages is the way out of that. It owns the boring, error-prone coordination — input, focus, navigation, panes — and leaves you with your own types and your own rendering. You describe what should happen ("move focus to the next thing", "go to the Settings page"); the library runs the state machine that makes it happen.

The deal: who owns what

You owntui-pages owns
Your Action enumTurning key presses into your actions
Your View/page enumResolving typed commands (:quit) into actions
Your application stateWhich element has focus, and moving it
All renderingBuffer history, panes, splits
The actual side effects of an actionMulti-key chord sequences, modes

The library ships no rendering. You draw your state with ratatui (or anything else). The one exception is the optional dialog feature — an opt-in modal you can drop in. (It fits every big app, so I included it)

The one idea to take away

Your code never reaches in and mutates focus, or pushes onto the buffer history, or splits a pane. You can't, really — those live inside the runtime. Instead your action handler returns a value describing what it wants:

#![allow(unused)]
fn main() {
// "the user pressed Tab — move focus forward"
ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Next))

// "the user picked the Settings button — go there"
ActionOutcome::effect(TuiEffect::Navigate(View::Settings))
}

The runtime applies that effect. That's the whole model. Once it clicks, every page in this book is just a catalogue of effects you can return and targets you can declare.

Where to go next

  • Installation — add the crate.
  • Getting Started — a complete, runnable app in one file.
  • Core Concepts — the types, PageSpec, and effects.
  • The rest of the book drills into focus, navigation, commands, and dialogs.

There are also four runnable examples in the repo (examples/). They are the real source of truth — every snippet here is lifted from them.

Installation

[dependencies]
tui-pages = "0.7.0"
crossterm = "0.28"

Why these two extra crates:

  • crosstermtui-pages doesn't read the keyboard for you. In your event loop you read a key with crossterm and pass it to tui.handle_key(key, …), whose parameter is a crossterm::event::KeyEvent. So crossterm has to be your own dependency — that's the type the two of you exchange. No conversion needed.
  • a renderer — the core crate ships no drawing code and doesn't depend on ratatui; you draw the screen. ratatui is the obvious pick (and all the examples use it), but nothing forces it. Only the optional dialog feature pulls ratatui in.

Features

dialog

A built-in modal dialog with a ratatui renderer. Pulls in ratatui 0.28.

tui-pages = { version = "0.7", features = ["dialog"] }

Gives you DialogData, DialogResult, DialogTheme, render_dialog, and the dialog::* helpers that drive a modal for you. See Dialogs.

serde

Derives Serialize/Deserialize on the input/mode types (ModeId, KeyChord, FocusWrap), so you can load keybindings from a config file.

tui-pages = { version = "0.7", features = ["serde"] }

Edition

The crate is built on Rust edition 2021.

Getting Started

A whole app, top to bottom. It's two pages (Home and About) with two buttons — Tab moves between them, Enter activates, Ctrl+C quits. This is the minimal example; run it with cd examples/minimal && cargo run.

It's three files: app.rs is everything that talks to the library, ui.rs just draws, main.rs is the loop. Start with app.rs.

Your types

A View is a page. An Action is something that can happen. You define both.

#![allow(unused)]
fn main() {
use tui_pages::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
    Home,
    About
}

#[derive(Debug, Clone, Copy)]
pub enum Action {
    FocusNext,
    FocusPrev,
    Select,
    Quit
}

pub type App = TuiApp<View, Action, (), Handler>;   // () = no state in this app
}

The handler

An action comes in, you say what should happen. You don't move focus or change pages yourself — you return a TuiEffect and the runtime does it.

#![allow(unused)]
fn main() {
pub struct Handler;

impl TuiActionHandler<View, Action, ()> for Handler {
    type Error = std::convert::Infallible;

    fn handle_action(&mut self, action: Action, ctx: ActionContext<View>, _state: &mut ())
        -> Result<ActionOutcome<View>, Self::Error>
    {
        Ok(match action {
            Action::FocusNext => ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Next)),
            Action::FocusPrev => ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Prev)),
            Action::Quit      => ActionOutcome::effect(TuiEffect::Quit),

            // Enter means different things depending on where you are.
            // `ctx` tells you the page and what's focused.
            Action::Select => match (ctx.current_view, ctx.focus) {
                (View::Home,  Some(FocusTarget::Button(0))) => ActionOutcome::effect(TuiEffect::Navigate(View::About)),
                (View::About, Some(FocusTarget::Button(0))) => ActionOutcome::effect(TuiEffect::Navigate(View::Home)),
                (_,           Some(FocusTarget::Button(1))) => ActionOutcome::effect(TuiEffect::Quit),
                _ => ActionOutcome::none(),
            },
        })
    }
}
}

That match action { … } is your whole app's logic. Every arm returns an effect.

The page spec

For each view, say what's on it: the focusable things (two buttons) and which key modes are active.

#![allow(unused)]
fn main() {
fn page_spec(_view: &View, _state: &(), _focus: Option<&FocusTarget>) -> PageSpec {
    PageSpec::new()
        .focus(PageFocusBuilder::new().button(0).button(1))
        .modes(vec![modes::GENERAL, modes::GLOBAL])
}
}

Build it

Bind keys to actions and assemble the runtime. GENERAL is the normal mode; GLOBAL is always on, so Ctrl+C quits anywhere.

#![allow(unused)]
fn main() {
pub fn build() -> App {
    let mut app = TuiPages::builder(View::Home)
        .page_fn(page_spec)
        .handler(Handler)
        .bind(modes::GENERAL, "tab",       Action::FocusNext)
        .bind(modes::GENERAL, "shift+tab", Action::FocusPrev)
        .bind(modes::GENERAL, "enter",     Action::Select)
        .bind(modes::GLOBAL,  "ctrl+c",    Action::Quit)
        .build();
    app.refresh_page(&());   // load the first page's focus before drawing
    app
}
}

That's all of app.rs. Now the loop in main.rs.

The loop

Draw, read a key, hand it to handle_key. When an effect asks to quit, stop.

fn main() -> anyhow::Result<()> {
    let _guard = tui_pages::terminal::enter()?;   // raw mode; restores on drop/panic
    let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

    let mut tui = app::build();
    let mut state = ();

    loop {
        terminal.draw(|frame| ui::render(frame, *tui.current_view(), tui.focus.current()))?;

        if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
            if tui.handle_key(key, &mut state)?.quit_requested {
                break;
            }
        }
    }
    Ok(())
}

ui::render gets the current view and the focused element and draws them — it reads state, never changes it. (It's plain ratatui; look at examples/minimal/src/ui.rs.)

That's the loop

Read a key → handle_key turns it into an action → your handler returns an effect → the runtime applies it → draw again.

The rest of the book is just more effects you can return (focus, navigation, panes) and more you can put in a PageSpec.

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.

Input Pipeline

The input pipeline turns a KeyEvent into one of: an action to run, plain text for a field, a "waiting for the next key" state, or a cancellation. You rarely touch it directly — handle_key runs it for you — but you do need to know the binding syntax and how text input and chord sequences work.

Binding syntax

Bindings are plain strings. Modifiers are joined with +; a multi-key sequence is space-separated.

WriteMeans
tab, enter, esc, spacenamed keys
up down left right home end pageup pagedownnavigation keys
backspace delete insertediting keys
f1f12function keys
a, :, ?single characters
ctrl+sCtrl + S
alt+xAlt + X
shift+tabShift + Tab (normalized to BackTab)
ctrl+shift+pcombined modifiers
g hpress g, then h (a sequence)

Modifier names are case-insensitive and have short forms: ctrl/control/c, alt/meta/m, shift/s. So ctrl+s and c+s are the same binding.

This is the string format — ctrl+, not the C- you may have seen elsewhere. The examples all use this form.

Binding keys to actions

On the builder, .bind(mode, binding, action) registers a key in a mode:

#![allow(unused)]
fn main() {
TuiPages::builder(View::Home)
    .bind(modes::GENERAL, "tab", Action::FocusNext)
    .bind(modes::GENERAL, "enter", Action::Select)
    .bind(modes::GENERAL, "g h", Action::GotoHome)   // sequence
    .bind(modes::GLOBAL,  "ctrl+c", Action::Quit)    // works everywhere
}

A key only fires if its mode is in the current page's PageSpec::modes. GLOBAL is always active.

Multi-key sequences

Bind a space-separated sequence and the runtime waits for the rest after the first key:

#![allow(unused)]
fn main() {
.bind(modes::GENERAL, "g h", Action::GotoHome)
.bind(modes::GENERAL, "g n", Action::GotoNotes)
.bind(modes::GENERAL, "ctrl+x ctrl+c", Action::ForceQuit)
}

While waiting, handle_key returns TuiPagesStatus::Waiting(hints) — those hints are the candidate continuations, which you can show in a status bar. If the next key doesn't match, or the timeout (input_timeout_ms, default 1000ms) elapses, the sequence cancels.

Text input

By default every key is looked up as a binding. When a page sets accepts_text_input(true), plain character keys that aren't bound instead flow through as text:

#![allow(unused)]
fn main() {
PageSpec::new()
    .accepts_text_input(true)
    .modes(vec![modes::INSERT])
}

Now handle_key returns TuiPagesStatus::TextHandled for ordinary typing, and your handler's handle_text(chord, ctx, state) gets the KeyChord. Bound keys (like esc to leave the field) still fire as actions.

What handle_key returns

handle_key returns a TuiPagesOutput whose status is one of:

#![allow(unused)]
fn main() {
pub enum TuiPagesStatus<A> {
    ActionHandled,            // a binding matched; your handler ran
    TextHandled,             // text flowed to handle_text
    Waiting(Vec<InputHint<A>>), // mid-sequence; show the hints
    Cancelled,               // sequence expired or didn't match
    CommandIncomplete(_),    // from submit_command — see Commands
    CommandUnknown,
    CommandEmpty,
}
}

and a quit_requested: bool set when an effect asked to quit. Most loops only check quit_requested and stash Waiting hints for display:

#![allow(unused)]
fn main() {
let output = tui.handle_key(key, state)?;
match output.status {
    TuiPagesStatus::Waiting(hints) => waiting = hints,
    _ => waiting.clear(),
}
if output.quit_requested {
    return Ok(());
}
}

Parsing bindings yourself

If you load keybindings from a config file, the parse functions are public:

#![allow(unused)]
fn main() {
use tui_pages::{parse_binding, try_parse_binding, parse_key, try_parse_key};

// Lenient: drops tokens it can't parse. Good for trusted, in-code strings.
let chords: Vec<KeyChord> = parse_binding("ctrl+x z");

// Strict: reports the first bad token. Use for user-editable config.
match try_parse_binding("ctrl+shft+x") {
    Ok(chords) => { /* ... */ }
    Err(e) => eprintln!("bad binding: {e}"),  // UnknownKey("ctrl+shft+x")
}
}

parse_binding returns a sequence (Vec<KeyChord>); parse_key parses a single chord. To build a chord from a live event, use KeyChord::from_event(&key_event).

Focus

Focus is "which element is selected right now". The runtime owns it. You do three things:

  1. Declare what's focusable, in each page's PageSpec.
  2. Move focus by returning TuiEffect::Focus(intent) from your handler.
  3. Read the current focus when rendering, to highlight it.

You never mutate a focus manager yourself. (There is one — tui.focus — but you read it, you don't drive it.)

1. Declaring focus targets

A FocusTarget is an element that can hold focus. You list them with PageFocusBuilder:

#![allow(unused)]
fn main() {
PageFocusBuilder::new()
    .button(0)
    .button(1)
    .section_with_items(NOTES_SECTION, notes.len())
}

The target variants:

#![allow(unused)]
fn main() {
pub enum FocusTarget<O = ()> {
    Button(usize),                          // a button, by index
    Section(usize),                         // a collapsible group / list header
    SectionItem { section: usize, item: usize }, // an item inside a section
    CanvasField(usize),                     // a text/edit field
    InternalCanvasField(usize),             // a field skipped by Tab order
    Overlay(O),                             // one of your overlays (O = your type)
    ModalItem(usize),                       // a button inside a modal
    Custom(String),                         // an escape hatch
}
}

Button/Section/SectionItem/CanvasField cover almost everything. Overlay carries your overlay type — see the full example's command bar.

2. Moving focus

Return a FocusIntent wrapped in a focus effect. The ones you'll actually use:

#![allow(unused)]
fn main() {
// from your handler:
Action::FocusNext => ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Next)),
Action::FocusPrev => ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Prev)),
}
FocusIntentWhat it does
Next / Prevstep through targets (and through a section's items when you're inside one)
Activate"enter" the focused target — steps into a section, presses a button
LeaveSectionstep back out of a section (no-op if you're not in one)
Set(target)jump focus to a specific target
Open(target)open an overlay target
ClearOverlayclose whatever overlay is open

The key thing about Next/Prev: they're smart about sections. When focus is on a section and you Activate, the runtime steps into its items; Next/Prev then move between items, and stepping off the end moves back out to the next top-level target. You bind the same FocusNext action to both Tab and j and it just works — your handler never inspects focus to decide what a movement key should do.

Sections, end to end

This is the pattern that makes the section machinery pay off. Declare a section with its item count:

#![allow(unused)]
fn main() {
fn page_spec(view: &View, _state: &State, _focus: Option<&FocusTarget>) -> PageSpec {
    let mut focus = PageFocusBuilder::new();
    match view {
        View::Notes => focus = focus.section_with_items(NOTES_SECTION, NOTES.len()).button(0),
        _ => {}
    }
    PageSpec::new().focus(focus).modes(vec![modes::GENERAL, modes::GLOBAL])
}
}

Because the item count travels with the section (via .focus(builder)), the runtime can enter and walk the list itself. Your handler only describes intent — move, activate, leave — plus the genuinely app-specific bits:

#![allow(unused)]
fn main() {
fn select(ctx: ActionContext<View>, state: &mut State) -> ActionOutcome<View> {
    match (ctx.current_view, ctx.focus) {
        // Enter on a real item: do the app thing.
        (View::Notes, Some(FocusTarget::SectionItem { section: NOTES_SECTION, item })) => {
            state.selected_note = Some(item);
            ActionOutcome::effect(TuiEffect::RefreshPage)
        }
        // Enter on anything else (e.g. the section header) → let the runtime
        // activate it: it steps into the section using the declared item count.
        _ => ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Activate)),
    }
}
}

Bind esc to FocusIntent::LeaveSection and you have full list navigation without a single focus inspection in your movement code. (This is the full example.)

3. Reading focus when rendering

tui.focus.current() returns the focused target (owned Option<FocusTarget<O>>), which is exactly what your renderer needs to highlight the right thing:

#![allow(unused)]
fn main() {
pub fn render(frame: &mut Frame, view: View, focus: Option<FocusTarget>) {
    let focused = matches!(focus, Some(FocusTarget::Button(i)) if i == 0);
    // ...draw the button highlighted when `focused`.
}
}

Other read-only queries on tui.focus:

#![allow(unused)]
fn main() {
tui.focus.current()      // Option<FocusTarget<O>> — the focused target
tui.focus.has_overlay()  // bool — is an overlay/modal open?
tui.focus.is_focused(&t) // bool — is this exact target focused?
}

Wrap behaviour

FocusWrap (set on the builder) decides what happens at the ends of a list: Clamp stops, Wrap cycles. It applies to page focus, section items, buffers, and panes alike. You can read the current policy with tui.focus.focus_wrap().

Structuring a Bigger App

The handler in Getting Started is one match with four arms. That's perfect for two pages. For ten pages with real functionality, we do it like this: give each page its own module, and make the center dumb routing. Adding a page becomes "write a file, add two lines" — you never touch the other pages.

One module per page

Each page owns the two things the runtime asks about it: its page_spec (what's on the page) and its handle (what its actions do). Nothing else knows about Home except home.rs:

#![allow(unused)]
fn main() {
// pages/home.rs
use tui_pages::prelude::*;
use crate::app::{Action, View, State};

pub fn page_spec(state: &State) -> PageSpec {
    PageSpec::new()
        .focus(PageFocusBuilder::new().button(0).button(1))
        .modes(vec![modes::GENERAL, modes::GLOBAL])
}

pub fn handle(action: Action, ctx: &ActionContext<View>, state: &mut State) -> ActionOutcome<View> {
    match action {
        Action::Select => match ctx.focus {
            Some(FocusTarget::Button(0)) => ActionOutcome::effect(TuiEffect::Navigate(View::Settings)),
            _ => ActionOutcome::none(),
        },
        _ => ActionOutcome::none(),
    }
}
}

settings.rs, editor.rs, and the other seven look the same: their own focus targets, their own logic. They're independent — you can write, read, or delete one without opening the others.

The center just routes

Now app.rs holds no page logic at all. The page function routes to the right module:

#![allow(unused)]
fn main() {
// app.rs
fn page_spec(view: &View, state: &State, _focus: Option<&FocusTarget>) -> PageSpec {
    match view {
        View::Home     => home::page_spec(state),
        View::Settings => settings::page_spec(state),
        View::Editor   => editor::page_spec(state),
        // ...one arm per page
    }
}
}

and so does the handler:

#![allow(unused)]
fn main() {
impl TuiActionHandler<View, Action, State> for Handler {
    type Error = std::convert::Infallible;

    fn handle_action(&mut self, action: Action, ctx: ActionContext<View>, state: &mut State)
        -> Result<ActionOutcome<View>, Self::Error>
    {
        Ok(match ctx.current_view {
            View::Home     => home::handle(action, &ctx, state),
            View::Settings => settings::handle(action, &ctx, state),
            View::Editor   => editor::handle(action, &ctx, state),
        })
    }
}
}

match ctx.current_view assumes View is Copy (it is in all the examples). If yours isn't, match on a reference instead — match &ctx.current_view — so the &ctx you pass to the page is still valid.

That's the backbone. Everything below is for keeping it tidy as the app grows.

Handle global actions once

Some actions mean the same thing on every page — quit, move focus, open the command palette. Don't repeat them in ten modules. Catch them in the router before you delegate, and only fall through to the page for page-specific work:

#![allow(unused)]
fn main() {
fn handle_action(&mut self, action: Action, ctx: ActionContext<View>, state: &mut State)
    -> Result<ActionOutcome<View>, Self::Error>
{
    // Global: handled once, the same everywhere.
    match action {
        Action::Quit      => return Ok(ActionOutcome::effect(TuiEffect::Quit)),
        Action::FocusNext => return Ok(ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Next))),
        Action::FocusPrev => return Ok(ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Prev))),
        _ => {}
    }

    // Page-specific: route to wherever we are.
    Ok(match ctx.current_view {
        View::Home     => home::handle(action, &ctx, state),
        View::Settings => settings::handle(action, &ctx, state),
        View::Editor   => editor::handle(action, &ctx, state),
    })
}
}

A page's handle now only deals with what's genuinely its own.

When the Action enum gets big

Ten pages with lots of functionality means a lot of action variants. A single flat enum still works, but you can group per page and let each module own its slice. A is your type — nest it:

#![allow(unused)]
fn main() {
pub enum Action {
    // shared across pages
    FocusNext,
    FocusPrev,
    Quit,
    // one group per page
    Editor(EditorAction),
    Settings(SettingsAction),
}

pub enum EditorAction { Save, Format, ToggleWrap, /* ... */ }
}

Then the router hands the inner enum straight to the page, which never sees anything but its own actions:

#![allow(unused)]
fn main() {
match action {
    Action::Editor(a)   => editor::handle(a, &ctx, state),   // a: EditorAction
    Action::Settings(a) => settings::handle(a, &ctx, state),
    other               => global::handle(other, &ctx, state),
}
}

Do the same for View if the page list itself gets large (View::Editor(EditorView)), so the top-level enums stay small.

Where state lives

All your pages share one State (the S type), but each page usually cares about its own slice. Give each one a field — or its own sub-struct — and pages read and write only what's theirs:

#![allow(unused)]
fn main() {
pub struct State {
    pub editor: EditorState,
    pub settings: SettingsState,
    pub user: User,            // shared
}
}

editor::handle touches state.editor; settings::handle touches state.settings. The runtime passes the whole &mut State through; the discipline of "each page stays in its lane" is yours to keep.

Keys: shared by default, per-page when needed

Bindings are registered once on the builder, keyed by mode — not per page. So every page on modes::GENERAL shares Tab, Enter, and the rest for free, and you bind them in one place. A key only needs special handling when it means something different on one page, and you have two ways to do that:

  • Branch in that page's handle on ctx.focus / ctx.current_view (what the examples do) — good for "Enter does X here, Y there".

  • Give the page its own mode when it has a whole different keymap (a text editor, a modal tool):

    #![allow(unused)]
    fn main() {
    const EDITOR: ModeId = ModeId::borrowed("editor");
    
    // bound once on the builder:
    .bind(EDITOR, "ctrl+s", Action::Editor(EditorAction::Save))
    
    // and the page turns that keymap on:
    PageSpec::new().modes(vec![EDITOR, modes::GLOBAL])
    }

Adding a page, start to finish

That's the payoff. To add page number eleven:

  1. Write pages/foo.rs with page_spec and handle.
  2. Add one arm to the page_spec router and one to the handler router.
  3. (Only if it needs them) bind its keys, or its own mode, on the builder.

You don't open any other page's file. That's the architecture the README promised — a hundred pages, each minding its own business, with no shared god object in the middle.

Navigation, Buffers & Panes

Same rule as focus: you describe intent with a TuiEffect, the runtime owns the state. There is no BufferState API you call into — you return effects, and the runtime mutates tui.buffer for you. You only ever read tui.buffer, and only to render it.

There are two layers:

  • Buffer history — the list of open views you cycle through (like editor tabs or buffers).
  • Panes — how the visible area is split into regions.

Driving it: effects

Every navigation action in your handler maps to one effect. From the buffers example:

#![allow(unused)]
fn main() {
let effect = match action {
    Action::Open(view)    => TuiEffect::Navigate(view),   // open / switch to a view
    Action::NextBuffer    => TuiEffect::NextBuffer,        // cycle history forward
    Action::PrevBuffer    => TuiEffect::PreviousBuffer,    // cycle history back
    Action::CloseBuffer   => TuiEffect::CloseBuffer,       // drop the active buffer
    Action::Split(split)  => TuiEffect::SplitPane(split),  // split the active pane
    Action::NextPane      => TuiEffect::NextPane,          // move pane focus
    Action::PrevPane      => TuiEffect::PreviousPane,
    Action::ClosePane     => TuiEffect::ClosePane,
    Action::Quit          => TuiEffect::Quit,
};
Ok(ActionOutcome::effect(effect))
}

That's the whole handler for a multi-buffer, multi-pane app. Bind keys to those actions and you're done:

#![allow(unused)]
fn main() {
TuiPages::builder(View::Editor)
    .focus_wrap(FocusWrap::Wrap)   // NextBuffer past the end wraps to the first
    .bind(modes::GENERAL, "tab", Action::NextBuffer)
    .bind(modes::GENERAL, "w",   Action::CloseBuffer)
    .bind(modes::GENERAL, "v",   Action::Split(PaneSplit::Vertical))
    .bind(modes::GENERAL, "s",   Action::Split(PaneSplit::Horizontal))
    .bind(modes::GENERAL, "o",   Action::NextPane)
    .bind(modes::GENERAL, "x",   Action::ClosePane)
    // ...
}

PaneSplit is just:

#![allow(unused)]
fn main() {
pub enum PaneSplit {
    Horizontal,  // stacked top/bottom
    Vertical,    // side by side
}
}

TuiEffect::Navigate(view) and buffer/pane cycling honour your FocusWrap policy. Buffer history is per-view: when you navigate back to a buffer, its focus is restored. After a navigation effect the runtime re-runs your page function, so the new view's PageSpec takes effect automatically.

Reading it: rendering

Your renderer reads tui.buffer (a BufferState<V>) to lay out the screen. These are the read accessors:

#![allow(unused)]
fn main() {
tui.buffer.get_active_view()    // Option<&V> — the focused buffer's view
tui.buffer.is_split()           // bool — is the area split into panes?
tui.buffer.split_direction()    // Option<PaneSplit>
tui.buffer.panes()              // &[PaneSession<V>] — one per pane
tui.buffer.active_pane_index()  // usize — which pane has focus
}

A render function that handles both the single and split cases (from the buffers example) looks like:

#![allow(unused)]
fn main() {
pub fn render(frame: &mut Frame, buffer: &BufferState<View>) {
    if buffer.is_split() {
        let dir = match buffer.split_direction() {
            Some(PaneSplit::Vertical) => Direction::Horizontal,
            _                         => Direction::Vertical,
        };
        let areas = Layout::default().direction(dir)
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
            .split(frame.area());

        for (i, pane) in buffer.panes().iter().enumerate() {
            let active = i == buffer.active_pane_index();
            draw_pane(frame, areas[i], pane, active);
        }
    } else {
        draw_single(frame, frame.area(), buffer.get_active_view());
    }
}
}

The shortcut tui.current_view() returns the active buffer's view directly — handy when you don't care about panes.

When do you need this?

Only if your app has tabs/buffers or split panes. A single-page app ignores all of it: there's always exactly one buffer and one pane, and you just render tui.current_view(). See the buffers example for the full thing.

Dialogs

A built-in modal dialog, behind the dialog feature. It's the one piece of rendering the crate ships, because a yes/no confirmation is the same everywhere and not worth rewriting per app. Everything here is the minimal_dialog example.

tui-pages = { version = "0.7", features = ["dialog"] }

How it fits

The dialog rides on the runtime's modal payload slot, M. You set M to DialogData<YourPurpose>, where YourPurpose is your own "which dialog is this" type:

#![allow(unused)]
fn main() {
// O = () (no named overlays), M = DialogData<Purpose>
pub type App = TuiApp<View, Action, AppState, Handler, (), DialogData<Purpose>>;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Purpose {
    ConfirmDelete,
}
}

The focus manager stores the dialog content and tracks which button is active. Your handler opens the dialog; a one-line helper drives it; you act on the result.

Opening a dialog

Build a DialogData and turn it into a focus effect with show_intent():

#![allow(unused)]
fn main() {
Action::Select => match ctx.focus {
    Some(FocusTarget::Button(0)) => {
        let dialog = DialogData::new(
            "Delete an item?",                          // title
            format!("Delete \"{first}\"?"),             // message (may be multi-line)
            ["Delete", "Cancel"],                       // buttons, left to right
            Purpose::ConfirmDelete,                     // your purpose payload
        );
        ActionOutcome::effect(TuiEffect::Focus(dialog.show_intent()))
    }
    _ => ActionOutcome::none(),
}
}

There's also DialogData::loading(title, message) for a buttonless "please wait" modal — swap it for a real DialogData::new (via another show_intent) when the work finishes.

Driving it in the event loop

dialog::handle_key does the conventional bindings for you and closes the dialog when answered. It returns a DialogKey<D>no Result, no ?:

#![allow(unused)]
fn main() {
match dialog::handle_key(&mut tui.focus, key) {
    // No dialog open — pass the key through to normal handling.
    DialogKey::Ignored => {
        if tui.handle_key(key, state)?.quit_requested {
            return Ok(());
        }
    }
    // Dialog absorbed the key (e.g. moved between buttons). Nothing to do.
    DialogKey::Consumed => {}
    // Dialog finished — act on the answer.
    DialogKey::Resolved(result) => apply_dialog(result, state),
}
}

The conventional bindings it applies:

KeyEffect
Tab / next button
Shift+Tab / previous button
Enterchoose the active button
Escdismiss

(Want different bindings? Skip the helper and drive it yourself with dialog::current_dialog, dialog::active_button, dialog::selection, and FocusIntent. The helper is just the common path.)

The result

#![allow(unused)]
fn main() {
pub enum DialogResult<D> {
    Selected { purpose: Option<D>, index: usize }, // which button (0-based)
    Dismissed,                                      // Esc, or a loading dialog
}

fn apply_dialog(result: DialogResult<Purpose>, state: &mut AppState) {
    match result {
        DialogResult::Selected { purpose: Some(Purpose::ConfirmDelete), index: 0 } => {
            state.items.remove(0); // "Delete" chosen
        }
        DialogResult::Selected { .. } => {} // "Cancel" or some other button
        DialogResult::Dismissed       => {} // Esc
    }
}
}

Rendering it

Draw your normal UI, then draw the dialog on top if one is open. The renderer needs the data and the active button index, both read from the focus manager:

#![allow(unused)]
fn main() {
use tui_pages::{render_dialog, DialogTheme};

// ...draw the rest of the screen first...

if let Some(data) = dialog::current_dialog(&tui.focus) {
    let active = dialog::active_button(&tui.focus).unwrap_or(0);
    render_dialog(frame, frame.area(), data, active, &DialogTheme::default());
}
}

render_dialog centers the modal in the area you pass and draws it. To restyle it, build a DialogTheme (all fields are ratatui::style::Color):

#![allow(unused)]
fn main() {
pub struct DialogTheme {
    pub background: Color,
    pub border: Color,
    pub border_active: Color,
    pub title: Color,
    pub text: Color,
    pub button: Color,
    pub button_active: Color,
}
}

Commands

Commands are the typed kind: :quit, :n, go to notes. You register a name with aliases, the user types a string, and the runtime resolves it (by prefix) to one of your actions.

The crate gives you the resolver. It does not give you a command palette — the text box, the cursor, the open/close state are ordinary app state you own and render. The full example builds a complete :-style palette this way in about 30 lines.

Registering commands

On the builder, .command(name, aliases, action):

#![allow(unused)]
fn main() {
TuiPages::builder(View::Home)
    .command("Go to Home",  ["h", "home"],   Action::GotoHome)
    .command("Go to Notes", ["n", "notes"],  Action::GotoNotes)
    .command("Quit",        ["q", "quit"],   Action::Quit)
}

The first argument is a display name (shown in hints); the rest are the aliases the user can type. Matching is by prefix, so not resolves to notes.

Submitting input

When the user confirms their typed string, hand it to submit_command. It resolves and runs the action exactly like a key binding would — including applying any effects and setting quit_requested:

#![allow(unused)]
fn main() {
let quit = tui.submit_command(&input, state)?.quit_requested;
}

The returned TuiPagesOutput::status tells you what happened:

#![allow(unused)]
fn main() {
pub enum TuiPagesStatus<A> {
    ActionHandled,                       // resolved and ran
    CommandIncomplete(Vec<CommandHint>), // a prefix of several commands
    CommandUnknown,                      // no match
    CommandEmpty,                        // empty input
    // ...
}
}

A palette is just app state

The runtime owns no palette. You own a flag and a string, open it on a key, feed keystrokes to your string, and call submit_command on Enter. This is the whole palette loop from the full example:

#![allow(unused)]
fn main() {
// State you own:
//   palette_open: bool,
//   palette_input: String,

if state.palette_open {
    match key.code {
        KeyCode::Enter => {
            let input = state.palette_input.clone();
            let quit = tui.submit_command(&input, state)?.quit_requested;
            close_palette(tui, state);
            if quit { return Ok(()); }
        }
        KeyCode::Esc       => close_palette(tui, state),
        KeyCode::Backspace => { state.palette_input.pop(); }
        KeyCode::Char(c)   => state.palette_input.push(c),
        _ => {}
    }
    continue; // palette swallows the key; don't fall through to handle_key
}
}

Opening it is an action that sets the flag and (optionally) opens an overlay focus target so your renderer knows to draw the bar:

#![allow(unused)]
fn main() {
Action::OpenPalette => {
    state.palette_open = true;
    state.palette_input.clear();
    ActionOutcome::effect(TuiEffect::Focus(FocusIntent::Open(
        FocusTarget::Overlay(Overlay::CommandBar),
    )))
}
}

Closing it clears your state and the overlay:

#![allow(unused)]
fn main() {
fn close_palette(tui: &mut App, state: &mut State) {
    state.palette_open = false;
    state.palette_input.clear();
    tui.focus.clear_overlay();
}
}

Live hints while typing

To show completions as the user types, query the resolver directly. tui.commands is the public CommandResolver:

#![allow(unused)]
fn main() {
match tui.commands.process(&state.palette_input) {
    CommandResponse::Incomplete(hints) => show(hints), // Vec<CommandHint>
    CommandResponse::Execute(_)        => {}           // a full match exists
    CommandResponse::Unknown           => show_error(),
    CommandResponse::Empty             => {}
}
}

Each CommandHint is { alias: String, action_name: String } — the alias to complete to, and its display name.

The resolution timeout (for multi-step command states) defaults to 1000ms and is configurable with .command_timeout_ms(ms) on the builder.

API Reference

A map of the public surface, with the signatures you'll actually call. Full generated docs live on docs.rs. Signatures here are abbreviated (bounds elided) for readability.

Prelude

#![allow(unused)]
fn main() {
use tui_pages::prelude::*;
}

Pulls in the runtime types, the focus types, modes::*, parse_binding, and — importantly — the traits TuiActionHandler, FocusController, and PageProvider. With the dialog feature it also brings in DialogData, DialogResult, DialogTheme, render_dialog, and the dialog::* helpers.

Building the app

#![allow(unused)]
fn main() {
TuiPages::builder(initial_view: V) -> TuiPagesBuilder<...>

impl TuiPagesBuilder {
    // pages + handler (both required)
    fn page_fn(self, f: PageFn<V, S, O>) -> Self;     // page provider as a fn
    fn pages(self, provider: P) -> Self;              // or a custom PageProvider
    fn handler(self, h: Handler) -> Self;

    // bindings & commands
    fn bind(self, mode: impl Into<ModeId>, binding: &str, action: A) -> Self;
    fn keymap(self, mode: impl Into<ModeId>, f: impl FnOnce(&mut KeyMap<A>)) -> Self;
    fn command(self, name: impl Into<String>, aliases, action: A) -> Self;

    // tuning (all optional)
    fn fallback_view(self, view: V) -> Self;          // view to fall back to on close
    fn focus_wrap(self, wrap: FocusWrap) -> Self;     // Clamp (default) or Wrap
    fn input_timeout_ms(self, ms: u64) -> Self;       // chord-sequence timeout
    fn command_timeout_ms(self, ms: u64) -> Self;

    fn build(self) -> TuiPages<...>;
}
}

PageFn<V, S, O> is fn(&V, &S, Option<&FocusTarget<O>>) -> PageSpec<O>.

The runtime: TuiPages

Public fields (you read these to render; you don't drive them):

#![allow(unused)]
fn main() {
pub input:    InputPipeline<A>
pub commands: CommandResolver<A>      // resolve typed commands
pub focus:    FocusManager<O, M>      // focus.current(), focus.has_overlay(), ...
pub buffer:   BufferState<V>          // buffer.panes(), buffer.is_split(), ...
}

Methods you call:

#![allow(unused)]
fn main() {
fn handle_key(&mut self, key: KeyEvent, state: &mut S)
    -> Result<TuiPagesOutput<A>, TuiPagesError<E>>;
fn submit_command(&mut self, input: &str, state: &mut S)
    -> Result<TuiPagesOutput<A>, TuiPagesError<E>>;
fn refresh_page(&mut self, state: &S);   // re-read the current page spec
fn current_view(&self) -> &V;            // active buffer's view
fn apply_effect(&mut self, effect: TuiEffect<V, O, M>, state: &S) -> bool;
}
#![allow(unused)]
fn main() {
pub struct TuiPagesOutput<A> {
    pub status: TuiPagesStatus<A>,
    pub quit_requested: bool,
}

pub enum TuiPagesStatus<A> {
    ActionHandled, TextHandled,
    Waiting(Vec<InputHint<A>>), Cancelled,
    CommandIncomplete(Vec<CommandHint>), CommandUnknown, CommandEmpty,
}
}

Your handler

#![allow(unused)]
fn main() {
pub trait TuiActionHandler<V, A, S, O = (), M = ()> {
    type Error;

    fn handle_action(&mut self, action: A, ctx: ActionContext<V, O>, state: &mut S)
        -> Result<ActionOutcome<V, O, M>, Self::Error>;

    // default returns ActionOutcome::none(); override for text-input pages
    fn handle_text(&mut self, chord: KeyChord, ctx: ActionContext<V, O>, state: &mut S)
        -> Result<ActionOutcome<V, O, M>, Self::Error>;
}

pub struct ActionContext<V, O = ()> {
    pub current_view: V,
    pub focus: Option<FocusTarget<O>>,
    pub has_overlay: bool,
}
}

Effects & outcomes

#![allow(unused)]
fn main() {
pub enum TuiEffect<V, O = (), M = ()> {
    None, Focus(FocusIntent<O, M>), Navigate(V),
    NextBuffer, PreviousBuffer, CloseBuffer,
    SplitPane(PaneSplit), ClosePane, NextPane, PreviousPane,
    RefreshPage, Quit,
}

impl ActionOutcome<V, O, M> {
    fn none() -> Self;
    fn effect(e: TuiEffect<V, O, M>) -> Self;
    fn effects(iter: impl IntoIterator<Item = TuiEffect<V, O, M>>) -> Self;
}
}

Pages & focus

#![allow(unused)]
fn main() {
pub struct PageSpec<O = ()> { /* see Core Concepts */ }
impl PageSpec<O> {
    fn new() -> Self;
    fn focus(self, builder: PageFocusBuilder<O>) -> Self;   // prefer this
    fn focus_targets(self, targets: Vec<FocusTarget<O>>) -> Self;
    fn modes(self, modes: impl IntoIterator<Item = ModeId>) -> Self;
    fn accepts_text_input(self, yes: bool) -> Self;
}

impl PageFocusBuilder<O> {
    fn new() -> Self;
    fn button(self, index: usize) -> Self;
    fn buttons(self, indices: &[usize]) -> Self;
    fn section(self, id: usize) -> Self;
    fn section_with_items(self, id: usize, item_count: usize) -> Self;
    fn canvas_field(self, index: usize) -> Self;
    fn canvas_fields(self, count: usize) -> Self;
    fn internal_canvas_field(self, index: usize) -> Self;
    fn target(self, t: FocusTarget<O>) -> Self;
    fn build(self) -> Vec<FocusTarget<O>>;
}

pub enum FocusTarget<O = ()> {
    Button(usize), Section(usize), SectionItem { section: usize, item: usize },
    CanvasField(usize), InternalCanvasField(usize),
    Overlay(O), ModalItem(usize), Custom(String),
}

pub enum FocusIntent<O = (), M = ()> {
    Next, Prev, Activate, LeaveSection,
    Set(FocusTarget<O>), Open(FocusTarget<O>), Close(FocusTarget<O>),
    Toggle(FocusTarget<O>), ClearOverlay,
    EnterSection { item_count: usize },
    ExitCanvasForward, ExitCanvasBackward,
    ShowModal { data: M, count: usize }, UpdateModal { data: M, count: usize },
    RegisterPage(Vec<FocusTarget<O>>),
    RegisterPageAndEnterSection { targets: Vec<FocusTarget<O>>, section: usize, item_count: usize, item: usize },
}

pub enum FocusWrap { Clamp, Wrap }
}

FocusManager — read-only from your side:

#![allow(unused)]
fn main() {
fn current(&self) -> Option<FocusTarget<O>>;
fn is_focused(&self, target: &FocusTarget<O>) -> bool;
fn has_overlay(&self) -> bool;
fn focus_wrap(&self) -> FocusWrap;
fn clear_overlay(&mut self);    // used when closing an app-owned palette
}

Input

#![allow(unused)]
fn main() {
fn parse_binding(s: &str) -> Vec<KeyChord>;                 // lenient, drops bad tokens
fn parse_key(s: &str) -> Option<KeyChord>;
fn try_parse_binding(s: &str) -> Result<Vec<KeyChord>, ParseKeyError>;  // strict
fn try_parse_key(s: &str) -> Result<KeyChord, ParseKeyError>;

impl KeyChord {
    fn from_event(event: &KeyEvent) -> Self;
    fn display_string(&self) -> String;
    pub code: KeyCode,
    pub modifiers: KeyModifiers,
}
}

There is no FromStr/parse() on KeyChord — use the functions above.

Commands

#![allow(unused)]
fn main() {
pub enum CommandResponse<A> { Execute(A), Incomplete(Vec<CommandHint>), Unknown, Empty }
pub struct CommandHint { pub alias: String, pub action_name: String }

impl CommandResolver<A> {           // available as tui.commands
    fn process(&self, input: &str) -> CommandResponse<A>;
    fn get_feedback(&self, input: &str) -> Option<String>;
}
}
#![allow(unused)]
fn main() {
pub enum PaneSplit { Horizontal, Vertical }

impl BufferState<V> {               // available as tui.buffer (read-only use)
    fn get_active_view(&self) -> Option<&V>;
    fn is_split(&self) -> bool;
    fn split_direction(&self) -> Option<PaneSplit>;
    fn panes(&self) -> &[PaneSession<V>];
    fn active_pane_index(&self) -> usize;
}
}

You drive buffers and panes through TuiEffect, not through these mutators.

Dialog (feature dialog)

#![allow(unused)]
fn main() {
pub struct DialogData<D = ()> {
    pub title: String, pub message: String,
    pub buttons: Vec<String>, pub purpose: Option<D>, pub is_loading: bool,
}
impl DialogData<D> {
    fn new(title, message, buttons, purpose: D) -> Self;
    fn loading(title, message) -> Self;
    fn show_intent<O>(self) -> FocusIntent<O, DialogData<D>>;
}

pub enum DialogResult<D> { Selected { purpose: Option<D>, index: usize }, Dismissed }
pub enum DialogKey<D>    { Ignored, Consumed, Resolved(DialogResult<D>) }

// helpers
fn dialog::handle_key<O, D>(focus: &mut FocusManager<O, DialogData<D>>, key: KeyEvent) -> DialogKey<D>;
fn dialog::current_dialog<O, D>(focus: &FocusManager<O, DialogData<D>>) -> Option<&DialogData<D>>;
fn dialog::active_button<O, D>(focus: &FocusManager<O, DialogData<D>>) -> Option<usize>;
fn render_dialog<D>(f: &mut Frame, area: Rect, data: &DialogData<D>, active_button: usize, theme: &DialogTheme);
}

Terminal

#![allow(unused)]
fn main() {
fn tui_pages::terminal::enter() -> io::Result<TerminalGuard>;
}

Enables raw mode + the alternate screen and returns a guard that restores the terminal when it drops — including on a panic.

Examples

Four runnable examples live in examples/. They are the source of truth — every snippet in this book is lifted from them. The small ones are three files — app.rs (all the tui-pages wiring), ui.rs (pure rendering that only reads state), and main.rs (the event loop). The full example grows into the per-page layout from Structuring a Bigger App: a directory per page, each split by concern.

Run any of them with:

cd examples/<name>
cargo run

minimal

The smallest real app: two pages (Home / About), two buttons, Tab to move, Enter to activate, Ctrl+C to quit. Read this first — it's walked through in full in Getting Started.

Teaches: views, a handler that returns effects, PageSpec with buttons, the event loop.

KeyAction
Tab / Shift+Tabmove focus
Enteractivate the focused button
Ctrl+Cquit

buffers

Three views (Editor / Terminal / Docs) as cyclable buffers, plus pane splitting. The handler is one match from action to effect — no buffer bookkeeping. Uses FocusWrap::Wrap so cycling past the ends comes back around.

Teaches: Navigate / NextBuffer / CloseBuffer, SplitPane / NextPane / ClosePane, and rendering a split layout from tui.buffer. See Navigation, Buffers & Panes.

KeyAction
1 2 3open / switch to a buffer
Tab / Shift+Tabcycle buffer history
wclose the active buffer
v / ssplit vertical / horizontal
o / pnext / previous pane
xclose the active pane
Ctrl+Cquit

full

Everything at once: navigation, a section with selectable items, multi-key chord sequences (g h, g n, g ?), buffers, panes, and a :-style command palette built entirely from app state plus the public command resolver.

It's also laid out the way a real app should be. The common code sits at the src root — app.rs (shared types + the two routers), ui.rs (shared chrome like the tabs and status bar), main.rs (the loop). Then each page is its own directory, split by concern:

src/
  main.rs       event loop
  app.rs        shared types + routers + build()
  ui.rs         shared chrome (tabs, workspace, status, palette) + render_button
  home/
    mod.rs      
    page.rs     tui-pages wiring: page_spec + handle
    logic.rs    home's own data + decisions (no tui-pages, no ratatui)
    ui.rs       home's rendering
  notes/        mod.rs · page.rs · logic.rs · ui.rs
  help/         mod.rs · page.rs · ui.rs   (static page — no logic to split out)

Only page.rs touches the library; logic.rs is plain Rust (the note list, the "which button goes where" decision); ui.rs is plain ratatui; mod.rs is just glue so the routers can call home::page_spec / home::ui::render. A page only gets a logic.rs when it has logic worth separating — Help is just text, so it doesn't. Open a page's folder and you have that whole feature in front of you. Adding a page is a new directory plus one arm in each router. This is Structuring a Bigger App made concrete.

Teaches: per-page structure + routing, sections + Activate / LeaveSection, chord sequences, and composing a command palette (the crate ships no palette — you build one). See Focus and Commands.

KeyAction
Tab / j / k / arrowsmove focus (and within a section)
Enteractivate / select
Escleave the current section
g h / g n / g ?go to Home / Notes / Help
] / [ / xnext / previous / close buffer
Ctrl+S / Ctrl+Dsplit vertical / horizontal
Ctrl+N / Ctrl+Wnext pane / close pane
:open the command palette
Ctrl+Cquit

minimal_dialog

A confirmation dialog using the dialog feature: highlight "Delete an item", press Enter, confirm or cancel a modal. The handler only opens the dialog; dialog::handle_key drives it and hands back the result.

Teaches: the modal payload slot M, DialogData::show_intent, dialog::handle_key, and render_dialog. See Dialogs. Run it with the feature on — the example's Cargo.toml already enables it.

KeyAction
Tab / Shift+Tabmove focus / dialog buttons
Enteractivate / confirm
Escdismiss the dialog
Ctrl+Cquit