Configuration
A form is a tree of panels, each holding fields, and you build it fluently. Rules are named-argument spec objects, so your IDE completes them and a typo fails at declaration time - not half-way through a session:
use DrevOps\PhpTui\Builder\Form;
use DrevOps\PhpTui\Builder\PanelBuilder;
use DrevOps\PhpTui\Condition\Condition;
use DrevOps\PhpTui\Derive\Derive;
$form = Form::create('My form')
->panel('general', 'General', function (PanelBuilder $p): void {
// text | template | select | suggest | confirm | toggle | number | rating
// calendar | textarea | password | search | reorder | filepicker | pause
// select, search and filepicker collect a list with ->multiple()
$p->text('name', 'Produce name')->required();
// Compute one field from others.
$p->text('slug', 'Slug')->derive(new Derive('{{name}}', transform: 'machine'));
$p->select('category', 'Category')
->default('fruit')
->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']);
// Shown only when the condition holds; compose with Condition::all()/any()/not().
$p->text('variety', 'Variety')->when(new Condition('category', eq: 'vegetable'));
});
Each field builder chains ->description(), ->help() and ->placeholder() (the three guidance texts), ->default(), ->required() (with an optional message: overriding the label-derived one), ->options() / ->option() (with per-option descriptions and optional disabled state), ->heading() / ->separator() (non-selectable option-list structure), ->when(new Condition(...)), ->derive(new Derive(...)), ->discover(...), ->validate(...) and ->transform(...).
The form declares its own chrome: ->banner() sets a start banner and ->buttons() controls the submit/cancel buttons. Everything that describes the terminal rather than the questionnaire - the global TUI runtime - is configured on the Tui facade instead: ->theme() names a theme, auto-detected from the terminal background when unset, or patches individual elements (see Themes); ->layout() arranges the screen into named regions, with ->place() and ->flow() putting blocks of your own in them (see Layouts); ->keys() sets the key bindings (see Key bindings); ->footer() toggles the key-hint footer; ->clearOnExit() keeps or clears the final frame; ->color() / ->unicode() force a display mode; ->markdown() renders the markdown subset in descriptions and notes; ->fullscreen() expands the frame to the whole terminal (see Fullscreen); and ->translator() presents chrome and questions in another language (see Translations).
A panel can be arranged too - $p->layout('two-column'), then $p->in('left') - which is the same registry the facade reads. That is the one structural choice a form does make for itself, because it is about where a panel's own blocks go rather than about the terminal.
Derived values
A Derive computes one field from others: a template with {{field}} placeholders plus a transform. A transform is any str2name conversion (machine, kebab, pascal, ...) plus host, lower, upper and initials - and an unknown name throws when the form is declared, so a typo can't sit unnoticed. Derives can feed other derives; chains settle to a fixpoint:
$p->text('slug', 'Slug')->derive(new Derive('{{name}}', 'machine'));
$p->text('label', 'Label')->derive(new Derive('{{category}}/{{slug}}', 'lower'));
$p->text('code', 'Code')->derive(new Derive('{{name}}', 'constant'));
Conditional fields
A ->when() rule shows or hides a field based on other answers, with operators eq / ne / in / contains. Compose them with Condition::all(), Condition::any() and Condition::not():
$p->text('heat_level', 'Heat level')->default('mild')->when(new Condition('category', eq: 'vegetable'));
$p->confirm('warning_label', 'Add a heat warning?')->when(Condition::all(new Condition('category', eq: 'vegetable'), new Condition('heat_level', ne: 'mild')));
The same rule gates a whole panel, a markup block and a progress row - anything that can come and go carries ->when(). A panel declares it inside its own callback, and takes everything it holds with it: while the condition doesn't hold, its questions aren't asked, aren't drawn and aren't in the answers, and a question inside it that carries a rule of its own waits on both.
$p->panel('heat', 'Heat', function (PanelBuilder $sp): void {
$sp->when(new Condition('category', eq: 'vegetable'));
$sp->select('heat_level', 'Heat level')->default('mild')->options(['mild' => 'Mild', 'hot' => 'Hot']);
});
A form-level ->fixup(new Fixup(set: ..., to: ..., when: ...)) reconciles dependent answers on every settle pass - so an answer that stops making sense after another change is corrected instead of leaking through.
Showing the dependency
A conditional block renders flush with every other one, so nothing on screen says which answer brought it into view. The indent_conditional theme option steps each one in from the answers that decide it - one step per condition in the chain, so a block shown behind three conditions sits three steps in and an unconditional one stays at the frame edge. It is off by default:
$tui = (new Tui($form))->theme('default', ['indent_conditional' => TRUE]);
Every kind of block steps in on the same gutter - a field, a markup block, a progress row and a panel alike - so a chain that mixes them reads as one hierarchy rather than as a row list with notes floating beside it.
The steps follow the rules rather than the declaration order: a rule naming several fields takes the deepest of them, and a field whose rule names another conditional field sits one step further in than that one. A panel's own rule is one step counted once by everything the panel holds, so an unconditional field inside a conditional panel sits one step in and a conditional one inside it sits two. A rule that decides for itself - a closure rather than a Condition - names no field, so nothing can be said about what it waits on and its row sits where an unconditional one in the same panel does.
Derived values, conditional fields and fix-ups each have a runnable script in playground/05-form-logic-*.