Specification
A screen is built from four kinds of thing - a screen, its layout, that layout's regions, and the blocks drawn in them. Each kind owns a fixed set of capabilities, and owns them alone.
That last part is what makes the model useful. When something doesn't obviously fit - a new kind of block, a feature that could live in two places - the question is never "where does this go", it's which level owns the capability it needs. The answer follows.
Structure
What a screen is made of, and what each part of it is allowed to do.
The hierarchy
Four levels, and nothing else:
Screen the root; occupies the terminal, or fits its contents
└─ Layout arranges; reusable by name
└─ Region holds blocks and flows them; declares whether it scrolls and whether it draws edges
└─ Block drawn in a region
One kind of block - a panel - contains a layout, which starts the chain again. That's where depth comes from, rather than from a fifth level:
Screen
└─ Layout 'default'
└─ Region 'content'
└─ Block a Panel
└─ Layout 'two-column'
└─ Region 'left'
└─ Block a Field
Nothing holds a field except a region. A field is a block, so it's placed exactly as any other block is: a panel doesn't contain fields, it contains a layout whose regions do.
Levels, kinds and instances
The four levels are what the model is made of. What you actually build with are kinds of each, and what ends up on screen are instances:
| Level | Kinds | Instances, on the screen below |
|---|---|---|
| Screen | one | the screen |
| Layout | default, panel, two-column, and the shaped grid | the screen's default |
| Region | none - a layout names its own | header, content, footer |
| Block | Panel, Field, Markup, Breadcrumb, Legend, Actions, Progress | one Breadcrumb, one Panel, three Fields, one Legend |
Layouts are the level with reusable named kinds - that's what Reuse means in the table below. Regions have no kinds at all: a region is a named slot its layout declares, so header exists because default declares it.
The rows in the next table mix the two: the first three are levels, and the seven after them are kinds of block.
Capabilities
Seventeen capabilities cover everything on screen, each described as what you can observe rather than how it's built.
| Capability | What's possible |
|---|---|
| Activate | Activating it does something, rather than revealing something. |
| Arrange | It decides where the things inside it sit. |
| Bind | It says which keys apply while it's in play. |
| Capture | It opens in place to capture something, then closes again. |
| Collect | It holds a value that ends up in the result. |
| Constrain | It says what it will accept, before you act. |
| Depend | It appears or disappears depending on other answers. |
| Descend | You go into it: the screen becomes its contents, the trail grows, and you can come back. |
| Flow | The things inside it run in one direction - down, or across. |
| Focus | You can move onto it, and your keys then act on it. |
| Nest | Other things appear inside it. |
| Occupy | It expands to the whole terminal, instead of fitting its contents. |
| Overlay | It draws over everything else. |
| Reject | It can reject what you gave it, and say why. |
| Reuse | One definition, used in more than one place. |
| Scroll | Its contents can outrun its space, and you can move through them. |
| Show | It draws something you can read. |
What claims what
| Activate | Arrange | Bind | Capture | Collect | Constrain | Depend | Descend | Flow | Focus | Nest | Occupy | Overlay | Reject | Reuse | Scroll | Show | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Screen | ✓ | ✓ | |||||||||||||||
| Layout | ✓ | ✓ | ✓ | ✓ | ✓ | ||||||||||||
| Region | ✓ | ✓ | ✓ | ||||||||||||||
| Panel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ||||||||||
| Field | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||||||||
| Markup | ✓ | ✓ | |||||||||||||||
| Breadcrumb | ✓ | ||||||||||||||||
| Legend | ✓ | ||||||||||||||||
| Actions | ✓ | ✓ | ✓ | ✓ | |||||||||||||
| Progress | ✓ | ✓ | ✓ | ✓ |
Eight readings of that table are worth stating outright, because they're the ones a new block will test.
Only a field collects. Everything else on screen shows, focuses or activates, and none of it reaches the collected result.
Focus and Activate come apart. A progress block takes the cursor and runs work while collecting nothing. Markup does neither. A field focuses and collects but never activates.
Arrange is Flow plus sizing. Both a layout and a region run what is inside them in one direction, so both claim Flow. Only the layout also apportions space between them, which is Arrange, and only the layout claims that. A panel arranges nothing at all - it nests a layout and the layout arranges, which is what lets one layout serve a panel and a screen without either knowing about the other.
Scroll is claimed twice. A region scrolls the blocks it holds, so one layout can pin a column and scroll its neighbor, and two rows can scroll independently. An arrangement scrolls the lines it stacks, all of them together, which no region of it could do for the others. One capability, two things that can be a surface - and neither does the other's job.
Occupy belongs to the screen. Whether the frame takes the whole terminal or shrinks to its contents is a property of the root, not of any layout inside it - so a layout behaves the same either way.
A panel shows and focuses only when nested. A sub-panel is a row you select to enter. The panel currently filling the screen draws no row of its own and takes no cursor; its blocks do.
Reject does not imply Collect. A field claims both: it holds a value and refuses one it will not take. Actions claims Reject alone - submit is withheld while a required field is empty, and it says why - while holding no value of its own. Refusing and holding are separate jobs, and only one of them reaches the result.
Formatting is not a capability. Markup renders as plain lines, as a bordered card, or as a table of rows and headers, and none of that changes what it can do. A table is markup laid out as a table, so it needs no row of its own. Capabilities say what a block can do; the theme says what it looks like doing it.
A note on the code below. The sections that follow build each level directly, because that's what each level is. Declaring a form rarely needs that: the builder wraps the same objects, and $p in these examples is a panel builder handed to you by Form::panel(). Building one shows how little of this a three-field form has to name.
Screen
The screen is the root. It Nests one layout, and claims one capability of its own - Occupy:
$screen = (new Screen())->layout($layout);
Without Occupy the frame shrinks to fit its contents. With it, the frame takes the whole terminal however little there is to show:
$screen->fullscreen();
The capability and the method that turns it on are named separately on purpose. Occupy is what this page calls the behavior; fullscreen() is what a consumer types. A capability describes what is possible, so it stays the same however the API spells it.
Occupy sits here rather than on a layout because it's a fact about the terminal, not about any arrangement inside it. A layout behaves identically whether the frame stretches to the terminal or shrinks to its contents, which is what makes a layout portable between the two.
Layout
A layout is composed of region instances and decides where they sit. It claims Arrange, Flow, Nest and Reuse - and Scroll, where its lines move together as one surface.
Every layout is a class extending AbstractLayout, which carries the sizing arithmetic. A subclass declares its axis - whether its regions run top to bottom or left to right - and names them:
final class DefaultLayout extends AbstractLayout {
public function __construct() {
parent::__construct(Axis::Rows);
$this->region('header')->fixed(1);
$this->region('content')->scrolls();
$this->region('footer')->fixed(1);
}
}
Two axes cover every arrangement, because the second dimension comes from nesting rather than from a grid.
Regions are named, so blocks go in by name and nothing depends on declaration order:
$layout = new DefaultLayout();
$layout->in('header')->add(new Breadcrumb());
Sizes and scrolling are stated where the regions are declared, because that's where regions come into being. A region owns Scroll over the blocks it holds; a layout owns it over the lines it stacks, and declares it on itself.
A layout draws nothing itself - neither it nor a region claims Show. And a layout with no regions renders nothing at all, because there is nowhere for a block to go.
The variations below show only the constructor body, since that's the part that differs.
The default layout
Three regions stacked, with the middle one scrolling. This is what a form gets without asking:
parent::__construct(Axis::Rows);
$this->region('header')->fixed(1);
$this->region('content')->scrolls();
$this->region('footer')->fixed(1);
╭─────────────────────────╮
│ header │ pinned
├─────────────────────────┤
│ content ▲ │
│ ▼ │ scrolls
├─────────────────────────┤
│ footer │ pinned
╰─────────────────────────╯
One region
This is what a panel takes when it names no layout, and it ships as panel. A single-region layout needs no special axis, because it is Axis::Rows that happens to declare one:
parent::__construct(Axis::Rows);
$this->region('content')->scrolls();
╭─────────────────────────╮
│ content ▲ │
│ ▼ │ scrolls
╰─────────────────────────╯
Put a breadcrumb or a legend block in that one region and it renders inline with everything else. There's no rule that says a breadcrumb belongs at the top - only the default layout says so.
Two columns
The other axis. Neither region scrolls here, so both are pinned:
parent::__construct(Axis::Columns);
$this->region('left');
$this->region('right');
╭────────────┬────────────╮
│ left │ right │
│ │ │
╰────────────┴────────────╯
Scroll is per region, on either axis, and any number of regions can claim it. Two columns can scroll independently of each other:
parent::__construct(Axis::Columns);
$this->region('left')->scrolls();
$this->region('right')->scrolls();
Two rows can too, which is the same statement with the axis turned:
parent::__construct(Axis::Rows);
$this->region('top')->flex(1)->scrolls();
$this->region('bottom')->flex(1)->scrolls();
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:
parent::__construct(Axis::Rows);
$this->region('top')->flex(30)->scrolls();
$this->region('middle')->flex(40)->scrolls();
$this->region('bottom')->flex(30)->scrolls();
╭─────────────────────────╮
│ top ▲ │ 30
│ ▼ │
├─────────────────────────┤
│ middle ▲ │ 40
│ ▼ │
├─────────────────────────┤
│ bottom ▲ │ 30
│ ▼ │
╰─────────────────────────╯
content is what it holds. A window is as deep as the rows behind it, and neither a count of cells nor a share of the remainder can say that. It stays a declaration rather than a measurement: what the region holds is counted where blocks are drawn and handed to the layout as a number, so the region states a kind and the layout still does every line of the arithmetic. The three are one closed set, so they are an enum - Sizing::Fixed, Sizing::Flex, Sizing::Content - and ->sizing() answers with it.
Declaring neither is flex(1), which is why the default layout can leave its middle region bare:
parent::__construct(Axis::Rows);
$this->region('header')->fixed(1);
$this->region('content')->scrolls();
$this->region('footer')->fixed(1);
The three mix without negotiating: the sized regions are subtracted first - the fixed ones and the ones measured by what they hold alike - then what remains is divided by the flex values. A header stays one row however tall the terminal, and the flexible regions share the rest between them.
Who calculates what
Sizing and scrolling are two calculations, and each can only be done by one level.
The layout sizes. "How many rows does content get" can't be answered by content alone - its fixed siblings have to come off the top first, and the remainder split by flex. Only the thing that sees every region can do that arithmetic, which is why Arrange is the layout's capability.
The region scrolls. Given the one number the layout hands it, everything else is its own: how tall its blocks are, where its viewport sits, whether an overflow marker is due, and how the cursor moves it. No sibling is involved, which is why Scroll is the region's.
An arrangement can scroll too, and for the same reason it sizes: moving every line together is beyond any one region, because none of them sees its siblings. So Scroll is claimed by a region over the blocks it holds, and by an arrangement over the lines it stacks - one capability, two things that can be a surface. two-column is a surface neither way and default only the first, through its content region; a grid is the one shipped arrangement that is a surface itself.
The renderer measures. A region sized by what it holds still can't size itself, because what it holds is only known where blocks are drawn. So the renderer counts each region's contents and hands the layout a map of name to cells; the layout divides the axis by those numbers. That way a layout that apportions by content still never learns that blocks exist - it is told numbers keyed by its own regions, which is the same kind of thing arrange() already deals in.
Renderer ──▸ each Region's contents are counted
Layout ──▸ each Region is given a size
Region ──▸ offset, visible rows, overflow markers
Layout ──▸ the same, over every line at once, where it is one surface
Character cells don't divide evenly, so the layout rounds: it takes the fixed sizes off, divides the remainder by the flex values, and hands any leftover cell to the last flexible region. A region never sees that arithmetic - it's told a number and gets on with it.
Both axes at once
A panel is a block that contains a layout, so nesting one inside a region gives you rows and columns together:
$layout = new DefaultLayout();
// The Panel carries a TwoColumnLayout of its own.
$layout->in('content')->add($panel);
╭─────────────────────────╮
│ header │
├─────────────────────────┤
│ ╭──────────┬──────────╮ │
│ │ left │ right │ │ a Panel in 'content',
│ ╰──────────┴──────────╯ │ laid out in columns
├─────────────────────────┤
│ footer │
╰─────────────────────────╯
This is why the axis needs no third value. Any arrangement is rows of columns of rows, as deep as it needs to be, from two primitives.
Shipped layouts
Reuse means a layout is named and reusable, so the same one serves a screen and a panel. Three ship, and a form picks one by name rather than describing an arrangement inline:
| Name | Axis | Regions |
|---|---|---|
default | rows | header (fixed 1), content (scrolls), footer (fixed 1) |
panel | rows | content (scrolls) |
two-column | columns | left, right |
Two axes and one degenerate case is enough: default and panel run down, two-column runs across, and anything else is those nested, a grid, or a layout you write yourself. panel is what a panel is arranged by when it names none.
A grid of windows
A grid is the one arrangement built from a shape rather than picked by a name:
// One window on the first visual row, two sharing the second.
$panel->layout(new GridLayout(1, 2));
Each of its windows is a region of its own, so GridLayout(1, 2) declares five: 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 panel reaches a window by naming that region, exactly as any block reaches any other, and is drawn there as a window - a column showing what is behind it rather than a row summarizing it. That is the region's declaration, not 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 which of the two the space is can only be answered by the arrangement.
above and below are where the panel's own rows go, and which of the two a row is in is the whole of where it sits: a row written before the first window is in above and draws over the grid, one written after the last is in below and draws under it, and declaration order holds either side. The builder places them without being told - the region a block reaches by naming none is above until the first window is declared and below from then on - so $p->text(...) before a sub-panel and $p->markup(...) after it end up where they read.
Both of the questions a window raises are the layout's, which is the whole of what a grid adds over panel:
| Answered by | |
|---|---|
| how deep a window is | what it holds, which is a size a region states and the layout apportions |
| how wide a window is | the layout, because the windows it shares a visual row with come off the width first |
The width is where the theme comes in: the air between two things drawn side by side is a styling decision, so the layout asks the theme for the gutter before dividing the width, and the renderer asks the same theme when it joins what the windows drew. One fact, read in both places, so they cannot disagree.
A grid Scrolls as one surface, which is the third thing only the layout can do. 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 arrangement holds the offset, and the same rule that keeps a region honest applies to it: it is drawn whole, the part in sight is what its space has room for, and the overflow mark says which edge the rest is past. Nothing is trimmed away silently, which no region does anywhere either.
The driver moves it exactly as it moves a region, because both are the same kind of thing - a surface with contents that may outrun it. Following the cursor works on the arrangement rather than on one region of it: the row the focused window starts on is counted across every line, so stepping onto a window past the edge brings its whole line into sight.
Moving through one is spatial: ← and → walk a visual row, ↑ and ↓ move between rows and out to the blocks above and below the grid. The legend advertises all four only where windows actually sit beside each other, because it reads that off the layout's lines too.
A window is a whole section, so it comes and goes with the answers exactly as one row does - and a section that is not there leaves no space where it stood. The visual row closes up: the windows still there share the width between them, the cursor steps straight past the gone one, and the row is back at its old width the moment the answer brings it back.
Going into a window leaves the grid behind: the panel takes the whole arrangement rather than the cell it was previewed in, exactly as going into any panel replaces what was drawn where it stood.
No name reaches a grid, and that is what keeps a grid a grid. Two grids of different shapes are different arrangements and a name carries no shape, so 'grid' is not among the names a form can pick, the class is refused by name too, and a grid with no visual row at all is refused where it is constructed. It is built where the shape is written: new GridLayout(1, 2), or the builder's ->layout(1, 2) that says the same thing shorter.
(new Tui($form))->layout('two-column')->run();
The name is checked where you write it, so a typo throws at declaration rather than mid-session. The names are read from the shipped layout classes rather than listed anywhere, so the list above cannot fall out of step with what actually ships.
A layout knows nothing about blocks
A layout class declares arrangement and stops there. It never names a breadcrumb, a panel or anything else that might be drawn in it - which is what Reuse actually costs. A layout carrying content opinions is a layout exactly one form can use.
The line is between the class and the instance:
| Knows about | |
|---|---|
| the layout class | its regions: names, sizes, scrolling |
| a layout instance | the blocks somebody put in those regions |
$layout = new DefaultLayout(); // arrangement, reusable
$layout->in('header')->add(new Breadcrumb()); // this instance, this form
It's tempting to let a layout furnish itself, on the grounds that only it knows a header exists. That confuses two things. Whatever places a block does need to know which region each piece belongs in, but it doesn't need to be the layout - it only needs to ask one.
So it asks. A layout answers where each piece of standard furniture goes, one role at a time - Furniture::Trail, Furniture::Body, Furniture::Keys - and a role is arrangement rather than content, so answering costs the class none of its reuse:
public function furnishes(Furniture $piece): ?string;
AbstractLayout answers with the conventional names - header, content, footer - so every layout that uses them is furnished without writing a line. A layout that calls its regions something else overrides it and says where each piece goes; one that answers NULL keeps that piece off the screen entirely - two-column answers left for the form and nothing for the trail or the keys, so neither of those is drawn. Only Furniture::Body has to be answered: a layout with nowhere to draw the form is refused where the layout is named, because there is nothing to fall back to. The trail and the keys keep tracking the session whether or not a region was kept for them; they are only never drawn.
The standard furniture is assembled by the session, alongside the form's other defaults - its theme, its key bindings, the panel it opens on. Every session assembles the same four pieces around the declared panel: a breadcrumb, the panel itself, the buttons that end the form, and a legend.
Everything it places goes into the screen's own regions, never into the panel tree. One declaration outlives the session driving it, so a session that wrote its furniture into the tree would hand the next one a form nobody declared - two sets of buttons, or a standing notice from a run that has already finished. After a session the tree holds exactly the blocks the form declared.
Writing one
Every layout is a class, shipped ones included. AbstractLayout carries the sizing arithmetic; a subclass declares an axis and its regions:
final class SidebarLayout extends AbstractLayout {
public function __construct() {
parent::__construct(Axis::Columns);
$this->region('sidebar')->fixed(24);
$this->region('main')->flex(1)->scrolls();
}
}
Register it under a short name and it's available everywhere a shipped one is:
LayoutManager::register('sidebar', SidebarLayout::class);
(new Tui($form))->layout('sidebar')->run();
Three ways to reach one, which is the same set a theme offers: by shipped name, by registered name, or by passing the class itself.
LayoutManager::create('two-column'); // shipped
LayoutManager::create('sidebar'); // registered
LayoutManager::create(SidebarLayout::class); // the class, unregistered
All three hand over a name and nothing else, so a layout any of them can build is one that asks for nothing else either. That is the whole reason a grid is reached by none of them: its shape is what it arranges, and no name says which 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.
Most subclasses only declare regions, and inherit every line of the arithmetic. Overriding that arithmetic is the other reason to subclass - a layout that packs regions to fit, or gives the focused one extra room - and it is the same door AbstractLayout already holds open.
Region
A region is a named container inside a layout. It claims Flow, Nest and Scroll.
It's a class, and its layout builds one by name and hands it back to be configured. Every capability it claims is a method, so nothing piles up as arguments:
$this->region('sidebar')
->fixed(24)
->flow(Axis::Rows)
->scrolls();
| Call | Declares |
|---|---|
->fixed(24) | 24 cells of the axis, whatever the terminal size |
->flex(2) | twice the share of the remainder that flex(1) gets |
->content() | as much of the axis as what it holds comes to |
->flow(Axis::Columns) | its blocks run across it rather than down |
->scrolls() | its contents may outrun it, and you can move through them |
->previews() | a panel in it draws as a window onto it, not as a row |
->border(...) | edges around it: the sides, the style and a title |
->add($block) | a block goes in it, packed from the start of the flow |
->prepend($block) | a block goes in it, before everything already there |
->tail($block) | a block goes in it, packed from the end of the flow |
fixed(), flex() and content() are the odd trio out: they declare a size but don't compute one, because Arrange is the layout's. The region states what it wants and the layout does the arithmetic - even for content(), where the number comes from the renderer counting what the region holds.
border() is the same call the screen and every block take, because all three occupy a rectangle. It spends the cells of whatever declared it - a row for each horizontal edge, a column for each vertical one - so siblings are untouched and a region with too little room for both draws its contents and no edge. The renderer sizes the box and the theme draws it, which is why a border fits its column at any depth.
The name is the whole point of a region: it's how a block says where it goes, so nothing depends on the order things were declared in.
$layout->in('content')->add($panel);
$layout->in('footer')->add(new Legend());
Nest is the region taking blocks, and it takes every kind the same way:
$layout->in('content')
->add(new Markup('intro', 'Pick the produce for this delivery.'))
->add($panel)
->add(new Actions());
The panel in the middle of that is a block like any other. It takes the rows its own layout comes to, and never more than the blocks placed beside it leave - which is the room it scrolls inside when it holds more. So the buttons sit under the panel's last row wherever that falls, and stay there while the rows scroll under them. Going into a nested panel is the one thing that changes: the descent replaces what is drawn there, and the blocks beside it go with the rows they stood beside.
Scroll is per surface rather than per layout class, which is why a two-column layout can pin one column while its neighbor scrolls, and why two rows can scroll independently of each other. Each region instance declares whether it scrolls - and so does the arrangement itself, where its lines have to move together.
A surface holds where it has been moved to, within the one number it was given. What it holds is measured and marked where things are drawn, and moved by whatever is driving the screen - but the offset is the surface's own, over its blocks where it is a region and over its lines where it is an arrangement. No sibling is involved either way, which is what makes Scroll the surface's own capability rather than something apportioned to it.
A region knows only that it holds blocks. It never knows which kinds - which is why the three calls above are indistinguishable to it, and why a breadcrumb can go wherever a field can.
How blocks stack
A region flows its blocks: down the region by default, or across it if you say so.
$this->region('header')->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 breadcrumb and a clock in one header is a flow, not a second layout.
Both ends of a flow
A flow has two ends, and a block says which one it is packed from. add() packs from the start of the axis; tail() packs 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 │
╰──────────────────────╯
The same statement with the axis turned, which is why it's one call rather than a left-and-right pair: 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. That way the thing a reader needs is never the thing that goes, however small the terminal gets.
Packing is placement, not a new capability: both runs are the region's blocks, drawn in the order they end up in, landed on in that order, and collected exactly as they would be anywhere else.
Flow is what a layout and a region share; sizing is what only a layout does. Both run their contents in one direction, so both claim Flow. Only a layout also apportions space between them - naming the areas, sizing them, letting each scroll on its own - and that's Arrange. A region's blocks take their natural size and sit in the order they were added, from whichever end they were packed from.
It's the difference between a grid and the text flow inside one of its cells, and you don't nest a grid to put two words on one line. So nesting a layout is for when you need what a flow can't give:
| 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 |
Blocks that outrun the region are the region's problem, not the flow's - it Scrolls if it was declared to, and clips if it wasn't.
Block
A block is anything drawn in a region. That's the whole definition: it fills the space it's given, and the region knows nothing else about it.
Every block claims Show, and only a panel nests anything - and what it nests is a layout, never blocks directly. So no block ever contains a field or another block. Seven kinds exist, and each has a section of its own below:
| Block | Beyond Show, it claims |
|---|---|
| Panel | Bind, Depend, Descend, Focus, Nest, Overlay |
| Field | Bind, Capture, Collect, Constrain, Depend, Focus, Reject |
| Markup | Depend |
| Breadcrumb | nothing |
| Legend | nothing |
| Actions | Activate, Focus, Reject |
| Progress | Activate, Depend, Focus |
Every one of them is constructed and added the same way:
$region
->add(new Breadcrumb())
->add(new Legend())
->add(new Markup('intro', 'Weighed at the packing bench.'))
->add(new Actions())
->add($panel)
->add($field);
The last four in the list are the ones easily mistaken for chrome. They aren't: a breadcrumb is a block in the header region and a legend is a block in the footer region, placed exactly as a field is placed. Which is why either can be moved, or joined by something else, without a new concept:
// A breadcrumb at the bottom, and a standing warning at the top.
$layout = new DefaultLayout();
$layout->in('header')->add(new Markup('preview', 'Read-only preview.'));
$layout->in('content')->add($panel);
$layout->in('footer')->add(new Legend())->add(new Breadcrumb());
Panel
A panel is the busiest block:
Panel "Delivery"
├─ Show as a nested row: its title and a summary of its contents
├─ Focus as a nested row: the cursor lands on it
├─ Descend going in replaces the screen and grows the trail; leaving restores both
├─ Nest it holds a layout, whose regions hold its blocks
├─ Overlay as a modal, it draws over the dimmed screen behind it
├─ Depend a whole section appears or disappears on an earlier answer
└─ Bind its keys are the ones that apply while you are in it
Nest is the panel taking a layout, which is what makes it the only block that can hold anything:
$columns = new TwoColumnLayout();
$columns->in('left')->add($courier);
$columns->in('right')->add($weight);
$panel = (new Panel('delivery', 'Delivery'))->layout($columns);
Give it a single-region layout and it reads as an ordinary list of fields, which is what a panel is most of the time:
$rows = new DefaultLayout();
$rows->in('content')->add($courier)->add($weight);
(new Panel('delivery', 'Delivery'))->layout($rows);
Descend is a panel added to another panel's region. The nested one draws as a row you select, and selecting it replaces the screen:
$layout->in('content')
->add($courier)
->add($advanced); // a Panel: a row here, the whole screen once entered
Descend is the capability nothing else has, and it's what makes a panel more than a container. A region can hold blocks and a layout can arrange them, but neither is somewhere you go.
One panel fills the screen at a time. A modal is the same block that Overlays instead of replacing what's behind it - nothing else about the panel changes:
(new Panel('confirm', 'Confirm delivery'))->modal();
Depend reads on a panel exactly as it reads on a field, and a section takes everything it holds with it:
$p->panel('certification', 'Certification', function (PanelBuilder $sp): void {
$sp->when(new Condition('organic', eq: TRUE));
$sp->text('certifier', 'Certifier');
});
While the condition doesn't hold, the questions inside are never asked - not collected, not refused, not in the result - and they re-enter the settling the moment it does. On screen the row isn't drawn and can't be landed on, and a section that goes while you're inside it puts you back where it was: it's somewhere you are rather than something you're looking at, so there'd be nowhere left to stand. Leaving is the way out, as far out as it takes to reach a section that's still there.
A question inside a conditional section waits on both rules: the section's, and any of its own. Which is also how deep it sits, since the chain is one step per condition that gates it. A section's condition is one such step, counted once by everything it holds, and a rule on the block itself is the next step in from there - so an unconditional row inside a section shown behind one answer sits one step in, and a conditional one inside it sits two.
Field
A field is the block that collects. It's the only kind that contributes to the collected result, and the only kind that captures.
A field claims eight capabilities, more than any other block. Seven of them show up in one declaration:
$p->select('basket', 'Basket contents') // Show, Focus
->description('Pick the produce.')
->option('apple', 'Apple') // Capture
->option('carrot', 'Carrot')
->multiple()
->default(['apple']) // Collect
->minSelections(2)->maxSelections(3) // Constrain
->validate($ripeness) // Reject
->when(new Condition('organic', eq: TRUE)); // Depend
The entries are what edit mode opens onto, the default is what reaches the result until you change it, the bounds are stated before you act and the validator explains itself after, and the condition decides whether the field is there at all. The line that declares an entry is ->option(), and the element that draws it is fieldEntry() - the declaration names what you supply, the element names what appears.
The eighth is Bind, and the field doesn't declare it - it comes from the field's kind. A select binds Space because it offers a list; a text field binds no printable key at all, because every one of them is something you're typing.
One field owns both modes. In view mode it's one line, drawing every part of that line itself. Open it and it switches to edit mode, taking over the region right of its label:
view mode ❯ Basket contents ⁱ apple, carrot
└─────┬─────┘
the settled value
edit mode ❯ Basket contents ● Apple
○ Carrot
└───┬───┘
the field collecting it
The label and the selector stay put across both. Only the value region changes shape, which is why a field in edit mode is still one row of its panel rather than something new on the screen. Anatomy names every piece of both.
Constrain and Reject are two capabilities rather than one because they answer different questions. Constrain states what the field will accept before you act; Reject explains why what you did was refused. They share one line on screen and never appear together - which is why Anatomy names that line's two states the constraint and the error.
Between the field and the theme sit its capabilities - the shared behavior a field draws on rather than reimplements. The chain runs one way and never doubles back:
Field ──▸ capabilities ──▸ render() ──▸ theme elements
Markup
Markup renders formatted content and does nothing else. It takes plain text or the markdown subset, and it claims Show and Depend. How it's laid out on the page is a presentation choice rather than a capability:
// Prose.
$p->markup('weighing', 'Every crate is weighed at the packing bench.');
// The same block in a bordered card.
$p->markup('notice', 'Deliveries leave at dawn.')->border();
// The same block again, laid out as a table under a title.
$p->markup('yields', '', 'Yields per crate')
->table(['Produce', 'Crates'], [['Apple', '12'], ['Carrot', '8']]);
Prose, a bordered card and a table are the same block laid out three ways. Depend is what lets a warning appear only when an earlier answer calls for it:
$p->markup('certified', 'Organic crates need current certification.')
->when(new Condition('organic', eq: TRUE));
Breadcrumb
Breadcrumb renders the trail of panels you've entered, gaining a segment as you Descend and losing one as you come back. It declares two elements:
interface BreadcrumbElementsInterface {
public function breadcrumbLabel(string $text): string;
public function breadcrumbSeparator(): string;
}
Orchard › Delivery
───┬─── ┬ ────┬───
│ │ └── breadcrumbLabel()
│ └───────── breadcrumbSeparator()
└─────────────── breadcrumbLabel()
Legend
Legend renders the keys that apply right now, rewriting itself as focus moves - so an open field lists different keys from the panel around it. It declares three, and composes them per key:
interface LegendElementsInterface {
public function legendKey(string $text): string;
public function legendDescription(string $text): string;
public function legendSeparator(): string;
}
↑/↓ to move · ↵ to accept
─┬─ ───┬─── ┬
│ │ └── legendSeparator()
│ └──────── legendDescription()
└────────────── legendKey()
A legend is written from the bindings, never by hand. It's handed the map a key press resolves against and the fragments naming what those keys do, and reads the glyphs back out of it - so a rebound key changes the line advertising it, and a fragment nothing reaches is dropped rather than drawn as a label with no key in front of it. A key written down twice, once where it's bound and once where it's advertised, is a key that drifts.
Both follow the same shape, and so does every other block: an element per distinct thing it styles, prefixed with the block's name so a theme can implement every interface on one class without collisions.
Actions
Actions is the set of buttons that end the form - submit, cancel, and any the form declares. It claims Activate because pressing one does something rather than revealing something, and Reject because it withholds the submit with a message while a required field is empty.
It's the only block other than a field that refuses anything, and unlike a field it holds no value while doing so.
interface ActionsElementsInterface {
public function actionSelector(bool $selected): string;
public function actionButton(string $label): string;
public function actionSelected(string $label): string;
public function actionSeparator(): string;
public function actionRefusal(string $reason): string;
}
Courier is required.
─────────┬──────────
└────────── actionRefusal()
❯ [ Submit ] [ Cancel ]
┬ ─────┬──── ┬ ────┬───
│ │ │ └── actionButton()
│ │ └──────── actionSeparator()
│ └─────────────── actionSelected()
└────────────────────── actionSelector()
It claims Focus, so it draws focused differently from not. Where the cursor is and which button it would press are two questions and two elements: actionSelector() marks the row the way fieldSelector() and panelSelector() mark theirs, in the same column, so the buttons line up with every other row the cursor walks; actionSelected() marks the button a key press would reach, and only while the row has the cursor - off it there is no pending press to draw. The mark is a glyph rather than a color for the same reason the other two are: a terminal drawing no color would leave a row styled alone saying the same thing whether you were standing on it or three rows above it.
The refusal is the block's own row rather than a note somebody draws above it, and it sits against the buttons with nothing between them - not even the blank row the theme puts between one block and the next. A message that could drift a row away from what it refuses is a message about nothing. It stands off the selector's column, which says where the cursor is and is not somewhere a message can be.
The brackets belong to the element, not the block. A theme that framed a button differently changes actionButton() alone, and the block goes on knowing only that it has labels and one of them has focus.
The rules above and below the buttons are no element of this block. Actions claims the border capability like anything else that occupies a rectangle and declares two of the four sides, so the renderer draws them exactly as it draws a region's box or the frame around the screen. An edge whose end meets no side is a plain run rather than a corner, which is what makes a border of two sides read as a rule.
Progress
Progress runs work when activated, drawing an indicator while the work runs. It's the block that separates Focus from Collect: the cursor lands on it and activating it does something real, but nothing it does reaches the collected result.
interface ProgressElementsInterface {
public function progressSelector(bool $selected): string;
public function progressCaption(string $text): string;
public function progressSpinner(int $frame): string;
public function progressTrack(int $filled, int $width): string;
public function progressCount(int $current, int $total): string;
}
It draws one of two ways, and which one is a fact about the work rather than a setting. Work that reports a total gets a bar:
❯ Packing crates [██████████░░░░░░] 4/10
┬ ───────┬────── ───────┬─────── ─┬─
│ │ │ └── progressCount()
│ │ └─────────────── progressTrack()
│ └──────────────────────────────── progressCaption()
└────────────────────────────────────────── progressSelector()
Work that can't say how long it will take gets a spinner instead, and the caption and the mark are the only elements the two forms share:
❯ ⠙ Fetching the price list
┬ ┬ ───────────┬───────────
│ │ └── progressCaption()
│ └──────────────── progressSpinner()
└─────────────────── progressSelector()
progressSpinner() takes the frame number rather than a glyph, so the theme owns both the animation's characters and their count - a Unicode theme can spin through ten frames where an ASCII one cycles four.
It claims Focus, so it draws focused differently from not, exactly as the buttons do and for the same reason: the indicator says what the work is doing, which is a fact about the work rather than about the reader, so the row needs a mark of its own to say that starting it is one key press away. progressSelector() puts it in the column every other row the cursor walks marks itself in.
On screen
Here it is on a real screen, each level labeled on the row it owns - the region, the block in it, then the panel's fields and the mode each is drawing:
╭──────────────────────────────────────────────────────╮
header ▸ Breadcrumb │ Orchard › Delivery │
├──────────────────────────────────────────────────────┤
content ▸ Panel │ │
▸ Field edit mode │ ❯ Basket ● Apple │
│ ○ Carrot │
│ Pick the produce. │
│ │
▸ Field view mode │ Basket weight 1200 │
│ │
▸ Field view mode │ Harvest date 2026-07-15 │
│ │
│ ▼ │
│ │
├──────────────────────────────────────────────────────┤
footer ▸ Legend │ ↑/↓ to move · ↵ to accept · ESC to cancel │
╰──────────────────────────────────────────────────────╯
The header and footer regions declare no Scroll, so they're pinned; content declares it, which is why the mark under Harvest date belongs to that region rather than to the frame. The Basket field is open, so it's in edit mode: its two entries and its description all belong to that one field.
The labels skip a level between Panel and Field, because the panel has a layout of its own and the fields sit in that layout's single region. It is elided here for the same reason it is invisible on screen: a one-region layout adds a level without adding anything to see.
Theme
A block draws itself - that's Show, and it arrives as a render() method. What a block never does is choose a color or a glyph. For those it reaches into the theme for elements:
final class Breadcrumb extends AbstractBlock {
public function render(ThemeInterface $theme): string {
// Narrowed to the elements this block declares, so a theme that cannot
// draw one says so by name instead of drawing a blank line.
$elements = $this->elements($theme, BreadcrumbElementsInterface::class, 'a breadcrumb');
$labels = array_map(static fn(string $segment): string => $elements->breadcrumbLabel(Translator::t($segment)), $this->segments);
return implode(' ' . $elements->breadcrumbSeparator() . ' ', $labels);
}
}
ThemeInterface itself carries only the two things no block could own - the width they all lay out against, and how the theme writes a key - so the block narrows it to its own elements interface before drawing. That narrowing is the whole reason the core stays thin: a theme grows by implementing more element interfaces, not by growing ThemeInterface. The one other thing it carries is a number rather than a method: DEFAULT_WIDTH, the width a theme lays out to when no terminal has been measured, which belongs to the contract because everything that builds a theme without a terminal in front of it needs the same answer.
An element takes a plain string and returns a styled one. It knows nothing about what surrounds it, which is what lets the same element draw a separator inside a form and inside a standalone line of output.
Where that line falls is worth stating outright, because it's the rule that keeps elements reusable. A theme takes plain scalars and enums and nothing else - strings, integers, booleans, and the enum case for anything one-of-a-fixed-set - so nothing handed to it can carry a field, a panel or an answer set in behind it. The rule holds for the theme-wide methods on ThemeInterface exactly as it holds for an element: keyGlyph() takes a KeyName or the character a typed key writes, never the Key the input layer carries a press around in. A closed set travels as its enum; a value object stays where it was built.
That's the whole division of labor. Order, spacing and how many elements there are belong to the block; color and glyph belong to the theme. A theme can repaint a breadcrumb but can't reorder one, because reordering isn't styling.
A block declares the elements it needs
A block names its elements in an interface, and a theme implements it - Breadcrumb and Legend above show both the interface and what each element draws.
Eight ship, one per block plus one for the frame that belongs to no block:
| Interface | Draws |
|---|---|
ChromeElementsInterface | the border, the region's overflow mark, and the two gutters |
BreadcrumbElementsInterface | the trail's segments and what stands between them |
LegendElementsInterface | a key, what it does, and what stands between two entries |
PanelElementsInterface | a nested panel's row: selector, title, descend mark, summary |
FieldElementsInterface | both of a field's modes, from its selector to its caret |
MarkupElementsInterface | a passage, one span at a time: strong, emphasis, code, links, bullet |
ActionsElementsInterface | the buttons, the gaps and rules around them, and a withheld submit |
ProgressElementsInterface | the caption, the spinner frame, the bar's track and its tally |
ChromeElementsInterface is the one named for something other than a block, and for a reason worth stating: what it draws belongs to none of them. The frame surrounds every region at once and the overflow mark says a region's contents outran it - neither is anything a block could ask for, since a block only ever fills the space it is given and never learns where that space ends, so both belong to whatever draws the screen. The gutter a conditional block steps in behind is the other kind: every block that can come and go draws the same one, so a chain of a row, a passage of markup and a row steps in together rather than one kind at a time. The air between two things drawn side by side is that kind too, and two levels spend it - an arrangement takes it off the width before dividing what is left, and the renderer leaves it when it joins what they drew - so both read the one answer rather than each keeping its own.
A field that windows a long list to a page marks its own overflow with fieldOverflowMarker() rather than borrowing the chrome's. The two say different things - one that a list the field owns ran past its page, the other that a region ran past the space the layout gave it - so each belongs to whoever draws it. The shipped theme answers both alike, so a reader still learns one mark; a theme that wants them told apart says so once in each.
Two things follow from the shape. A theme that doesn't implement the interface can't draw that block, and the failure names the theme and the interface rather than leaving a blank line. And adding a block to the library adds one interface instead of growing a single theme class that already knows about everything.
A theme declares what it supports
A terminal may have no color, no Unicode, or a background the theme should read. A theme declares which of those it handles, and declaring one is what grants the facility that goes with it:
final class OrchardTheme extends AbstractTheme implements ColorSchemeCapableInterface, UnicodeCapableInterface {
use ColorSchemeCapableTrait;
use UnicodeCapableTrait;
public function breadcrumbLabel(string $text): string {
// isDark() and paint() exist because the theme declared the scheme.
return $this->paint($this->isDark() ? Sgr::of(Sgr::Jade) : Sgr::of(Sgr::Forest), $text);
}
public function breadcrumbSeparator(): string {
// glyph() exists because the theme declared Unicode.
return $this->glyph('›', '>');
}
}
Six capabilities exist, and that is the whole set:
| Declaration | Grants | For |
|---|---|---|
ColorSchemeCapableInterface | isColor(), isDark() | painting at all, and picking a palette for a dark or light terminal |
UnicodeCapableInterface | isUnicode() | choosing between a glyph and its ASCII stand-in |
DimCapableInterface | dim() | pushing back what a dialog is drawn over |
MarkdownCapableInterface | isMarkdown() | drawing the markdown subset rather than its markers |
OccupyCapableInterface | isFullscreen(), halign(), valign(), the min/max sizes, borderStyle(), spacing(), background() | saying how much of the terminal the frame takes, and where it anchors |
OverrideCapableInterface | overrides() | taking the elements a consumer states differently |
Color and the background are one declaration rather than two, because the two questions are never asked apart: a color is chosen against a background, and a color legible on a dark terminal is not legible on a light one. The border and the air between rows join them for the same reason: an edge costs two columns and a rule, so what a frame spends on itself is part of how much of the terminal it takes.
Two of the six carry a trait with the plumbing, so a theme states a flag and inherits the rest. ColorSchemeCapableTrait brings paint() and emphasize(), and UnicodeCapableTrait brings glyph() - which is why the palette above reads as color choices rather than as escape-sequence handling. The other four are small enough to answer directly.
AbstractTheme is the floor. It implements all eight of the elements interfaces above and declares no capability at all, so it may not paint and may not reach past ASCII: what is left is the strings it was handed and the stand-ins that read without them. That is why a form renders in a terminal that supports nothing, and why a theme adds what its terminal can do rather than working around what it cannot. It's also the list: what a theme has to draw is read off the floor rather than written down a second time, so adding a block changes what a theme owes by changing the one class that already owes it.
DefaultTheme is that floor with all six declared, and it's the class a theme extends when it wants a palette rather than a blank page. It isn't a requirement. ThemeManager builds anything implementing ThemeInterface from a frame width and an options array - the constructor AbstractTheme carries - so ->theme(OrchardTheme::class) reaches a theme built straight on the floor exactly as it reaches one built on DefaultTheme, whether it's named by class, registered under a short name or shipped. What the floor doesn't declare, the driver does without: no edge is drawn around it, no air shows between its rows, nothing recedes behind a dialog, and an element patch goes nowhere. The primitives are the one thing it can't do at all, because a card, a grid and a status line are composed rather than styled: they want PrimitiveElementsInterface, and a theme without it is named rather than drawn blank.
A theme is checked where it's picked. Naming one - ->theme(OrchardTheme::class), or registering it under a short name - is where it's asked for the whole of the floor: that it can be built from a width and an options array, and that it answers for every element a form is drawn from. A class that can't is refused there by name, so a theme that would go blank on the third row never gets as far as the first. The per-block check stays as the backstop for the other way in, where a theme instance is handed straight to a block or a screen with no manager between them - that one is a type error naming the block and the interface, and it's what a test that draws one block through a theme of its own runs into.
What a theme keeps behind its elements is its own business. DefaultTheme writes each hue down once in a small set of protected voices - accent(), value(), guidance() - so a subclass repaints a whole family in a line. Those voices aren't API: nothing outside the class may name one, and no driver, block or test may reach for one. The element is the whole of the contract, and the palette behind it is an implementation detail a subclass may lean on and everything else can only see through the element that draws from it.
Overriding an element
Subclassing a theme is the full answer, and overkill when all you want is a different glyph. The facade takes element overrides directly, grouped by the block that declares them:
$tui->theme(fn(ThemeBuilder $t) => $t
->breadcrumb(fn(BreadcrumbOverrides $b) => $b
->separator('›', '>'))
->legend(fn(LegendOverrides $l) => $l
->separator('·', '|')
->key(Sgr::Bold, Sgr::BrightCyan))
->field(fn(FieldOverrides $f) => $f
->selector('❯', '>')
->helpMarker('ⁱ', '[?]')
->valueSeparator(', ')
->entrySelector('▸', '->')
->entryMarker('◼', '[x]')
->caret('█', '|')));
Inside a group the block's prefix is implied, so ->separator() under ->breadcrumb() is breadcrumbSeparator(). Three kinds of thing can be restated, and the argument count says which is which. A glyph takes the mark and its ASCII stand-in, so a patch can't set one display mode and silently break the other - ->entryMarker('◼', '[x]') states the mark a picked entry carries and what stands in for it, not two states of the entry. Text takes one argument, because a phrase the reader parses is not something a terminal fails to draw. A color takes the palette parts in order.
Nine elements can be patched this way, and that is the closed set: the breadcrumb's separator; the legend's key and separator; and the field's selector, help marker, value separator, entry selector, entry marker and caret. Naming anything else is a type error rather than a knob that quietly does nothing.
Anything the override doesn't mention keeps the theme's own answer, which is what makes this a patch rather than a replacement. Reach for a subclass when you're changing a palette; reach for this when you're changing a handful of glyphs.
Behavior
How a screen moves, and what happens when there is not one.
Driving a screen
Everything above describes a screen at rest. What moves it is one key at a time, and the two directions are worth seeing together: keys travel inward, drawing travels outward.
A key goes to the innermost thing that Binds it, and only outward from there:
key ──▸ the focused block, if it binds that key
──▸ else the panel the block sits in
──▸ else the screen, which claims none of its own
KeyRouter is what applies that rule, and it is the whole of what a key does to the screen: move the cursor, open a field, go into a panel, come back out, show a field's help.
That one rule explains a behavior that otherwise looks like a special case. An open text field binds every printable key, because each is something you're typing - so ? reaches it and becomes a character. Close the field and it binds nothing printable, so the same ? travels outward to the panel and opens help. Nobody wrote an exception; the key simply stopped at a different level.
Focus decides which block is innermost. It moves with ↑ and ↓ between the blocks that claim it, skipping the ones that don't - so a markup block sits between two fields without ever being landed on. ← and → move across, which matters where sub-panels are dealt into a grid: what is beside a window is a neighbor rather than the next row, so moving across walks the row the cursor is on and stops at its ends. Stepping off the grid is the vertical move: ↑ and ↓ cross to the next row of windows, and past the last one they land on whatever is drawn beyond the grid - the row beside it rather than under whichever window the cursor happened to be on, and nowhere at all where nothing is drawn past it.
Four kinds of key never reach the router, and all four for the same reason - none of them acts on a block. Pressing a button ends the form or closes the dialog it belongs to; activating work runs it against the terminal a step at a time; leaving is about the session rather than about anything in it; and the wheel moves the surface the cursor is on - a region, or the whole arrangement where that is what scrolls - which is not a block at all. ScreenController holds those, because a panel knows about none of them and a block never learns where it is drawn.
The wheel is the clearest of the four. It moves the viewport by a row and leaves the cursor exactly where it was, so a reader can look ahead without answering anything - and the moment they press a key that could move the cursor, the rule that keeps the cursor in sight wins the viewport back. A surface that was not declared to Scroll ignores it, because there is nothing to move through.
Drawing runs the other way, outward from the root:
Screen ──▸ gives the Layout the terminal, or as much as it needs
Layout ──▸ works out a size for each Region
Region ──▸ flows its Blocks, and scrolls them if it has to
Block ──▸ render()s, reaching the Theme for elements
Theme ──▸ returns styled strings
Each step hands down exactly one thing and knows nothing of the step after it. A layout hands a region a number; a region hands a block a space; a block hands the theme a string. Nothing reaches back up.
Collecting headlessly
The same form can collect with no screen at all - from a JSON payload, from environment variables, from an agent. Nothing is drawn, and the capabilities split cleanly in two:
| Capability | Headless | |
|---|---|---|
| Collect | ✓ | the whole point |
| Constrain | ✓ | a bound is a fact about the answer, not the display |
| Reject | ✓ | so is a refusal |
| Depend | ✓ | what a condition hides is never asked for |
| Activate, Bind, Capture, Descend, Focus, Show | nothing draws and no key arrives | |
| Arrange, Flow, Nest, Occupy, Overlay, Reuse, Scroll | there is no screen to arrange |
Four survive, thirteen don't, and the line between them is the useful part: the four are the form's meaning, and the rest is how it looks. A screen, a layout and a region are never built headlessly, because they exist only to arrange drawing. Neither is a breadcrumb, a legend or markup - a block that only Shows has nothing to contribute when nothing is shown.
Fields are built, because they're the blocks that Collect. They're built without their modes: no view, no edit, no Capture, since there's no cursor to open anything. What runs is the part that was never about the screen - the value arrives, its bounds are checked, its validator is asked, and its condition decides whether it was asked for at all.
Panels are built too, but for what they hold rather than for what they draw: a section is where the questions are, and a condition on one decides which of them are asked. Nothing about it that belongs to the screen survives - no row, no trail, nowhere to go into.
That's why the same declaration serves both. A form doesn't say how to draw itself; it says what it collects, and the drawing is a separate set of capabilities layered on top.
In practice
Putting the model to work, and settling anything it does not obviously cover.
Building one
The hierarchy is what's there, not what you have to type. A three-field form names none of it:
$form = Form::create('Orchard')
->panel('main', 'Delivery', function (PanelBuilder $p): void {
$p->text('courier', 'Courier');
$p->number('weight', 'Basket weight')->min(200)->max(9000);
$p->confirm('organic', 'Organic only?');
});
(new Tui($form))->run();
Every level has a default, and this is what those defaults are:
$layout = new DefaultLayout();
$layout->in('header')->add(new Breadcrumb());
$layout->in('content')->add($panel)->add(new Actions());
$layout->in('footer')->add(new Legend());
(new Screen())->layout($layout);
Adding markup between two fields doesn't change the shape of the code, because markup and a number are both blocks in the same region. Only one of them answers:
->panel('main', 'Delivery', function (PanelBuilder $p): void {
$p->text('courier', 'Courier');
$p->markup('weighing', 'Every crate is weighed at the packing bench.');
$p->number('weight', 'Basket weight')->min(200)->max(9000);
})
Two columns is the first thing that needs a layout, so it's the first thing that names one:
->panel('main', 'Delivery', function (PanelBuilder $p): void {
$p->layout('two-column');
$p->in('left')->text('courier', 'Courier');
$p->in('right')->number('weight', 'Basket weight');
})
Named regions mean a block says where it goes, rather than depending on the order it was declared in.
Resolving a tension
When something doesn't fit, don't argue about where it goes. Name the capability it needs, and whichever level owns that capability is the answer. Eight that have already been settled this way:
| Question | Capability | Owned by | Answer |
|---|---|---|---|
| Can markup sit in the footer? | Show | every block | Yes. A placement, not a feature. |
| Should a progress row reach the result? | Collect | field | No. It Activates, which is a different thing. |
| Can a panel scroll one column and pin the other? | Scroll | region | Yes. The left region declares it; no block changes. |
Who works out how tall content is? | Arrange | layout | The layout. Only it sees the fixed siblings that come off first. |
| Can two blocks sit side by side? | Flow | region | Yes, and without nesting a layout. |
| Can a whole section come and go? | Depend | the block | Yes. A panel claims it, and carries what it holds. |
| Who deals sub-panels into a grid of windows? | Arrange | layout | The layout - a grid. Only it can size a window against the row it sits in. |
| Can a legend sit left and a version string right? | Flow | region | Yes. Both ends of one flow, on either axis. |
Every one of them turns on the same test: which level can see what the job needs? A region can't size itself, because its siblings' fixed cells come off the top before the remainder is divided. Nor can a window, because the windows beside it come off the width first - which is why a grid is a layout rather than something a panel carries. A layout can't furnish itself and stay reusable, because it would have to know what a breadcrumb is - it can only say which of its regions the trail belongs in, and that is arrangement.
That's the whole point of the split. A region that knew what a breadcrumb was would need to know what every block is; instead a region knows only that it holds blocks, a block knows only how to fill the space it's given, and a theme knows only how to style what it's handed.
It's also what makes an element reusable. Because breadcrumbSeparator() receives no field, no panel and no answers, the same element draws the separator inside a form and inside a standalone line of output. An element that reached for form state could only ever be used from inside a form.
What is built
Every level, every capability and every element on this page is implemented and tested. These are the classes behind them:
| Level | Class |
|---|---|
| Screen | Screen, and ScreenRenderer to draw one |
| Layout | LayoutInterface, AbstractLayout, DefaultLayout, GridLayout, PanelLayout, TwoColumnLayout, LayoutManager, and the Furniture roles a layout answers for |
| Region | Region, and the Sizing kinds it states |
| Block | BlockInterface, AbstractBlock, and Panel, Field, Markup, Breadcrumb, Legend, Actions, Progress |
| Capabilities | one interface per block capability - ActivateCapableInterface, BindCapableInterface, CaptureCapableInterface, CollectCapableInterface, ConstrainCapableInterface, DependCapableInterface, DescendCapableInterface, FocusCapableInterface, OverlayCapableInterface, RejectCapableInterface - with BindCapableTrait, DependCapableTrait and FocusCapableTrait carrying the shared behavior, and beside them ScrollCapableInterface with ScrollCapableTrait, the one capability claimed outside the block level: by a Region over its blocks, and by a LayoutInterface over its lines |
| Elements | one *ElementsInterface per block, plus ChromeElementsInterface, all implemented by AbstractTheme |
| Theme | ThemeInterface (two methods and the width to lay out to when nothing else says), AbstractTheme as the floor, DefaultTheme above it, the six *CapableInterface declarations with ColorSchemeCapableTrait and UnicodeCapableTrait, and ThemeManager to name any of them and refuse one that cannot draw |
| Overriding | ThemeBuilder, the BreadcrumbOverrides / LegendOverrides / FieldOverrides groups, and the ThemeElement set they write into |
| Behavior | KeyRouter for keys, ScreenController for the session, Collector for the headless path |
| Building | Form, PanelBuilder, FieldBuilder, Assembler |
| Testing | ScreenTester for a screen, TuiTester for a whole form, FieldRunner for one field |
Two endings are worth naming beside them, because they are what a caller sees when a collection does not finish: CollectException when the answers cannot be taken as they were given, and CancelException when the form is abandoned through its cancel button.
playground/12-specification-screen.php draws a form from these, opens a field, collects the same panel headlessly, and shows a refused value naming the field and the reason. playground/20-layouts-custom.php registers two layouts of its own and arranges a panel and a screen with them, and playground/09-themes-elements.php patches a handful of elements without a theme class.