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

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.