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, Left/Right move across a grid of sub-panels and along the buttons, Enter edits a field in place (or goes into a sub-panel), Esc comes back out, and q leaves. All of these keys are configurable (see Key bindings). A panel longer than the frame scrolls to keep the focused row in view, and the mouse wheel moves it a row at a time without moving the cursor. Submit and Cancel buttons are drawn beside the root panel, after everything it holds - ->buttons(FALSE) hides them, ->buttons(TRUE, 'Save', 'Discard') relabels them. They belong to the session rather than to the form's declaration, so going into a sub-panel leaves them behind and coming back out draws them again, and a form withheld from ending says why on the row directly above them. The pair is a row the cursor lands on like any other and carries the same mark to say so - and only while it does is one of the two drawn as the button Enter would press.

A contextual help footer runs along the bottom of every screen, listing exactly the keys that work right now and updating as focus moves between the hub and each open field. That is not a list somebody wrote out: it is read back off the bindings that actually apply, so a remapped key changes the line that advertises it. The hub shows move, select, back and quit. Each field adds 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. A field carrying help adds ?, which opens that text on a page of its own; any key dismisses it. 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, and the facade's ->clearOnExit(FALSE) keeps the final frame on screen after the TUI exits.

Where the panels, the trail and the key hints actually sit is a layout - the default arrangement is a pinned header, a scrolling middle and a pinned footer, and the facade's ->layout() swaps it for another.

Inline editing

Editing happens in place. Press Enter on a field and its editor opens right where the value sits - the field's own view, driven by its own keys - while the rest of the panel stays around it. The field'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 is the same editor the field always uses, just drawn in the panel instead of on its own screen - so changing a value costs a single Enter and 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 field that wants the whole viewport, like 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 row you select to enter, carrying a one-line summary of its values, and the breadcrumb header keeps track of where you are. Going in replaces the screen with the sub-panel's contents and grows the trail; coming back out restores both, along with the row you were on. 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

Conditional panels

A panel comes and goes on an earlier answer exactly as a field does. Declare ->when() inside the panel's own callback and the whole section hangs off the rule - not one row at a time:

$form = Form::create('Produce order')
->panel('basket', 'Basket', function (PanelBuilder $p): void {
$p->confirm('organic', 'Organic only?')->default(FALSE);

// The whole section appears with the answer, and leaves with it.
$p->panel('certification', 'Certification', function (PanelBuilder $sp): void {
$sp->when(new Condition('organic', eq: TRUE));
$sp->text('certifier', 'Certifier')->default('Soil Board');
$sp->calendar('renewed_on', 'Renewed on')->default('2026-07-15');
});
});

A section that isn't there takes everything it holds with it. Its row isn't drawn and the cursor passes over it, its questions collect nothing and refuse nothing, and none of them reach the answers - so certifier is absent from the result rather than present and empty. The moment the condition holds, the row is back and every question inside it is asked again, carrying whatever value it settled on. A question inside the section that carries a rule of its own waits on both, and a section inside a section goes with the one around it.

Going into a section whose condition then turns false - an answer inside it flips a fix-up, say - puts you back where the section was. A panel is somewhere you are rather than something you're looking at, so it can't merely stop being drawn while you're standing in it: the TUI leaves it the way Esc would, as far out as it takes to reach a section that's still there.

The same rule decides a headless collection, which is the point of putting it on the panel: one declaration says which questions a form asks, whether or not anything is drawn.

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

$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 sits inside a border by default. The border is a theme option - rounded (the default), line, double or none - passed as a plain string alongside the spacing (padded by default). The hub, breadcrumb header, fields and key-hint footer all sit inside the frame, 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 padded rounded box is the default - no options needed.
$tui = new Tui($form);

// Another border or spacing, or none at all, is a theme option on the facade.
$bare = (new Tui($form))->theme('default', ['border' => 'none', 'spacing' => 'normal']);

Panel browser with a rounded borderPanel browser with a rounded border

Panel layouts

A panel's sub-panels list vertically by default; ->layout() given numbers arranges the panel with a grid layout instead, dealing them into side-by-side windows. Each argument declares one visual row and says 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 panel you have gone into arranges its children independently: Form::layout() arranges the top-level panels, PanelBuilder::layout() a panel's children.

Each window is a region of that grid - window-1, window-2 and so on in reading order - and each sub-panel is placed in the one that names it as it is declared. The panel's own rows go in the pair around them, above and below: a row written before the first sub-panel draws over the grid, one written after the last draws under it, and you never have to name either.

A grid taller than the space it has scrolls as one: every line moves together, the overflow marker says which edge the rest is past, and moving onto a window past the edge brings its line into view. A sub-panel gated on an answer takes its window with it when the answer goes, and the visual row closes up around it rather than keeping a hole - the remaining windows share the width, and Left/Right step straight past it.

Given a name instead, the same PanelBuilder::layout() picks a layout that arranges the panel's own blocks into named regions - $p->layout('two-column'), then $p->in('left'). Both forms pick the layout a panel is arranged by, which is why mixing them in one call throws: a panel is arranged by one. A grid is never one of the names, because a name carries no shape; numbers are the only door to one. Names are covered on Layouts.

$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);
});

Panels arranged as a grid: one full-width panel above two side-by-side columnsPanels arranged as a grid: one full-width panel above two side-by-side columns

Each column previews its panel - the title, the description and one row per field value - so the grid reads as windows into the form. A panel nested inside a window draws the bare way into it, the title and the mark saying it leads somewhere: a window is a preview column, and the full row with a description and a summary belongs to a list, where there is a whole width to spend on it.

The arrows move spatially: Left/Right along a row, Up/Down between rows, and off the grid onto whatever is drawn past it - the panel's own rows above or below the windows first, and the buttons after those. Where nothing at all is drawn past it the move stops, because walking the row the cursor is on is what Left/Right are for. Enter drills into the focused panel exactly as in the row list - the panel then takes the whole frame rather than the cell it was previewed in - and fields declared on the same panel keep their normal rows, above the grid or below it depending on which side of the sub-panels they were written. The declaration is refused where it is written - a grid needs at least one visual row, every visual row must hold at least one window, and the slots must exactly cover the sub-panels - so a mismatch throws at declaration time, never mid-session.

Fullscreen

The facade's ->fullscreen() stretches 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. 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();

The panel browser stretched to the whole terminal with centered fieldsThe panel browser stretched to the whole terminal with centered fields

Four sizing options bound the stretch, each a non-negative integer:

  • max_width / max_height (default 0, uncapped) stop the frame short of a very wide or tall terminal; the capped frame then floats at the halign / valign anchor, like a dialog.
  • min_width / min_height guard against a terminal too small for the frame: below either, the TUI shows a centered resize notice (only the key that leaves works there) until the terminal grows. min_height defaults to 10. min_width defaults to 0, which measures the form's own content instead - the widest row any panel draws, plus the border - so the guard adapts to your questionnaire without any configuration. It is measured once, from the rows the form opens on: a minimum that followed the answers would trip and clear again as they grew, which is a screen nobody can work in rather than a guard.

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.php.