tui-pages

## 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 own | tui-pages owns
--- | ---
Your `Action` enum | Turning key presses into your actions
Your `View`/page enum | Resolving typed commands (`:quit`) into actions
Your application state | Which element has focus, and moving it
All rendering | Buffer history, panes, splits
The actual side effects of an action | Multi-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:

- `crossterm` — `tui-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
--- | ---
`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:

- `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.

Write | Means
--- | ---
`tab`, `enter`, `esc`, `space` | named keys
`up` `down` `left` `right` `home` `end` `pageup` `pagedown` | navigation keys
`backspace` `delete` `insert` | editing keys
`f1` … `f12` | function keys
`a`, `:`, `?` | single characters
`ctrl+s` | Ctrl + S
`alt+x` | Alt + X
`shift+tab` | Shift + Tab (normalized to BackTab)
`ctrl+shift+p` | combined modifiers
`g h` | press `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)),
    }

`FocusIntent` | What it does
--- | ---
`Next` / `Prev` | step 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
`LeaveSection` | step 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
`ClearOverlay` | close 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:

Key | Effect
--- | ---
`Tab` / `→` | next button
`Shift+Tab` / `←` | previous button
`Enter` | choose the active button
`Esc` | dismiss

(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,
    }
    }

# Canvas

Enable first-class canvas support with one feature:

    tui-pages = { version = "...", features = ["canvas"] }

The `canvas` feature intentionally enables the full canvas surface at once: form editor, ratatui rendering, suggestions, validation, computed fields, cursor style helpers, textarea, textinput, keymap handling, and crossterm input session helpers.

## App Action Shape

Use a wrapper action and implement `From<CanvasAction>`:

    #![allow(unused)]
    fn main() {
    use tui_pages::canvas::CanvasAction;

    enum Action {
        Canvas(CanvasAction),
        Quit,
    }

    impl From<CanvasAction> for Action {
        fn from(action: CanvasAction) -> Self {
            Action::Canvas(action)
        }
    }
    }

Then install standard bindings and typed text routing:

    #![allow(unused)]
    fn main() {
    let app = TuiPages::builder(View::Editor)
        .page_fn(page_spec)
        .handler(Handler)
        .canvas_defaults()
        .build();
    }

`PageSpec::canvas_editor(&editor)` or `PageSpec::canvas_mode(mode)` keeps the active input mode stack aligned with the focused canvas widget.

For custom wiring, `.canvas_keymaps()` installs only the standard NORMAL, INSERT, and SELECT keymaps, while `.canvas_text_actions()` installs only the typed-character mapper that turns plain text into `CanvasAction::InsertChar`. For lower-level standalone textarea and textinput wiring, route raw key events through the helpers below.

If you want `tui-pages` to own the canvas key dispatch directly, implement `CanvasWidgetState` for your app state and use the widget builders:

    #![allow(unused)]
    fn main() {
    impl tui_pages::canvas::CanvasWidgetState for State {
        fn canvas_form_editor(
            &mut self,
            id: usize,
        ) -> Option<&mut dyn tui_pages::canvas::CanvasFormEditorHost> {
            match id {
                0 => Some(&mut self.editor),
                _ => None,
            }
        }

        fn canvas_textarea(
            &mut self,
            focus_index: usize,
        ) -> Option<&mut dyn tui_pages::canvas::CanvasTextAreaHost> {
            match focus_index {
                0 => Some(&mut self.body),
                _ => None,
            }
        }

        fn canvas_textarea_entered(&mut self, focus_index: usize) -> Option<&mut bool> {
            match focus_index {
                0 => Some(&mut self.body_entered),
                _ => None,
            }
        }

        fn canvas_textinput_suggestion_suffix(
            &mut self,
            focus_index: usize,
            text: &str,
        ) -> Option<String> {
            match (focus_index, text) {
                (1, "adm") => Some("in".to_string()),
                _ => None,
            }
        }
    }

    let app = TuiPages::builder(View::Editor)
        .page_fn(page_spec)
        .handler(Handler)
        .canvas_form_editor(0)
        .canvas_textarea_widget(0)
        .build();
    }

`canvas_form_editor` dispatches canvas actions into the `FormEditor` and returns focus effects at editor boundaries, so your application action enum does not need a `Canvas(CanvasAction)` variant. The textarea and textinput builders treat their focused widget as one top-level stop until Enter sets the supplied `entered` flag. Once entered, NORMAL-mode keys drive the widget's modal editor, INSERT-mode keys go directly to the text widget, Esc exits edit mode and then the widget, and boundary/submit keys hand focus back to `tui-pages`. For textinput widgets, `canvas_textinput_suggestion_suffix` is optional; when it returns `Some(suffix)`, the widget shows that suffix inline and accepts it with Tab.

