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']);
});
});
});
Modal panels
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.');
});
});
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']);
Nesting, modal dialogs and both border looks are runnable in
playground/03-panels/,
and inline editing in
playground/04-inline-editing/.