Skip to main content

Panels and navigation

The interactive TUI is a full-screen panel browser: the root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field in place (or drills into a sub-panel), Esc goes back, q quits, and the mouse wheel scrolls long panels without moving the cursor - all of these keys are configurable (see Key bindings). Submit and Cancel buttons live on the root panel - ->buttons(FALSE) hides them, ->buttons(TRUE, 'Save', 'Discard') relabels them.

A contextual help footer runs along the foot of every screen, listing exactly the keys valid right now and updating as focus moves between the hub and each editor. The hub shows move, select, back, quit and ?; each widget contributes its own bindings on top of accept/cancel - a select adds move, a multiple select adds toggle and select-all/none, a bounded number adds the step keys, a textarea adds newline and the external-editor handoff, a revealable password adds the reveal toggle. Pressing ? at the hub opens a fuller overlay that lists the hub and every widget type the form uses, so the form teaches its own less-common widgets. The footer follows the active theme and key map - it degrades to ASCII glyphs and always reflects remapped keys - and the facade's ->footer(FALSE) turns it off.

A form-level ->banner() shows a start screen (with an optional version) before the panels; the facade's ->clearOnExit(FALSE) keeps the final frame on screen after the TUI exits.

Inline editing

Editing happens in place. Pressing Enter on a field opens its editor right where the value sits - the widget's own view, driven by its own keys - while the rest of the panel stays around it; the widget's accept key commits and collapses the row back to its summary, Esc cancels. A confirm shows its ● Yes ○ No in the row, a select drops its option list under the label, a text field becomes a caret input - each the same editor the widget always uses, just drawn in the panel instead of on its own screen. A change costs a single Enter with no context switch.

Inline is the default for every field. A field opts out with ->standalone(), which opens that same editor full-screen instead - the better fit for a widget that wants the whole viewport, such as a month calendar, a long option list or a multi-line textarea:

$form = Form::create('Order')
->panel('basket', 'Basket', function (PanelBuilder $p): void {
// Inline by default: the editor opens in the row on Enter.
$p->confirm('ripe', 'Ripe only?')->default(TRUE);
$p->select('fruit', 'Fruit')->default('apple')->options(['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry']);

// Full-screen editor, a better fit for the month grid.
$p->calendar('harvest_on', 'Harvest date')->default('2026-07-15')->standalone();
});

Nested panels

Panels nest to any depth: a sub-panel renders as a drillable row with a one-line summary of its values, and the breadcrumb header tracks where you are. A ->fixup() rule reconciles dependent answers on every settle pass - here, organic sourcing is forced off outside the premium grade, whatever was answered:

$form = Form::create('Basket settings')
->buttons(TRUE, 'Save', 'Discard')
->fixup(new Fixup(set: 'organic', to: FALSE, when: new Condition('grade', ne: 'premium')))
->panel('basket', 'Basket', function (PanelBuilder $p): void {
$p->select('grade', 'Grade')->default('standard')->option('premium', 'Premium', 'Hand-picked')->option('standard', 'Standard', 'Everyday quality')->option('budget', 'Budget', 'Value pack');
$p->confirm('organic', 'Organic only?')->default(TRUE);

$p->panel('extras', 'Extras', function (PanelBuilder $sp): void {
$sp->select('extras', 'Add extras')->multiple()->options(['herbs' => 'Herbs', 'nuts' => 'Nuts', 'seeds' => 'Seeds']);

$sp->panel('portion', 'Portion', function (PanelBuilder $tp): void {
$tp->suggest('serving', 'Serving size')->default('medium')->options(['small' => 'Small', 'medium' => 'Medium', 'large' => 'Large']);
});
});
});

Nested panels demoNested panels demo

A sub-panel usually drills in, replacing the view. A panel marked ->modal() instead opens as a centered dialog over the dimmed parent - the better fit for a focused aside or a decision that should not lose the reader's place. The dialog carries its own configurable submit/cancel buttons (mandatory chrome, so a modal always shows them), collects whatever fields it declares - or just shows a message - and blocks the rest of the form while open. Save keeps the edits made inside the dialog; Discard, Escape or q restores every answer exactly as it was when the dialog opened. A modal cannot itself hold sub-panels:

$form = Form::create('Produce order')
->buttons(TRUE, 'Place order', 'Cancel')
->panel('basket', 'Basket', function (PanelBuilder $p): void {
$p->text('item', 'Item')->default('Pear');
$p->number('quantity', 'Quantity')->default(6)->min(1)->max(99);

// A dialog collecting fields: Save keeps them, Discard restores them.
$p->panel('gift', 'Gift options', function (PanelBuilder $m): void {
$m->modal('Save', 'Discard');
$m->description('Wrap this order as a gift.');
$m->confirm('gift_wrap', 'Gift wrap?')->default(TRUE);
$m->text('gift_note', 'Gift message')->default('Enjoy the harvest');
});

// A text-only dialog: the message is the whole content, either button dismisses.
$p->panel('empty', 'Empty the basket', function (PanelBuilder $m): void {
$m->modal('Empty it', 'Keep it');
$m->description('This clears every item from your basket.');
});
});

A modal panel dialog centered over the dimmed basketA modal panel dialog centered over the dimmed basket

Bordered panels

The whole panel browser can be wrapped in a border. The border is a theme option - none (the default), line, rounded or double - passed as a plain string alongside the spacing; the hub, breadcrumb header, fields and the key-hint footer all sit inside it, and every drill-in sub-panel keeps it:

$form = Form::create('New order')
->buttons(TRUE, 'Create', 'Cancel')
->panel('basics', 'Basics', function (PanelBuilder $p): void {
$p->text('name', 'Produce name')->default('Pear')->required();
$p->select('category', 'Category')->default('fruit')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable', 'herb' => 'Herb']);
$p->number('quantity', 'Quantity')->default(6)->min(1)->max(99);
})
->panel('delivery', 'Delivery', function (PanelBuilder $p): void {
$p->select('ripeness', 'Ripeness')->default('ripe')->option('ripe', 'Ripe')->option('unripe', 'Unripe');
$p->confirm('express', 'Express delivery?')->default(FALSE);
});

// The border and spacing are theme options, set on the Tui facade.
$tui = (new Tui($form))->theme('default', ['border' => 'rounded', 'spacing' => 'padded']);

Panel browser with a rounded borderPanel browser with a rounded border

Nesting, modal dialogs and both border looks are runnable in playground/03-panels/, and inline editing in playground/04-inline-editing/.