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']);
Panel layouts
A panel's sub-panels list vertically by default; ->layout() arranges them as a grid of side-by-side columns instead. Each argument declares one visual row and names how many panels sit beside each other in it, filled in declaration order: layout(2) puts two panels side by side, layout(2, 2) makes four windows, layout(1, 2) one full-width panel above two columns - and layout(2, 1) the other way around. Every level of the panel tree declares its own, so a drilled-in panel arranges its children independently. Form::layout() arranges the top-level panels; PanelBuilder::layout() arranges a panel's children:
$form = Form::create('Market stall')
->layout(1, 2)
->buttons(TRUE, 'Place order', 'Cancel')
->panel('summary', 'Summary', function (PanelBuilder $p): void {
$p->description('The order at a glance.');
$p->text('name', 'Order name')->default('Weekly Box')->required();
})
->panel('produce', 'Produce', function (PanelBuilder $p): void {
// Drilling into Produce reveals its own side-by-side grid.
$p->layout(2);
$p->panel('fruit', 'Fruit', function (PanelBuilder $sp): void {
$sp->select('fruit', 'Fruit')->default('apple')->options(['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry']);
});
$p->panel('veg', 'Vegetables', function (PanelBuilder $sp): void {
$sp->select('veg', 'Vegetables')->multiple()->default(['carrot'])->options(['carrot' => 'Carrot', 'tomato' => 'Tomato', 'spinach' => 'Spinach']);
});
})
->panel('delivery', 'Delivery', function (PanelBuilder $p): void {
$p->confirm('gift', 'Gift wrap?')->default(FALSE);
});
Each column previews its panel - the title, the description and one row per field value, plus a drill-in row per nested panel - so the grid reads as windows into the form. The arrows move spatially (Left/Right along a row, Up/Down between rows, out to the fields above or the buttons below), Enter drills into the focused panel exactly as in the row list, and fields declared on the same panel keep their normal rows above the grid. The declaration is validated when the form is built: every row must hold at least one panel and the slots must exactly cover the sub-panels, so a mismatch throws at declaration time, never mid-session.
Fullscreen
The facade's ->fullscreen() expands the frame to the whole terminal: the border hugs the screen edges, the key-hint footer pins to the bottom row, and the body stretches between them. Where the fields sit inside the stretched frame is a pair of theme options - halign (left, center or right) and valign (top, middle or bottom) - passed as plain strings like the border, and the fields move as one block so their left edges stay aligned:
// The market-stall grid from above, stretched to the terminal and centered.
// Fullscreen is a facade switch; the alignment and size limits are theme options.
$tui = (new Tui($form))
->theme('default', ['border' => 'rounded', 'halign' => 'center', 'valign' => 'middle'])
->fullscreen();
Four sizing options bound the stretch, each a non-negative integer:
max_width/max_height(default0, uncapped) stop the frame short of a very wide or tall terminal; the capped frame then floats at thehalign/valignanchor, like a dialog.min_width/min_heightguard against a terminal too small for the layout: below either, the TUI shows a centered resize notice (only quit works there) until the terminal grows.min_heightdefaults to10;min_widthdefaults to0, which measures the form's own content - the widest label, value, badge and description row across every panel - so the guard adapts to the questionnaire without configuration.
Fullscreen affects only the interactive TUI; headless collection ignores it. The frame's layout width is resolved once at start-up, while the live terminal size is still sampled every frame for the minimum-size guard and frame positioning - so a window resized mid-session reflows on height but keeps its layout width until the next run.
Nesting, modal dialogs, both border looks and the fullscreen stretch are runnable in playground/03-panels/, and inline editing in playground/04-inline-editing/.