Layouts
A form never has to name a layout. Declare a panel of fields and you get the default arrangement: a trail across the top, the fields in the middle, the key hints along the bottom. A layout is what you reach for when that is not the shape you want - two columns instead of one, a taller header, a sidebar that stays put while its neighbor scrolls.
A layout is an arrangement and nothing else. It names its regions, says how big each is, says which of them scroll and whether it is a scrolling surface itself, and stops there. It never mentions a breadcrumb, a panel or a field - a layout carrying content opinions is a layout exactly one form can use. That is what makes it reusable: the same layout can arrange a whole screen and a single panel, and neither knows about the other.
A region is one named slot inside it. Blocks go in by name, so nothing depends on the order anything was declared in.
Screen the frame
└─ Layout names the regions, sizes them, says which scroll - and may scroll as one
└─ Region holds blocks, flows them, scrolls them, draws its own edges
└─ Block a field, a panel, a note, the key hints
The specification explains why the split falls exactly there. This page is how to use it.
The layouts that ship
Three, picked by name:
| Name | Axis | Regions | For |
|---|---|---|---|
default | rows | header (fixed 1), content (scrolls), footer (fixed 1) | the screen, unless you say otherwise |
panel | rows | content (scrolls) | a panel, unless it says otherwise |
two-column | columns | left, right | anything that wants its blocks side by side |
Two axes cover it, because the second dimension comes from nesting rather than from a grid: a panel is a block that holds a layout, so any arrangement is rows of columns of rows, as deep as it needs to be.
A fourth arrangement ships without a name: the grid, covered in the next section. What it arranges is a shape rather than a set of names, and no name carries a shape, so it is built where the shape is written rather than picked from this table.
Picking one
Two places take a layout, and they are separate choices.
The screen takes one on the facade, beside the theme and the key bindings - it describes the terminal rather than the questionnaire:
$tui = (new Tui($form))->layout('two-column');
A panel takes one in its own declaration, before anything is placed in it:
$form = Form::create('Market stall')
->panel('order', 'Order', function (PanelBuilder $p): void {
$p->layout('two-column');
// A block says which region it belongs to; the ones after it keep that
// region until another is named.
$p->in('left');
$p->text('item', 'Item')->default('Pear');
$p->number('crates', 'Crates')->default(6)->min(1)->max(99);
$p->in('right');
$p->confirm('gift', 'Gift wrap?')->default(FALSE);
});
->layout() comes first because every block after it has to know which regions it may go in. Declaring it after placing blocks throws, naming the panel, rather than quietly dropping the rows that had nowhere to go. So does an unknown region name in ->in(), and an unknown layout name in either ->layout().
Both calls resolve through the same registry, which is the whole of what reuse buys: one layout, named once, arranging a screen in one form and a panel in another.
PanelBuilder::layout() also takes numbers: ->layout(1, 2) arranges the panel with a grid of that shape, dealing its sub-panels into side-by-side windows. Both forms pick a layout, so mixing them in one call throws - a panel is arranged by one - and so does calling it with neither, since that names no arrangement and declares no shape.
A grid of windows
The grid is the one arrangement built from a shape rather than picked by a name:
use DrevOps\PhpTui\Screen\Layout\GridLayout;
// One window on the first visual row, two sharing the second.
$panel->layout(new GridLayout(1, 2));
The builder's ->layout(1, 2) is the same thing said shorter, and it is how a form usually reaches one.
Every window is a region. GridLayout(1, 2) declares five of them: above, then window-1, window-2 and window-3 numbered in reading order - across the first visual row, then across the second - and last below. A sub-panel goes in the window that names it, which is what the builder does for you as each one is declared, and it draws there as a window: a column showing what is behind it rather than a row summarizing it. That is the region's declaration rather than the panel's - a row has one line to say what is behind a panel and a window has the depth to show it, so only the arrangement knows which of the two the space is.
above and below are the panel's own rows, and which of the two a row is in is the whole of where it sits: written before the first window it draws over the grid, written after the last it draws under it, and declaration order holds either side. The builder puts them there without being asked - the region a block reaches by naming none is above until the first sub-panel is declared and below from then on:
$p->layout(2);
$p->text('name', 'Order name'); // above the grid
$p->panel('fruit', 'Fruit', $declare); // window-1
$p->panel('veg', 'Vegetables', $declare); // window-2
$p->markup('note', 'Crates are weighed at the bench.'); // below the grid
Both questions a window raises are the layout's, which is the whole of what a grid adds:
| Question | Answered by |
|---|---|
| how deep a window is | what it holds - a size the region states and the layout apportions |
| how wide a window is | the layout, because the windows beside it come off the width first |
The gutter between two windows comes off the width before it is divided, so they are the same width at every terminal size. How wide that gutter is belongs to the theme, so the layout asks for it before dividing and the renderer asks the same theme when it joins what the windows drew - one fact, read in both places.
A grid scrolls as one surface. A grid too tall for its space has to move every line together, and no window can move its siblings any more than it can size them, so the offset belongs to the arrangement - the third thing only the layout can do. Everything else reads exactly as it does for a region: the grid is drawn whole, the part in sight is what its space has room for, and the overflow marker points at the edge the rest is past. Nothing is dropped silently. Following the cursor works on the whole surface too, so stepping onto a window past the edge brings its line into sight.
A window that is not there closes its row up. A sub-panel gated on an answer is a whole section, and a section that has gone leaves no space where it stood: the windows still there share the width between them, the arrows step straight past the gone one, and the row is back at its old width the moment the answer brings it back.
Moving through a grid is spatial: Left and Right walk a visual row, Up and Down move between rows and out to whatever sits above or below the grid. The key hints advertise all four only where windows actually sit beside each other, because they read that off the layout too. Going into a window leaves the grid behind: the panel takes the whole frame rather than the cell it was previewed in.
Three mistakes are refused where they are written rather than mid-session: a grid declared with no visual row at all is not a grid, a visual row declaring fewer than one window is nothing that could ever be dealt into, and a shape whose slots do not exactly cover the sub-panels would leave one of them off the screen or a row of the grid empty.
Sizing a region
A region takes its share of the axis one of three ways, and both axes work the same:
$this->region('header')->fixed(1); // exactly one row (or column)
$this->region('content')->flex(1); // a share of whatever is left
$this->region('window-1')->content(); // as much as what it holds comes to
fixed is cells, and rows are why it exists. A header is one line whatever the terminal height, and no proportion can say that: 4% of a 24-row terminal is one row, of a 50-row terminal is two. Columns rarely need it; rows almost always do at their edges.
flex is a share of the remainder. Shares don't sum to anything in particular, so 30, 40, 30 and 3, 4, 3 mean the same thing. Declaring neither is flex(1).
content is what it holds, which is what a window of a grid takes. It is still a declaration rather than a measurement: what the region holds is counted where blocks are drawn and handed to the layout as a plain number, so the region says which kind it is and the layout does every line of the arithmetic.
The three mix without negotiating. Sized regions come off the top first, what remains is divided by the flex values, and any cell left over by the rounding goes to the last region taking a share - so the sizes always add up to what was available. A region never sees that arithmetic; it is told a number and gets on with it.
A terminal too small even for the fixed regions is a real state rather than an error: they are trimmed in declaration order, so the sizes still add up and the frame stays whole.
Scrolling
Scrolling is declared per surface rather than per layout - usually a region, and sometimes the arrangement itself:
$this->region('produce')->flex(3)->scrolls();
$this->region('delivery')->flex(2);
That is what lets a long produce list outrun its column while its neighbor stays pinned, and what lets two rows scroll independently of each other. A region that was not declared to scroll clips what outruns it instead.
The region does the scrolling too. Its layout hands it one number - the size it was given - and everything after that is the region's own: how tall its blocks add up to, where the viewport sits, whether an overflow marker is due, and how the cursor moves it. No sibling is involved, which is why a region can scroll its own blocks alone - and why moving every line at once has to belong to the arrangement instead.
An arrangement can be a surface too, declared the same way and moved by the same rules:
public function __construct() {
parent::__construct(Axis::Rows);
// Its lines move together rather than each region moving on its own.
$this->scrolls();
$this->region('top')->content();
$this->region('bottom')->content();
}
Use it where no single region can do the moving, which is exactly what a grid needs: its lines have to move together, and none of them sees its siblings. The layout is then drawn whole and shows what its space has room for, marking whichever edge the rest is past - the region rules, one level up.
playground/20-layouts-scrolling.php runs one grid in a frame deliberately too short for it, so the arrow keys move the whole surface and the panel's own rows travel with the windows.
Drawing a region's edges
A region draws its own box when it says so, captioned in the top edge:
$this->region('noticeboard')->flex(2)->border(BorderSide::ALL, Border::Line, 'Today at the market');
╭─ Today at the market ─╮
│ Stalls open at six. │
│ Scales close at four. │
╰───────────────────────╯
The box spends the region's own cells: a row top and bottom and a column each side of what the layout granted, exactly as the frame around the whole screen spends the terminal's. A region granted four rows shows two of them. Nothing else moves - a sibling gets the cells it always got, because the box is drawn inside the grant rather than around it. Below three rows or three columns there is no room for a box and any contents both, so the region draws its contents and no edge.
Passing no caption writes the region's own name.
playground/20-layouts-nested.php stacks four arrangements in one form - a custom screen layout, a custom two-column panel layout, a grid of windows, and the shipped two-column on a panel inside one of those windows - and runs it twice, once as it ships and once inspected.
Flow: two blocks on one line
Within a region, blocks run down it by default. Tell it otherwise and they run across:
$this->region('header')->fixed(1)->flow(Axis::Columns);
flow: Axis::Rows flow: Axis::Columns
(the default)
╭──────────────────╮ ╭──────────────────╮
│ Breadcrumb │ │ Breadcrumb Clock │
│ Markup │ ╰──────────────────╯
╰──────────────────╯
This is what saves you from nesting a layout every time two things belong side by side. A trail and a standing note in one header is a flow, not a second arrangement.
A flow has two ends, and a block says which one it is packed from. ->add() packs from the start of the axis and ->tail() from the end of it:
$layout->in('footer')
->add(new Legend())
->tail(new Markup('version', 'v1.2.3'));
flow: Axis::Columns flow: Axis::Rows
╭──────────────────────╮ ╭──────────────────────╮
│ ↵ to accept v1.2.3 │ │ ↵ to accept │
╰──────────────────────╯ │ │
│ v1.2.3 │
╰──────────────────────╯
One call rather than a left-and-right pair, because it is the same statement with the axis turned: the end of a flow running across a region is its far edge, and the end of one running down it is its last row.
Where the two runs meet in the middle, the head keeps its space and the tail is cut. A trail too long for its header pushes the version string off rather than being truncated itself, and a footer of one row draws what was packed at its start. Packing is placement and nothing more: both runs hold ordinary blocks, landed on in the order they are drawn and collected wherever they sit.
Flow is what a layout and a region share; sizing is what only a layout does. Both run their contents in one direction, but only a layout apportions space between them. A region's blocks take their natural size, in the order they were added.
| You need | Use |
|---|---|
| Two blocks side by side | a flow |
| Areas you can address by name | a layout |
| Areas at declared sizes or shares | a layout |
| Areas that scroll independently | a layout |
| Somewhere you can navigate into | a panel, which nests a layout |
playground/20-layouts-region-flow.php runs the same form twice, the same two blocks placed in both, with only the regions turned - across, where the note shares the header row and the version string the footer row, then down, where the note stacks under the trail and the one-row footer cuts the version.
Putting your own blocks in a region
The regions around the form are the session's rather than the form's, so what stands in them is stated on the facade rather than declared on a panel. ->place() puts a block in a named region and ->flow() turns that region:
use DrevOps\PhpTui\Block\Markup;
use DrevOps\PhpTui\Screen\Axis;
(new Tui($form))
->layout('market')
->place('header', new Markup('preview', '(read-only preview)'))
->place('footer', new Markup('version', 'v1.2.3'), tail: TRUE)
->flow('header', Axis::Columns)
->run();
The standard furniture is placed first, so a block of yours lands after the trail or the key hints its region already holds. tail: TRUE packs it from the end of the region's run instead of the start, which is the same statement ->tail() makes on a region.
Both take a region name, and a name the layout does not answer to throws where it is written rather than mid-session - whichever order the layout and the region were named in, since naming a layout re-checks every region stated before it.
A region running down gives each block a row and clips what outruns it, so a one-row header has room for the trail and nothing else. Turning it across, or giving it the rows, is what makes room for both.
Writing one
Every layout is a class extending AbstractLayout, shipped ones included. AbstractLayout carries every line of the sizing arithmetic, so a subclass declares an axis and its regions and inherits the rest:
use DrevOps\PhpTui\Screen\Axis;
use DrevOps\PhpTui\Screen\Layout\AbstractLayout;
final class StallLayout extends AbstractLayout {
public function __construct() {
parent::__construct(Axis::Columns);
// A share is of whatever is left over, so 3 and 2 mean the produce column
// takes half again what the delivery column does, at every width.
$this->region('produce')->flex(3)->scrolls();
$this->region('delivery')->flex(2);
}
}
Two axes exist - Axis::Rows and Axis::Columns - and that is the whole set. Declaring the same region name twice throws, naming the region. So does arranging a panel with a layout that declares no region at all, since there would be nowhere for a block to go.
Overriding the arithmetic is the other reason to subclass - a layout that packs its regions to fit, or gives the focused one extra room - and arrange(int $available, array $measured = []): array<string,int> is the method that does it; $measured is what each region's contents came to, for the ones sized by what they hold. Two more sit beside it for arrangements that draw several regions on one line of the axis: lines(): list<list<string>> says which regions share each line, and share(int $available, int $count, ChromeElementsInterface $chrome): int how much of that line each of them takes. GridLayout is the one shipped arrangement that answers either with anything but "one region to a line, the whole width".
Two more are not arithmetic at all. natural(array $measured = []): int says what the arrangement comes to when nothing has sized it, which is what a panel takes of the region it sits in. And furnishes(Furniture $piece): ?string says which region each piece of the standard furniture belongs in. Every layout inherits natural() whatever it calls its regions; a layout using the conventional names inherits furnishes() as well.
Registering one
Three ways reach a layout, and they are the same three a theme offers:
use DrevOps\PhpTui\Screen\Layout\LayoutManager;
LayoutManager::create('two-column'); // shipped
LayoutManager::register('stall', StallLayout::class);
LayoutManager::create('stall'); // registered
LayoutManager::create(StallLayout::class); // the class, unregistered
Registered or not, the name goes wherever a shipped one does - ->layout('stall') on the facade for the screen, $p->layout('stall') for a panel. Naming the class directly needs no registration at all; registering buys a short, stable alias.
Each call builds its own instance, because two forms picking the same layout must not share its regions. Registration checks the class up front - it has to implement LayoutInterface, be instantiable, and be buildable from its name alone - so an abstract class or a typo is refused where it is written rather than at the first frame.
That last check is why a grid is reached by none of these three routes: all of them hand over a name and nothing else, and a name carries no shape. A layout whose constructor takes an argument is left out of the shipped names and refused by the other two routes, naming the class rather than failing at the first frame - so layout('grid') throws exactly as a misspelled name does, and a grid is written where its shape is.
Both routes are in playground/20-layouts-custom.php, which registers one layout for a panel and another for the screen.
Where the standard furniture goes
A layout names no block, so something else has to decide that a breadcrumb belongs at the top. That is the session's job - and it asks the layout, one role at a time, rather than going looking for names it hopes are there:
| Role | Gets | Where AbstractLayout puts it |
|---|---|---|
Furniture::Trail | the breadcrumb - the trail of panels you entered | header |
Furniture::Body | the panel, and the buttons that end the form | content |
Furniture::Keys | the legend - the keys that apply right now | footer |
Those are the conventional names, so a layout that uses them is furnished without writing a line. A layout with no header shows no trail rather than being refused, and one with no content puts the form in whichever region it declared first - which is why two-column works as a screen layout even though it has neither name. The trail and the keys keep tracking the session either way; they are just never drawn.
A layout that calls its regions something else, or wants a piece somewhere else, says so:
use DrevOps\PhpTui\Screen\Furniture;
final class StallLayout extends AbstractLayout {
public function __construct() {
parent::__construct(Axis::Columns);
$this->region('produce')->flex(3)->scrolls();
$this->region('delivery')->flex(2);
}
#[\Override]
public function furnishes(Furniture $piece): ?string {
// The trail runs down the narrow column beside the form, and the keys are
// refused outright rather than being lost to a name nobody looked for.
return match ($piece) {
Furniture::Body => 'produce',
Furniture::Trail => 'delivery',
Furniture::Keys => NULL,
};
}
}
NULL is a refusal: the piece is never drawn. Only Furniture::Body has to be answered, because a screen with nowhere to draw the form is refused where the layout is named rather than opening on an empty frame.
Everything the session places goes into the screen's regions, never into the panels the form declared - so a second run over the same declaration opens on the same first frame rather than on the last run's furniture. That holds for blocks you place yourself too. A layout meant to arrange a panel's own blocks can name its regions anything, because nothing is placed in it but what the form places itself.
What a layout does not touch
Arranging exists only to draw, so headless collection ignores every word of it. The same form collects the same answers under any layout, or none - which is also why ->layout() sits on the facade beside the theme rather than in the form declaration.