## FormEditor Handoff

The app owns editor state. In the action handler, dispatch canvas actions and return focus effects when the editor reaches a boundary:

    #![allow(unused)]
    fn main() {
    match tui_pages::dispatch_canvas_action(&mut state.editor, action) {
        CanvasDispatchOutcome::Focus(intent) => {
            ActionOutcome::effect(TuiEffect::Focus(intent))
        }
        CanvasDispatchOutcome::Applied(result) => {
            // handle messages, validation errors, persistence flags, recompute, etc.
            ActionOutcome::none()
        }
    }
    }

`ExitCanvasForward` and `ExitCanvasBackward` are handled by the normal `FocusManager`, so fields can sit beside buttons, sections, modals, buffers, and panes without custom focus code.

## Textarea And Textinput

For top-level textarea and textinput widgets, route raw key events through the typed helpers:

    #![allow(unused)]
    fn main() {
    let outcome = tui_pages::dispatch_canvas_text_area_key(&mut state.notes, key);
    let outcome = tui_pages::dispatch_canvas_text_input_key(&mut state.command, key);
    }

These return `Handled`, `Submitted`, `NotHandled`, or a focus intent. `Tab` and vertical boundary keys map to the same forward/backward canvas exits used by `FormEditor`.

## Rendering

Canvas renderers are re-exported from `tui_pages::canvas`. The common form-editor path is:

    #![allow(unused)]
    fn main() {
    tui_pages::render_canvas_with_suggestions_default(
        frame,
        frame.area(),
        form_area,
        &state.editor,
    );
    }

This draws the editor and, when suggestions are active, anchors the dropdown to the active input rectangle returned by the renderer. Textarea and textinput use the re-exported stateful widgets and expose `cursor(area, block)` helpers; set the ratatui cursor from those values after rendering the focused widget.

## Cursor Style

Cursor style is opt-in. Call `update_cursor_style_for_editor(&editor)` or `update_cursor_style_for_mode(mode)` from your event/render loop when you want terminal cursor shape updates. `tui-pages` does not mutate terminal cursor style unless the host calls these helpers.

## Example

See `examples/canvas/minimal` for the smallest form editor setup, `examples/canvas/textarea` for direct textinput/textarea dispatch, and `examples/canvas/full` for form editor, suggestions, validation, computed fields, cursor/session helpers, textarea, textinput, and canvas focus handoff together.

# 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;

        // canvas feature
        fn canvas_defaults(self) -> Self;      // keymaps + typed CanvasAction routing
        fn canvas_keymaps(self) -> Self;       // keymaps only
        fn canvas_text_actions(self) -> Self;  // typed chars -> CanvasAction::InsertChar
        fn canvas_form_editor(self, id: usize) -> Self;
        fn canvas_textarea_widget(self, focus_index: usize) -> Self;
        fn canvas_textinput_widget(self, focus_index: usize) -> Self; // optional inline suffix via CanvasWidgetState

        // 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>;
    }
    }

## Navigation

    #![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.

Key | Action
--- | ---
`Tab` / `Shift+Tab` | move focus
`Enter` | activate the focused button
`Ctrl+C` | quit

## `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.

Key | Action
--- | ---
`1` `2` `3` | open / switch to a buffer
`Tab` / `Shift+Tab` | cycle buffer history
`w` | close the active buffer
`v` / `s` | split vertical / horizontal
`o` / `p` | next / previous pane
`x` | close the active pane
`Ctrl+C` | quit

## `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.

Key | Action
--- | ---
`Tab` / `j` / `k` / arrows | move focus (and within a section)
`Enter` | activate / select
`Esc` | leave the current section
`g h` / `g n` / `g ?` | go to Home / Notes / Help
`]` / `[` / `x` | next / previous / close buffer
`Ctrl+S` / `Ctrl+D` | split vertical / horizontal
`Ctrl+N` / `Ctrl+W` | next pane / close pane
`:` | open the command palette
`Ctrl+C` | quit

## `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.

Key | Action
--- | ---
`Tab` / `Shift+Tab` | move focus / dialog buttons
`Enter` | activate / confirm
`Esc` | dismiss the dialog
`Ctrl+C` | quit