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-pagesdoesn't read the keyboard for you. In your event loop you read a key with crossterm and pass it totui.handle_key(key, …), whose parameter is acrossterm::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.ratatuiis the obvious pick (and all the examples use it), but nothing forces it. Only the optionaldialogfeature pullsratatuiin.
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:
InputPipelineis still here, same job — key into typed action.InputOrchestrator,ActionDecider, andCommandPipelinecollapse into your oneHandler::handle_actionmatch. You are the decider now.FocusManageris still here, but you don't call it — you returnFocusIntentand the runtime applies it.- The
Executor(the library calling your page's function) is gone. Your handler runs the side effect itself and returns only coordination effects (Navigate,Focus,Quit).
So the one-way flow key → action → your handler → effects → runtime applies is
the whole machine.
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 theC-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:
- Declare what's focusable, in each page's
PageSpec. - Move focus by returning
TuiEffect::Focus(intent)from your handler. - 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_viewassumesViewisCopy(it is in all the examples). If yours isn't, match on a reference instead —match &ctx.current_view— so the&ctxyou 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
handleonctx.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:
- Write
pages/foo.rswithpage_specandhandle. - Add one arm to the
page_specrouter and one to the handler router. - (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, } }
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>; } }
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 |