A Component is a piece of user interface: it owns a Surface, has a lifecycle and a coroutine,
child components, and event handlers. The surface is a terminal like any other, so a component
draws by writing escape sequences into it, or arranges child components with a layout. A
component tree is the same structure as everything else: surfaces hosting surfaces.
use phasync\Tui\{Ansi, Component, Layout, Tty};
final class Clock extends Component
{
protected function render(): ?Layout
{
$this->surface->write(Ansi::clearScreen() . Ansi::moveTo(0, 0) . date('H:i:s'));
return null; // drew itself
}
protected function run(): void
{
while ($this->sleep(1)) { // false once disposed
$this->requestRender();
}
}
}
fiber\run(function () {
$clock = (new Clock())->start(new Tty()); // on the terminal, at its size
$clock->surface->on('key', fn() => $clock->dispose()); // any key quits
}); // returns once it has quitA screen that only shows state needs no class: an element tree and a runner.
use phasync\Tui\{State, Tui};
use phasync\Tui\Element\{Column, Text};
use phasync\Tui\Component\ProgressBar;
fiber\run(function () {
$s = new State(['done' => 0]);
fiber\go(function () use ($s) { for ($i = 1; $i <= 100; $i++) { fiber\sleep(0.05); $s->done = $i; } });
Tui::inline(fn() => new Column([
new Text("Working… {$s->done}%"),
new ProgressBar($s->done / 100),
]), keys: ['ctrl+q' => ['quit', 'quit']]);
});A closure is a component. Tui::run(), Tui::inline() and Tui::headless() take an
element, a component, or a closure that returns either. The closure is rendered (called) when
the screen is shown and again whenever a State it read changes (see "State" below): here,
every write of $s->done. It quits on the key it binds (keys: ['ctrl+q' => ['quit', 'quit']]; Tui binds none of its own). A closure that
reads no state is called once, and the component it returns is the root itself
(Tui::run(fn() => new App())).
Element content may be a closure, so one value can change in a screen that is otherwise
built once: new Text(fn() => "{$s->done}%"), a Box title fn() => "log ({$s->lines})", a
ProgressBar value fn() => $s->done / 100. Such an element is a small component of its own:
when a State its closure read changes, only it is drawn again, and the component that placed
it does not render.
Keys of the program. keys: on the runner binds keys on the root, in the syntax of
bindings() (see "Key bindings"), with a closure as the action:
Tui::run(fn() => board($s), keys: [
'up' => fn() => $s->row--, // not listed by Help
'ctrl+n' => [fn() => addCard($s), 'new card'], // listed as "ctrl+n new card"
'ctrl+s' => [fn() => save($s), 'save', 'always' => true], // also under a dialog
]);They run before the focused component's keys (a text field does not type a key the program
binds); a closure action always takes the key. always keeps one working while a dialog is
open, as in a class.
Keys and the mouse of an element. Every element takes onKey: and onMouse:
(new Text($card, onKey: [...], onMouse: fn(Mouse $m) => ...), also ->with(onKey: ...)):
-
onKey:is a bindings array as above, or a closurefn(Key $key, Element $element): bool(true when it took the key). Its actions are those of every binding: a closure (given the key and the element),'quit'(['q' => 'quit']ends the program, as inkeys:), or the name of a public method of the component whose render made the element. Its keys apply while the focus is in the element; itsfocus:classes and:focusedrules apply then (class: 'focus:inverse'),onFocus:runs when the focus enters it from outside, andHelplists its described keys. Which element is a stop of the Tab walk:- an element with
onKey:and nothing inside that takes keys (a card of text, a row of a list) is focusable itself: in the Tab walk with the components, in the order they show, and a press on it focuses it; - an element with
onKey:around components that take keys (aRowofButtons) is not a stop: Tab visits the components inside it exactly as withoutonKey:, and the element's keys apply while one of them has the focus. Sonew Row([$label, new Button('Pause'), new Button('Cancel')], onKey: ['p' => fn() => $pause($id)])pauses the row whose button has the focus.
A key goes to the focused component first (a
Buttonkeeps Enter), then to theonKey:of the elements around it, innermost first, then to the component that placed them and its parents (the program'skeys:come before everything). So an element's keys,'quit'among them (onKey: ['q' => 'quit']), work only while the focus is in that element: a key that must work wherever the focus is belongs inTui::run(..., keys: [...])(or the root'sbindings()), not on an element. - an element with
-
focused:gives the element the focus from the program's state, the wayselected:gives it theselectedlook:new Text($card, onKey: [...], focused: $s->sel === $i)moves the keys with the selection. It takes effect in the render where it becomes true (shown with it for the first time, or changed from false): a render that keeps it true does not take the focus back after the user moved it with Tab or a click. On a component,->with(focused: ...)calls itsfocus()the same way. -
onMouse:(fn(Mouse $m, Element $element)) gets the presses, drags and releases over the element, in its coordinates ($m->x,$m->y, 0 at its top-left). An element that took the press (anything butfalsereturned) gets the drag and the release wherever the pointer goes, with coordinates that may be outside it;$m->over()lists the elements under the pointer, innermost first, so the release of a drag tells where it was dropped (thekey:of a columnBox). A wheel click is taken only when the closure returnstrue, so it scrolls the window around it otherwise.
Handlers may wait. A handler (a keys: closure, onKey:, onMouse:, a widget's
onSubmit:) runs in a coroutine of its own: it may await a dialog, sleep or read a file,
and the screen goes on meanwhile (the keys typed into the dialog reach it).
Dialog::open(Dialog $d) shows a dialog on the program's root and returns the awaitable of its
answer, from any coroutine of the program; Confirm::ask($title, $text) asks yes or no:
'delete' => [function () use ($s) {
if (fiber\await(Confirm::ask('Delete', "Delete {$s->card}?"))) {
deleteCard($s);
}
}, 'delete'],One dialog at a time per root: opening another while one is open gives the open one the focus
and returns its awaitable. Without a running program, Dialog::open() throws a
LogicException.
Coroutines. The program is the fiber\run() it is in: start its coroutines with plain
fiber\go() (before Tui::run(), or from a handler) and let them wait with fiber\sleep(),
fiber\await(), a stream. An exception in one that nobody awaits ends the program at once
(fiber\on_failure()): the terminal is restored, and the exception comes out of fiber\run()
(see "How a program ends"). And they stop with it: once the program has ended (its quit key,
Tui::quit(), the root disposed), the run is cancelled (fiber\cancel(fiber\frame())): the
wait each coroutine is in throws fiber\Cancelled, and so does every later one, which ends
the coroutine quietly (catch fiber\CancelledException to clean up first, without waiting), so
fiber\run() returns when the screen is gone. The coroutine that quit goes on until its next
wait, as does the code after Tui::run(). Tui::headless() is the same: the test's run is the
program's (a test that goes on after the program, or runs several, nests each in a
fiber\run() of its own). A loop that animates is paced by the frames the
terminal gets with Tui::nextFrame(): it returns once the next frame has been drawn and sent,
with the seconds since the frame before (the dt of a game loop), so the loop runs once per
frame on a fast terminal and no faster than a slow one takes them (a frame comes at the frame
rate also when nothing changed); it ends with the program as fiber\sleep() does:
fiber\go(function () use ($s) {
while (true) { fiber\sleep(0.1); $s->load = sys_getloadavg()[0]; }
});
Tui::run(fn() => new Text("load {$s->load}"), keys: ['q' => 'quit']); // q: the loop ends with the screenCancellation needs an adapter with fiber\cancel() (phasync, Revolt, AMPHP, ReactPHP,
TrueAsync have it); on one without, the screen still ends but the coroutines run on; end such a loop
with a flag (while ($running), $running = false after Tui::run() returns). The
alternative is a class: a component's $this->go() is contained by its error boundary, so a
failure shows in the component's rectangle and the rest goes on (see "Errors stay in their
rectangle"), and it ends with the component.
Kept instances. A component made once and placed by every render ($bars[$name] ??= new ProgressBar(...)) keeps its identity wherever the next render places it: its state, its
surface and its value go with it when rows above it leave or the rows are reordered. When a
render leaves it out, it is stopped; placing it again throws a LogicException (make a new
instance, or keep it shown with the class hidden; see "Which instance is which"). Give such instances a key:: it is what
keeps an instance shown in only one frame apart from the new instances at its place, and it is
how they are reached from outside. $device->component() is the root component of what a
terminal device shows (Tui::headless() returns the device), and children() lists its
children by key: $t->component()->children()['progress']. Component::of($surface) gives the
component of a surface.
fiber\go() and fiber\go() must be called inside fiber\run() (fiber-interop/api throws
NotInRunException otherwise), which is why the program above is wrapped in one; the runners
work inside a fiber\run() of yours. Classes stay for widgets with behaviour of their own that
are used more than once (a component with its own keys, coroutines and children); a closure
program is the same thing without a name.
-
start(Surface|TerminalDevice|null $on = null)mounts a root component: on a terminal device (new Tty(), or aFakeTerminal), which gets a new root surface attached first so the first render is for the terminal's size; on a surface of yours; or on a new 80x24 surface that you attach or host afterwards (it renders again at the size it gets). Child components are started by their parent'schild(). -
mount()runs once: set up props and state here, and create children. The surface does not have its real size yet. -
Once the component has its real size,
sized()runs once, then the firstrender(), thenrun()starts as a coroutine. A root has its size at once (the terminal device's, or that of the surface you gave it). A child has it when its parent's render has placed it in a layout: it never draws for a guessed size, and a child that no render shows waits (is not sized, rendered or run) until a render shows it. The rule for component authors:mount()is for props and state; anything that depends on the rectangle starts insized(): a program that reads its terminal size once when it starts (the built-inTerminalstarts its program there), text laid out for the width, a view computed for the height. Later sizes arrive withonResize()and a render. -
render()runs again whenever the component's area changed, and afterrequestRender()(batched: calls before the next frame give one render; see "Frames" below). The area is the rectangle the component has: its size ($this->surface->cols()/rows()) and where it shows on the screen ($this->surface->area(), aRectin root coordinates). The library renders again when either changed since the last render, whatever changed it: the terminal was resized, a layout re-arranged (a parent's render that moves or resizes it, a nested layout around it that moved), or a window it is in was moved, resized or scrolled by hand. So arender()that reads its size or position (a status line that shows the size, a widget that lays itself out, a pop-up placed next to another window) is never stale, and needs no resize handler of yours.Together with
sized():sized()runs once, before the first render, when the area is first known; every later change of the area is followed by a render (and a change of size byonResize()first). -
dispose()ends it: children are disposed first, thestopped()awaitable resolves andsleep()returns false, so the component's coroutines know they are to end, and it waits for the end:unmount()runs when the last of its coroutines has returned and its children have ended (each the same way, so a parent unmounts after its children), or after$timeout(one second) at the latest, when a coroutine does not listen. Whendispose()returns,unmount()has run (a dialog closed in a key handler has let go of what it held).fiber\run()returns only when everything has ended. The surface keeps its last content. A component is disposed by its parent (when a render neither asks for it nor shows it, or the parent ends), bydispose(), or when its surface is closed (Surface::close()); in these other cases nothing waits (a render, or detaching the terminal, does not stall while a component winds down), and the end follows the same way. A disposed child is forgotten at once:child($key)gives null for it (or, with a factory, a new one).Disposing from the component's own coroutine (
$this->dispose()inrun()or ago(), quitting the program from a child's coroutine, aTerminal's$onExitclosing its pane):dispose()cannot wait for the coroutine it is called from. It signals as above and returns; the coroutine goes on withisMounted()false andsleep()returning false, andunmount()runs once it has returned. Return soon after; do not wait in it for the component's end (it cannot come while you wait). This holds for a coroutine of the component or of any of its descendants; the library knows which coroutine runs fromfiber\current().
A component's coroutines are not cancelled from outside: write loops as
while ($this->sleep(...)), check isMounted(), or race a wait against stopped():
fiber\await_any([$work, $this->stopped()]) (a fiber\go() coroutine of a program is: see
"Without a class").
Tui::run(new App()); // full screen until the app quits; Tui::inline() for a live region
// or, with the run in your hands:
fiber\run(function () {
(new App())->start(new Tty());
}); // returns when the app has quit; rethrows the exception if it failedTui::run($root) (phasync\Tui\Tui) is the whole runner: it attaches a Tty, starts the
root, waits until it has quit, gives the terminal back and returns the root component (to read
what it collected). Tui::inline($root, keep: true) does the same in inline mode, and
Tui::headless($root, $cols, $rows) starts it on a FakeTerminal inside your own fiber\run()
and returns the terminal, for tests (inline: true for inline mode). The quit key is the
program's: Tui binds none; keys: ['ctrl+q' => ['quit', 'quit']] (listed by Help as
"quit") or a component's binding to the action quit ends it, as Tui::quit() does.
$root may be an element tree or a closure (see "Without a class").
- Quitting. Dispose the root component (
$this->dispose()in its key handler), callTui::quit()from any handler or coroutine (also as a key action:'q' => ['quit', 'quit']inkeys:orbindings(), on a component without aquit()method of its own), close the root surface ($this->surface->root()->close()from anywhere), or detach the device; each leads to the others. The whole tree is disposed: everyTerminalhangs its program up, every component's coroutines are told to stop, and the terminal is given back. Then nothing is left running andfiber\run()returns. (APtyyou started yourself withPty::start()is not part of the tree; end it yourself.) - A failing root. An exception in the root component's
mount(),render(), handlers or coroutines disposes the tree and closes the surface (so the terminal is restored), and then reaches you:start()throws it when it happened there, elsefiber\run()does. A typo in a rootrender()is an error message, never a hang. - Failing code around it. When the closure given to
fiber\run()throws afterstart()(a typo: a protected method called from outside), or the coroutine that started the program does, the program ends with it: the tree is disposed, the terminal restored, andfiber\run()throws the exception, as it would without a terminal UI. The library watches the task that calledstart()(andTerminalDevice::attach()) throughfiber\current()(awaiting that task from inside it needs fiber-interop/api >= 0.3.2 and, on phasync, fiber-interop/phasync >= 0.4.2, which the library requires). UnderTui, an exception in a coroutine you started withfiber\go()that nobody awaits ends the program the same way (fiber\on_failure(), api >= 0.3.3); on a root youstart()yourself it is not seen untilfiber\run()ends, so there start such work with the component'sgo(), whose failure is the component's. - A failing child is contained to its rectangle (see below); its own children end with it.
What keeps fiber\run() running: an attached terminal device, the programs of running ptys,
and the coroutines of mounted components and your own.
| Hook | When it runs |
|---|---|
mount(): void |
Once, at start: props, state, children. Not sized yet. |
sized(): void |
Once, when the component has its real size, right before the first render: start work that depends on the rectangle here. |
render(): Layout|Element|null |
Return an element tree (see "Element trees"), or a layout of child components' surfaces, or draw into $this->surface and return null. |
propsChanged(): void |
A parent's render placed this component anew with other props; they were taken over (see "Props"). |
bindings(): array |
The keys of the component and the public methods they run (see "Key bindings"). |
run(): void |
The component's coroutine, started after the first render. |
unmount(): void |
Once, after disposal, when the coroutines have returned and the children have ended. |
onKey(Key $key): bool |
A key reached the surface; return true if handled (a render follows). |
onMouse(Mouse $mouse): bool |
A mouse event, in the component's coordinates; true if handled (a render follows). |
onPaste(Paste $paste): bool |
Text pasted while the component is on the focus path; true if handled (a render follows). |
onFocus(Focus $focus): void |
The component gained or lost the focus (a render follows). |
onResize(Resize $size): void |
The surface was resized (a render follows by itself; so does one after a move). |
wantsPointer(): bool |
Return true to follow the mouse: every motion over the component reaches onMouse() (Mouse::MOVE) and its :hover state is kept (see "The mouse"). |
canFocus(): bool |
Whether focus() gives the keys to the component itself or to the field it focused inside (see "Focus"). |
dismissedByPressOutside(): bool |
Shown modal in an overlay, whether a press outside closes it (a popup Menu; default false, a Dialog stays). See "Overlays and dialogs". |
measure(int $cols, int $rows): ?array |
The size it needs for a parent's 'auto' size: by default that of the element tree it rendered last (see "Sizes"). |
Event handlers run as the event travels down the surface tree (see hosting): a component sees an event before its focused child does, and returning true stops it there. That is where application-wide keys go (a quit key on the root component); a widget that handles keys itself (a text field) returns true for the keys it uses.
render() may return a tree of elements, plain PHP values made anew on every render. The
library lays the tree out into the component's surface (a surface for each text and each
container, the child components' own surfaces where they are placed) and paints every element
through write(), the way any producer draws: there is no other way onto a Surface. A Row
or Column that only arranges (no border, background, title or shadow of its own, no onKey:,
onFocus:, onBlur:, onMouse:, state or focus and hover look) has no surface: its items are
windows of the surface around it, so every level of nesting does not add a level of
compositing. It is still an element of the tree for the mouse (Mouse::over() lists it) and
gets its surface back when its look needs one.
use phasync\Tui\Element\{Box, Column, Help, Layers, Row, Span, Text};
protected function render(): Element
{
return new Column([
new Text(['Status: ', new Span($this->status(), class: 'ok')]),
new Row([
new Box($this->child('files', fn() => new Select($this->files())), 'files', width: 30),
new Box($this->child('log', fn() => new Log()), 'log'),
]),
new Help(),
]);
}| Element | What it is |
|---|---|
Column(array $children, int $gap = 0, padding: null, align: null) |
Children on top of each other (as Split::vertical()); each as high as its height. padding: (1, '0 1') and align: (start, center, end, stretch: where the children go across it) are the padding and align-items styles. |
Row(array $children, int $gap = 0, padding: null, align: null) |
Children side by side (as Split::horizontal()); each as wide as its width; padding: and align: as for a Column. |
Layers(array $children) |
Children over each other, the first at the bottom (as Stack); each width x height, placed by its align style (left top-left, center, right top-right). |
Box(Element $child, string|Closure $title = '', padding: null) |
A border with a title around one element; border line, colour, padding and title-align from the stylesheet or classes (border-double title-center p-1); the title takes markup, and may be a closure (drawn again by itself). |
Text(string|array|Closure $content, bool $wrap = true, align: null) |
Text, wrapped between words (or cut with "…" when wrap or the wrap style is false: truncate), aligned by align: (left, center, right: the align style); content is a string (markup and SGR sequences allowed), a list of strings and Spans, or a closure returning either (drawn again by itself when the state it read changes). Text::raw($s) shows $s without markup. |
Span(string $text, $class, $style) |
A run of text in another style, inside a Text; takes markup. |
Help(?Component $for = null) |
The keys in force for the focused component (or $for) and its parents, as "key action" pairs. |
any Component |
Its surface, where it is placed: from child(), or new (see "Props"). |
More than fits: a ScrollView. A Column (or Row) of more than its box holds is cut at
its end: the rows past the bottom are not shown, and a Button or a field among them has no
room, so it is not in the Tab walk either (Tab cannot reach what nobody sees). Put the list in
a ScrollView when it may be longer than its box (a list of jobs, a long form, anything that
grows with the data): new ScrollView(new Column($rows)). The view shows part of it, scrolls
with the wheel and the arrow and page keys, and keeps the focus in view: Tab, Shift-Tab, a
click and focus() on a component or an element with onKey: out of view scroll it into view,
also after the user scrolled away from the focused one; and the element that becomes
selected: (a card the arrow keys select) comes into view the same way, scrolled as little as
shows it. See docs/widgets.md, "ScrollView".
Attributes. Every element takes key, id, class, style (inline style properties),
width, height, disabled, selected, state (states of its own for the stylesheet:
state: 'error' matches 'Text:error'), onFocus, onBlur, onKey, onMouse and
focused (it takes the focus when this becomes true) as named arguments; a
component takes them with with() ($this->child('name', fn() => new Editor())->with(id: 'name', height: 1)). Read them with attribute('class'). A style: given beside
padding:/align: wins over them. onFocus and onBlur are called with no arguments when
the focus comes into the element (onto a component in it) or leaves it, after the render that
moved it, blurs before focuses; a focus that passes through within one frame is not reported:
new Column([$this->child('name', fn() => new Editor())], onFocus: fn() => $this->state->help = 'Your full name')Sizes. In a Column a child's height, in a Row its width, in Layers both: cells,
'25%', '1fr' (a share of what is left after the others), or 'auto' (what its content
needs: measure()). Without one, everything is 'auto': Text, Box and Help are as
high as their wrapped lines, a component as its measure() says (a one-line Editor: 1 row; a
Table: its rows and its columns' widths; a component of yours that renders an element tree:
that tree, so one rendering a Row of one-line things is one row, and a Column of such row
components needs no height:), and a container (Column, Row, Layers) as its
children need: along its direction the sum of theirs (and the gaps), across it the largest. An
element whose size is not bounded (a component whose measure() is null, a Log, a
multi-line Editor without maxLines) takes a '1fr' share instead, and so does a container
with such a child in that direction. So a toolbar Row of one-line fields, a Column of
one-row Texts and a Column of Checkboxes need no sizes. Sizes may also come from the
stylesheet (width, height), and max-width/max-height cap an 'auto' size (a Log
with max-height: 10 takes up to ten rows). When a component's measure() changes (an
editor grew a line), its parent lays the tree out again by itself.
The room is given out in this order: fixed sizes (cells, percentages) first; then, when
every 'auto' item fits with what its siblings that take any room need at least (one row each
for a field that fills, its parts for a container), the 'auto' items get what they need and the
'1fr' items share what is left. When they do not all fit, the 'auto' and '1fr' items share
the rest, each 'auto' item getting what it needs, but no more than a '1fr' share while
others still need room. So an item that needs little gets exactly that,
and one whose content is larger than the room (a Table of 10,000 rows, a one-line Editor
that measures as wide as it is given) acts as '1fr' and leaves its siblings theirs: a Help
line under such a table keeps its row, a Text(width: 20) beside such a field is drawn. Give
an element a size to decide yourself.
What stays. Elements are matched with the previous render's by their place in the tree
(positions and element types), or by key (for items of a list that come and go or move):
the surface behind each stays, and with it the focus, scroll positions and the child
components. A tree rebuilt on every render keeps everything; a Text that moves to another
place in the tree is a new text, which costs only its repaint.
Focus. Text and help do not take the focus (Surface::setFocusable(false)), nor do
containers with no component in them: a Column of a label and a field gives the keys to the
field. When the program focused nothing and the keys would go to a component that does not
take them (a ProgressBar above the fields), the root gives the focus to its first component
that does. Tab and Shift-Tab walk the components that take keys, in the order they show,
around, when nothing binds them (a Terminal's program keeps its Tab). $this->focusNext()
and $this->focusPrevious() go from field to field inside one component, around (with wrap: false, stopping at the last and returning false, so the key goes on to a parent that continues
from there); bind them for a group of its own: 'tab' => 'focusNext', 'shift+tab' => 'focusPrevious'.
Layout from the style. Besides sizes, an element's style (classes or rules, see
styling.md) gives it margin (blank cells around it), flex-grow (grow: a share
of the room left along its parent, at least what it needs), flex-shrink: 0 (shrink-0: never
less than it needs) and display: none (hidden: left out, as null would be); a Column or
Row takes padding, gap (over its gap: argument), flex-direction (flex-row,
flex-col: a Row laid out as a column and the other way round, for flex-col md:flex-row),
align-items (items-center: children as large as their content across, centred) and
justify-content (justify-between: room left spread between the children).
Mixing. A component may draw into its surface and return a tree in the same render: what it
draws shows where no element covers it. Components that draw themselves (a graph with Ansi)
go in a tree like any other.
What a render costs when little changed. The elements are made anew on every render, so
the library compares the new tree with the one it laid out last. When they lay out the same
(the same elements in the same places, with the same attributes, texts and classes, while
nothing they are styled by changed: the focus, the mouse, the sizes, the stylesheet), the last
layout stands, whatever else is new: handlers (onKey:, onFocus:, onMouse:) may be other
closures, and they are the ones that run from then on; component elements may be new
instances with other props, and the components at their places take those props (and render
when they changed, see "Props"). Nothing is styled, measured or laid out again. So a screen of
fifty rows that a closure makes anew because a bar moved in each costs the comparison of its
elements and the bars' own renders. An element the program keeps from one render to the next
and changes with with() is laid out again.
A component placed in the tree with new is a child at that place: the first render starts
it; on later renders the new instance is not started, the one already there takes its props
(its public properties that are not readonly) and attributes, propsChanged() runs, and it
renders. Its state stays.
final class Counter extends Component
{
public function __construct(public string $label = '') {} // a prop
// ...
}
// in a parent's render: the label follows the parent's state, the count stays the counter's
return new Column([new Counter(label: "clicks of {$this->state->user}"), ...]);A component of another class at the same place replaces the old one. child($key, $create)
is the other way: its factory runs once, and the parent changes the child afterwards through
its methods.
A child renders when its own things change, not when its parent renders. A parent's
render that places new Counter(label: 'A') again with the same props does not render the
counter again: nothing of the counter changed. So a child that shows something of its
parent's (a value the parent keeps in a property, the parent's state) does not follow it by
itself. Two remedies: pass the changing value as a prop (new Counter(label: $this->state->user):
a new value is new props, and the child renders), or give both the same State object (a
write renders every component that has it as its $state).
Three rules say how long a component lives and who owns it. They are the same whether the component is a built-in widget or one of yours.
newin a render: the place owns it. Make it withnewwhere the render places it and do not hold on to it. The first render starts it; later renders make a new instance that is thrown away, and the one at that place takes its props (see above) and keeps its state. Only the props and attributes of the new instance count: anything else its constructor sets (state, a callback after the first) is dropped. It lives while renders place it.child($key, $create): the parent owns it. The factory runs once, the parent holds whatchild()returns and changes it through its methods, andchild($key)finds it again. It lives while some render asks for it (child()again) or shows it; the first render that does neither disposes it, andchild($key)is then null (or, with a factory, a new one).keep($key, $create): it lives until youstop()it. For what must outlive the renders that show it: the pane of a hidden tab, a backgroundTerminal(see "Kept-alive children").
The disposal trap. An instance of your own that a render places ($bars[$name] ??= new ProgressBar(), or a pane held in a property and placed only while its tab is current) is
disposed when the next render does not place it. It cannot be placed again; the render that
tries throws a LogicException:
ProgressBar was placed in the tree again after it left the tree, which stopped it (an instance that leaves the tree is stopped): place a new instance instead (forget the old one when its row goes: unset($bars[$i])), or keep it in the tree with the class 'hidden' while it should not show
The cure follows from the rules. Place a new instance (rule 1) and let the place keep the
state; or make it with keep() (rule 3); or keep placing the instance and hide it with the
class hidden. A factory that returns a component disposed already throws child('key'): Pane was disposed.
The library's widgets, and a component of yours that others place with new, follow these:
- Props are the constructor's parameters promoted to
public(notreadonly) properties:public bool $tail = false. They are what a parent may change by rendering the widget anew with other values; the kept instance takes them, thenpropsChanged()runs, then it renders. A widget whose props need more than a render (laying out again, following the end) does it inpropsChanged(). Everything else it keeps isprivate(orreadonly): a parent changes that through the widget's methods (append(),setValue()), never by rendering. - Callbacks (
?Closure $onPress = null) are not props: they are taken from the first instance only, and each also has an event (press) for code that is not the creator. - A callback that may answer later (
Tree'sload:, which fetches a node's children) returns its answer or an awaitable of it: an array or anyTraversable(a generator) is the answer, and any other object is taken for an awaitable and passed tofiber\await()in a coroutine of the widget (afiber\go()handle, afiber\Deferred, a promise withthen()), so the widget stays usable while it waits. A widget of yours with such a callback does the same, in itsgo(). ...attributesin a signature (docs/widgets.md) means the named argumentskey,id,classandstyle, after the widget's own parameters, plusdisabledon a widget that takes input (aButton, aCheckbox):new ProgressBar(0.4, class: 'slim'). They are passed with their names, never by position. The others (width,height), and these too, go throughwith(), which every component has:(new Log())->with(height: '1fr'). The elements (Column,Row,Layers,Box,Text,Help) take all seven as named arguments.- The attributes count as props: a parent's render that gives another
class:restyles the kept instance.
$this->state is the component's State: an object whose writes render the component again.
A component has a plain one (any property names, $this->state->open = true), or declares a
class with typed public properties and assigns it in the constructor or mount():
final class TodoState extends State
{
public array $items = [];
public string $filter = '';
}
final class Todo extends Component
{
/** @var TodoState */
protected State $state;
public function __construct()
{
$this->state = new TodoState();
}
public function add(string $item): void
{
$this->state->items[] = $item; // an array changed in place counts as a change
}
}Arrays in a state may be changed in place: $s->items[] = $item, $s->progress[$i] = 0.5,
unset($s->rows[$id]) render as an assignment does (the library compares the array at the
next tick with what it was when the program read it, so the render follows a moment later, in
the same frame). Objects in a state are not watched: assign a new one, or keep the data in
arrays.
The rule, in one paragraph: a component renders again in the next frame after its state
changed, after one of its handlers took an event (onKey() and the others returning true, a
bound key), after a step of one of its coroutines (each sleep(), and the end of a go()
that waited), after a closure of the component that it gave to a child ran (a callback such as
onSubmit, a listener given to on()), when a parent's render gave it new props, and when its
area changed. Everything in one frame is one render, parents before their children, so a
coroutine loop that changes the state many times between two waits renders once per step.
Frames. A tree shown on a terminal device (Tty, FakeTerminal, a Tui runner) renders
with the device's frames: what was asked for since the last frame renders, is composed and is
drawn together, at most 60 times a second, and at once when the terminal drew nothing for a
sixtieth of a second (a key in a quiet program shows right away). A tree that is not shown on
a device renders as soon as the current coroutine yields. A component asked for again by a
render of the same frame (a child whose size changed makes its parent lay out again) renders
again in that frame, so no half-done state is drawn. sleep() ends at a tick of the same
clock (see its entry below), so the coroutines of many components wake together and are one
frame. In tests, read the screen with text() or after settle(): both draw what is pending
first.
There are no signals, observables or dependency tracking: what render() shows is computed in
render() (or in methods it calls). requestRender() is the explicit call for the rest, a
change the component cannot see.
Across parent and child. A parent whose render() reads a child widget's state (a title
that shows which tab is current, a count of the rows a Select has marked) renders again only
when something of the parent's own happens; the child's renders are the child's. Two
remedies, both in the rule above: give the child a callback made in the parent
(new Tabs($tabs, onChange: fn() => null): the parent renders after its closure ran, whatever
it does), or listen to the child's event ($tabs->on('change', fn() => $this->requestRender()),
for a child made by someone else). Every widget says which callbacks and events it has.
Where a coroutine lives decides what renders. A step of a coroutine renders the component
the coroutine belongs to, with its whole tree. A monitor whose parent runs one go() loop per
job and keeps the jobs in its own state renders all fifty rows on every step of every job; the
same monitor with a component per row whose coroutine (its run(), or a go() of the row)
updates the row's own state renders one row per step, and the parent not at all (a row whose size
did not change does not make the parent lay out again). So: state in the row, the row's
coroutine in the row, and a parent that only lists its rows. See "Performance" in
surface.md for what a frame costs.
Reading subscribes. A component whose render() reads a property of any State, not
only its own $this->state, renders again when that property is written, and not when another
property of the same state is (an onFocus: that notes the current row in $s->current
renders nothing that does not read it; the component holding the state as its $state renders
on any of its writes). A render that asks a component's hasFocus() subscribes to its focus the
same way. That is how a closure
component works, and how a component shows a state another one owns. A plain state with values
is new State(['done' => 0]); a subclass with typed properties needs no constructor (one that
has its own calls parent::__construct(), so that reads subscribe before a component holds it).
A closure that reads no State renders once. Nothing tells it to run again: a header
fn() => new Text(date('H:i:s')) shows the time it was first drawn and never moves. Make the
change visible to the render. Read a State that something writes on a tick (the usual fix),
with a coroutine of the program for the tick:
$s = new State(['now' => date('H:i:s')]);
fiber\go(function () use ($s) { while (true) { fiber\sleep(1); $s->now = date('H:i:s'); } });
Tui::run(fn() => new Text($s->now)); // reads $s->now: renders when it is writtenOr, in a component of a class, call requestRender() from its run() loop (see the Clock
at the top). fiber\go() is the program's coroutine (it ends with the program); a plain
fiber\go() is not.
$state->set(count: 0, label: 'x') assigns several properties at once. A write of the value a
property has already renders nothing; a write of a wrong type is a TypeError as for any typed
property. Shared state: give several components the same State object as their $state
(a parent passes its own to children's constructors), and a write renders all of them.
Elements and components take class:, id: and style:; the program's Stylesheet gives
each its look. Set it on the root component (it applies to the subtree; the nearest one wins)
and build it on the default theme:
$app->setStylesheet(Stylesheet::default()->with([
'Text.title' => ['bold' => true, 'color' => '$accent'],
'Box:focused' => ['border' => 'double $accent'],
'#name' => ['width' => 30],
'Select::row:current' => ['background' => '$accent', 'color' => 'black'],
], vars: ['accent' => 'magenta']));A component that renders a Box takes its border from its own classes too: $this->child('log', fn() => new Log())->with(class: 'border-accent') inside a Box that sets no border colour of
its own colours that box (a border colour on an element without a border passes down to the
nearest Box in it).
Or with utility classes and no stylesheet at all: class: 'border-round border-sky-500 p-1 grow text-slate-300', focus:bg-sky-800, flex-col md:flex-row. The full vocabulary, the
properties, markup in text (<b>, <fg=red>) and how colours reach terminals of 16 and 256
colours are in styling.md.
Selectors: a type (the element's or component's class name; parents' names match too) or *,
#id, .class, states :focused (on the focus path), :disabled, :hover (under the mouse), :selected,
an element's state: and a component's own, the screen's breakpoints :sm/:md/:lg/:xl
(from 60, 80, 100 and 140 columns), and
widget parts ::part with their own states. Properties: color, background, bold, dim,
italic, underline, inverse, strike, border (kind single/round/double/heavy/
ascii, colour, bold/dim/plain, or none), shadow, title-align, padding (and its sides), margin (and its
sides), align, wrap, width, height, max-width, max-height (cells: the most an
'auto' size, and an overlay's measured size, may take), flex-grow, flex-shrink,
display, and for containers gap, flex-direction, align-items, justify-content.
Colours and attributes inherit down the tree. The cascade: rules by specificity (ids, then
classes/states/parts, then types; later rules win ties), then the utility classes in the order
written, then inline style:. The default theme uses the 16 terminal colours and the variables
accent, muted, panel, error, success, warning. Stylesheet::explain() tells what
resolved for an element. See Stylesheet for the details.
A component that draws itself asks for its look with $this->style() (a ComputedStyle:
text is the Style for Ansi::style(), plus border, padding, align, width,
height, maxWidth, maxHeight and the layout properties) and $this->style('part', 'state')
for its parts; it renders again when its states change. A label that takes markup is drawn
with $this->markup($label, 'part', ...$states): its tags as escape sequences in that style. properties() gives a computed style as inline properties, for a Text or
Span in a part's style.
Parts that apply together compose. A run of text can be in several parts at once (a link
inside a strong span inside a heading): $this->style(['heading', 'strong', 'link']) composes
them in order, each on the one before, as an element inherits from its parent, and the first
on the component's own style. Attributes add up (bold and underlined), and the colour of the
last part that sets one wins.
A widget that draws several parts on one row (a tree's guide lines, its ▸ marker and the label; a tab's number, label and ×) asks for each as the row's part composed with its own, in the row's states: the states given apply to every part of the list.
$row = $this->style('node', ...$states); // Tree::node:current: the bar
$marker = $this->style(['node', 'marker'], ...$states); // on the bar: Tree::marker, Tree::marker:currentSo a part without a colour of its own keeps a highlighted row one bar (it inherits the row's
inverse and colours), one with a colour shows it on the row, and a rule may style a part in
the row's state ('Tree::marker:current' => ['bold' => true]). This is the one merge the
library makes; a widget never overlays styles itself.
The pen is the component's style. Before render() runs, the library sets the surface's
pen (its current SGR) to the component's style. So a render that clears the surface
("\e[2J", Ansi::clearScreen()) fills it with the themed background, and text written
without SGR of its own is in the component's colours. "\e[0m" (Ansi::reset()) resets the
pen to the terminal's default; write Ansi::style($this->style()->text) after it to come back.
States of your own. setStyleState('selected', true) puts a component in a state the
stylesheet can select ('.card:selected' => ['border' => 'double $accent']), and renders it
and the tree around it (a Box showing it); hasStyleState() reads it. A parent may set it on
a child ($card->setStyleState('selected', $key === $this->state->current)). The library's own
states are focused, disabled and hover; the widgets add theirs (Button:pressed,
Checkbox:checked, a Form field's :invalid).
Keys that are actions are data: bindings() maps a key (Key::parse() notation) to a public
method of the component, with a description for Help when it should be listed:
protected function bindings(): array
{
return ['ctrl+s' => ['save', 'save'], 'f5' => ['reload', 'reload'], 'delete' => 'remove'];
}Order. A key goes down the focus path, the root first: each component on the way may take
it with its bindings marked first and its onKey(). At the component it ends at (the
focused one): its own bindings, then its onKey(), then the bindings of its parents, nearest
first; before an owner, the components the focused one is shown in below that owner (a
Tabs and its pane around a Tree the owner made and gave the tabs). So a component has the
same keys, and Help the same line, whether its owner kept it with child() or makes it anew
in every render. A key nobody takes goes to the focused surface's program (a Terminal's shell). So a
focused text field types every letter an application binds, the application's Ctrl-Q works
wherever the focus is, and a method that returns false did not take the key (an editor's Up
on its first row), which goes on.
A component that takes every key (takesEveryKey() returns true; a Terminal does) is
the exception: while it is focused, the keys its own bindings do not take go to its program,
not to the plain bindings of its parents, their elements' onKey: or anywhere bindings. Its
parents' bindings marked first (asked on the way down) and always still come before it, and
so a shell gets Ctrl-D, Ctrl-R and Escape
whatever the application binds, and a key the application needs from inside a shell is bound
'first' => true.
A container that wants some keys first and some last (tabs around panes, a split view)
says which in its bindings: the ones it takes whatever is focused inside it (Ctrl-Tab to the
next tab, even from a shell) are marked 'first' => true, and the ones it takes only when the
focused content did not (Escape back to the tab bar, unless an editor uses it to clear its
selection) are plain:
protected function bindings(): array
{
return [
'ctrl+tab' => ['nextTab', 'next tab', 'first' => true], // before the focused pane
'escape' => ['toBar', 'tab bar'], // after it, if it did not take it
];
}Keys that work anywhere. A binding marked 'anywhere' => true also works while the focus is
outside the component, when nothing on the way took the key and no other component on the
screen binds that key anywhere; not while a modal dialog is open. Tabs marks Alt-1..9 and
Ctrl-Tab so: with one Tabs on the screen they switch tabs from a sidebar too, with two each
does it only while the focus is inside it (add keys: ['alt+1' => fn() => $tabs->select('a')]
to say which one then).
Help lists both kinds (those with a description). onKey() follows the same order as a
first binding on the way down and comes after the bindings at the focused component: keep it
for what a map cannot say (any letter, Alt and a digit).
While a modal overlay is open (a dialog), the component that opened it is suspended: its
onKey() and its bindings do not run, except bindings marked always; the bindings of its
parents run as usual. So a dialog opened by a pane leaves the application's keys working, and
a key the application must have everywhere, also under a dialog the root itself opened, is
marked:
return ['ctrl+q' => ['quit', 'quit', 'always' => true], 'ctrl+s' => ['save', 'save']];A closure as the action. Instead of a method name, an action may be a closure ('f5' => [fn() => $this->reload(), 'reload']): it gets the Key, always takes the key, and the
component renders afterwards; it may wait (await a dialog), as any handler. This is how a
program without a class binds keys (keys:, onKey:; see "Without a class").
Changing them. $component->bind(['ctrl+s' => null, 'ctrl+w' => 'save']) changes one
instance; $app->bind(['ctrl+enter' => 'submit'], Editor::class) every Editor below $app.
The nearest setting wins. bindingsInForce() lists the result. A Help element shows the
described ones for the focused component and its parents, and follows the focus.
Anything can float over everything, outside the component's rectangle and its parents' clipping: a dropdown's list, a completion popup, a context menu, a tooltip, a dialog. One primitive, on the component:
$overlay = $this->overlay(Element $element, ?Rect $anchor = null, string|Rect $place = 'below', bool $modal = false);- Hosted on the root. Every overlay is a window of the root component's surface (not of a
Layersor the opener's parent), above the whole tree, so nothing around the opener clips or covers it. - Where. Next to
$anchor, a rectangle in the component's own coordinates (default: the whole component), by$place:'below'or'above'it,'center'of the screen, or aRect(in the component's coordinates). The root's rectangle is a hard boundary: on the preferred side if the overlay fits there, else on the other side if it fits there, else on the side with more room, shrunk to that room (its content scrolls: a list of 30 items in 6 free rows shows 6 and scrolls); shifted left or right to stay inside, narrowed only when it is wider than the screen. The decision isPlacement::for(), a pure function a widget can also call for itself (see layout). - When the screen changes. After the layout of every frame, before it is painted, each overlay is placed again where its anchor is now: the terminal resized, the anchor's component moved or scrolled, the tree rebuilt. It keeps its state (a list keeps its current item, scrolled into view in its new height). When the anchor's component is no longer shown, the overlay is cancelled.
- Size. The element's
width/heightwhen given (cells, or'50%'of the screen), else what it measures, else the whole screen; capped by its style'smax-width/max-height; plus the border its style gives it. What it measures is asked again every frame: when the content grows or shrinks (a completion list filtered by typing, a dialog showing a validation message), the overlay is placed again at its new size and what it no longer covers shows again. - Order. Later overlays above earlier ones, kept across renders.
- Focus. The overlay gets it, by the surface tree's rule (a widget that keeps the keys, as
an
Editorwith its completion list, calls$this->focus()after opening it). Closing gives it back to where it was. - Modal overlays take all input until they close: clicks outside reach nothing, and the
opener's keys are suspended (see "Key bindings"); a modal content whose
dismissedByPressOutside()is true (a popupMenu) is also closed by a press outside it, which still activates nothing below. Non-modal ones are cancelled by Escape (when their content does not take it), a press outside them and their opener, or the focus moving to something else. - The element is shown by a child component of the opener: the element itself when it is a component (not started yet), else a component of the library around it that scrolls it. Disposing the opener cancels its overlays.
The returned Overlay: close(mixed $result = null) closes it with a result, closed() is an
awaitable of the result, isOpen(), result(), and content (the component shown). A
cancelled overlay closes with null (a Dialog: its $cancel). A component shown in one finds
it with inOverlay().
public function openMenu(): void // bound to a key
{
$menu = $this->overlay(new Select(['copy' => 'Copy', 'paste' => 'Paste'],
onActivate: fn($key) => $menu->close($key)));
$this->go(function () use ($menu) {
$action = fiber\await($menu->closed()); // null when cancelled
});
}Dialogs. $this->dialog(Dialog $dialog, ?string $key = null): object is an overlay in
the middle of the screen, modal, and returns an awaitable of the answer. Await it in a
coroutine:
public function delete(): void // bound to a key
{
$this->go(function () {
if (fiber\await($this->dialog(new Confirm('Delete the file?')))) {
unlink($this->state->file);
}
});
}While it is open, the component that opened it is suspended (its bindings marked always, and
its parents' bindings, still work: an application's Ctrl-Q quits). Escape cancels; when it
closes, the focus goes back where it was. Quitting with dialogs open ends the program:
disposing a component (the root, or any child) while dialogs it opened are open answers each
with its cancel value and disposes it, so the coroutines awaiting them go on and end, and
fiber\run() returns. See widgets for Dialog and Confirm.
One at a time. A key that opens a dialog and works while one is open (an always
binding: Ctrl-Q asking "Quit?") would open a second one when pressed twice. Give the dialog
a key: $this->dialog(new Confirm('Quit?'), key: 'quit'). While the dialog of that key is
open, the call gives it the focus and returns its awaitable; the new dialog is not shown.
Both coroutines get the one answer, so act on it in a way that is fine twice (dispose()).
focus() in mount() sets where a screen starts; in render() it takes effect on the first
render that asks and on renders asked for with requestRender(), not on the renders the
library makes by itself (after a key, a state write, a coroutine step), so a render does not
take back the focus the user moved.
When focus() takes effect. In mount() it is the first focus and it sticks: the
component is started when mount() runs, so the request is made at once (a component not
started yet, made with new and focused before it is placed, gets it when it starts). A
focus() anywhere else is a request like any other and the latest wins; so a focus() in
mount() of a component that is created later, in a pane the user is not in, takes the keys
from where they are. In render() it is honoured only on the first render that asks and on
renders asked for with requestRender() (above): a render that repeats it after the user moved
on does nothing, which is why a focus() there seems not to stick. For "when this appears,
focus it", call it in mount(), or in the handler that creates the component.
Which component receives it. focus() on a component with keys of its own (bindings,
onKey()) focuses it; on a container without keys of its own (a pane, a Box, a Form) it
focuses what the container focused inside, else its first component that takes keys. So
$pane->focus() is "the first widget in the pane" when the pane itself takes no keys. The
same rule is used by Tabs::focusContent(): its first component that takes keys, else the
first component, else the pane; it returns false when the focus is in the content already.
A program that focuses nothing needs no call: the root gives the focus to its first component
that takes keys (see "The first focus and Tab, by default").
One component has the keyboard: the one focused last (focus()), or the descendant it passes
the keys on to. A component with keys of its own takes them itself when it is focused:
one with bindings() (or keys given with bind()) or an onKey()/onPaste() of its own. A
board that focused its filter field takes the focus back with $board->focus(). A container
(a component without keys of its own: a Box, a Dialog, a Form, a screen of fields)
passes the focus on to the descendant it focused, or to its first focusable one: a dialog that
focuses its text field in mount() and is then focused itself gives its keys to the text
field. Override canFocus(): bool to decide otherwise.
final class NameDialog extends Component
{
protected function mount(): void
{
$this->child('name', fn() => new TextInput())->focus(); // the dialog's own focus
}
// render() shows the TextInput
}
// in the parent's key handler: create the dialog, focus it, render it
$this->dialog = $this->child('dialog', fn() => new NameDialog());
$this->dialog->focus();
$this->requestRender();The rule is the same before and after the component is shown, so nothing has to be repeated in
render():
- Before a render shows it (the keys typed right after the one that opened the dialog), the
keys reach it through its nearest shown ancestor, which passes them on to it as if it were on
the focus path, and it passes them on to the descendant it focused (the text field gets
them, not only the dialog's own
onKey()).hasFocus()is true for it and for that descendant, and false for the window that had the focus before. - Once a render shows it, the surface tree's focus path leads to the same component and on
to the same descendant (the surfaces remember the requests; see
Surface::focus()).
The latest request wins wherever it is: focusing another component, shown or not, takes the
keys there. Of several children none of which was focused, a host gives the keys to its first
window; a component not rendered yet gives them to its first child (as a Box does to its
content).
The first focus and Tab, by default. A program that focuses nothing still starts with a
field that has the keys: when the first window would give them to a component that does not
take keys (a ProgressBar, a Spinner, a label drawn by a component), the root gives the
focus to its first component that does (canFocus(): one with bindings or an onKey() of its
own, a Terminal), in the order they show, top to bottom and left to right. Once the program
or the user focused something, the root leaves the focus alone. Tab and Shift-Tab, when
nothing on the key's way binds them and the key ends at a component with keys of its own, go to
the next (previous) such component in the same order, around; while a modal dialog is open,
only among the components in it. A Terminal has no keys of its own (they go to its program),
so a shell keeps its Tab; a component that binds tab (a Form, Tabs, an Editor with
completion) keeps its meaning. Elements with onKey: and nothing focusable inside are in the
same walk; one around focusable components is not, its components are (see "Without a
class"). Container widgets that move the focus themselves use the same order:
Component::focusOrder($surface) lists the stops below a surface. Only what the current render shows is in the walk: a component kept alive but not
shown (the Terminal of a hidden tab, a keep() child, a component given as a prop), whoever
owns it, is skipped, and keys do not go to it, until it is shown again or focused by
focus(). When the focused component is no longer shown, the component focused before it gets
the focus back.
The focus belongs to the surface, not to the layout objects around it: a render() that builds
new Stacks and Splits every time, with new plain surfaces in them and the focused
component's surface added directly with a border and title
(->add($this->dialog->surface, 40, 5, title: 'Name')), keeps the keys on the focused
component. hasFocus() reads the same path the keys take, so it is true exactly when the next
key reaches the component (or goes through it).
Mouse events go to the window under the pointer, in its coordinates, each component on the way
seeing them first (onMouse()). The component that took the press gets the whole drag and
the release, wherever the pointer goes, with coordinates relative to its own rectangle that
may be outside it (negative, or beyond its width and height): drag and drop between siblings
needs nothing of the root's. (A program in a Terminal that asked for mouse reports gets them
at the edge of its window, as a terminal reports them.) Return true from the press to take
the drag; a press nobody takes starts the host's text selection.
Following the pointer. Motion without a button is reported only to components that ask
for it: one whose wantsPointer() returns true, or that the stylesheet has a :hover rule for
('Card:hover'), or for one of its parts ('Tabs::tab:hover'). Such a component gets every
motion over it in onMouse() ($mouse->action === Mouse::MOVE) and its :hover state is
kept; its surface asks the terminal for motion reports (mode 1003), so it takes its mouse
events itself. A widget lights up the part under the pointer by passing the part the state
hover ($this->style(['bar', 'tab'], 'hover')).
final class Menu extends Component
{
private ?int $under = null;
protected function wantsPointer(): bool
{
return true;
}
protected function onMouse(Mouse $mouse): bool
{
$this->under = $mouse->y; // the item under the pointer; a render follows
return true;
}
// render() draws the item on row $this->under in $this->style(['item'], 'hover')
}A child made with child() lives while renders ask for it or show it: the first render that
does neither disposes it. A component that holds panes the current render does not show
(hidden tabs, a log collecting in the background, a shell that goes on running) makes them
with keep() and ends them itself with stop():
protected function render(): Element
{
$pane = $this->keep("pane:$this->current", fn() => new Terminal($this->commands[$this->current]));
return new Column([$this->bar(), $pane]); // the others stay alive, unshown
}
public function closePane(string $name): void
{
$this->child("pane:$name")?->stop(); // returns at once; the program is hung up
}A kept child is started at once and sized and rendered when a render first shows it (a
Terminal starts its program then). stop() is dispose() without the wait, for renders and
handlers; it leaves the parent at once (child($key) gives null). Tabs keeps its component
panes this way.
The methods of the library are final (all in the table that are not hooks to override:
render(), mount(), run(), measure(), onKey() and the other hooks of "Hooks to
override", canFocus(), wantsPointer(), bindings()): a method of yours with the name of one
fails where it is declared ("Cannot override final method"), not somewhere at run time. A name
the library uses privately (key(), end()) is free: a public key() of yours is bound like
any other method.
| Method | What it does |
|---|---|
surface |
The component's surface (readonly; set by start()). |
start(Surface|TerminalDevice|null $on = null): static |
Mount a root component, on a terminal device, a surface, or a new surface. Throws when already started; rethrows a failure of its first mount()/render() after disposing what was started; ends the program when the code that called it fails (see "How a program ends"). Tui::run($root) does it all for a program. |
dispose(float $timeout = 1.0) |
End the component (see the lifecycle): signals its coroutines and children and waits until they have ended, $timeout seconds at the latest; unmount() has run when it returns, except when called from a coroutine of the component or a descendant (it returns at once, and the end follows when that coroutine returns). For the root, its surface is closed first, which detaches the terminal. Safe to call twice. |
stop(float $timeout = 1.0) |
As dispose(), without waiting: the end follows when the coroutines have returned. For a child the render no longer wants (a closed tab's pane, a keep() child) from a render or a handler. |
isMounted(): bool |
True between start and dispose. |
stopped(): object |
An awaitable that resolves on disposal. |
requestRender() |
Ask for a render in the next frame (batched). Rarely needed: see "State: rendering by itself". |
with(key:, id:, class:, style:, width:, height:, disabled:): static |
Set element attributes (see "Element trees"); returns the component. attribute(string $name) reads one. |
measure(int $cols, int $rows): ?array |
The size it needs as [cols, rows], or null to take what it is given; for 'auto' sizes. By default the size of the element tree its last render returned (null for one that draws itself); override it in a component that draws itself and knows its size. The answers are remembered until the component is marked for a render (a state write, a handler, requestRender()) or renders, so compute it from what goes with a render: state, props, style. |
device(): ?TerminalDevice |
The terminal device its tree is shown on (Tty, FakeTerminal, also one Tui::run(), Tui::inline() or Tui::headless() made), or null when the tree is not attached: $this->device()?->println('done'), freeze(). |
style(string|array|null $part = null, string ...$states): ComputedStyle |
Its look from the stylesheet in its current states, or that of a part, or of several parts composed (innermost last; see "Stylesheets"). |
markup(string $text, string|array|null $part = null, string ...$states): string (protected) |
$text with its markup (<b>, <fg=red>, see styling.md) turned into escape sequences in the style of the component or of its part: how a widget draws a label that takes markup. |
stylesheet(): Stylesheet / setStylesheet(Stylesheet $s) |
The stylesheet in force (its own, else its parent's, else the default); set one for the subtree. |
theme(): string / setTheme(?string $name) |
The theme in force (dark, light, mono: its own, else its parent's, else the one Tui::run(theme:) or the terminal's background chose); set one for the subtree, null to follow the parent. A stylesheet built with ->with() is replayed on the theme (see styling.md). |
bind(array $bindings, ?string $for = null, bool $first = false): static |
Change the keys of this component, or of every component of class $for below it (see "Key bindings"); with $first, every binding given is marked first. bindingsInForce() lists the keys in force. |
Component::of(Surface $surface): ?Component (static) |
The component whose surface $surface is (the root on a device's root surface), or null: how code that has a surface (an event's, $device->root()) reaches the component. TerminalDevice::component() is the root component of a device. |
types(): array |
The type names it matches in a stylesheet (its class's short name and its parents'). |
focusNext(bool $wrap = true): bool, focusPrevious(bool $wrap = true): bool |
Move the focus to the next (previous) field inside it, around; with $wrap false, false at the last (first) one so the key goes on. Bind them to Tab and Shift-Tab for a group of fields of its own (unbound, Tab walks every component that takes keys; see "Focus"). |
focus() |
Give it the focus (on a component not started yet: when it starts), and through it to the descendant it focused (see "Focus" above): every host from the root down focuses the way to it, components or plain surfaces or nested layouts (see Surface::focus()). It takes effect at once, for the very next key, also for a component not shown yet (a dialog created in a key handler, shown by the render that follows). The focus stays with it through every re-render, also one that moves it into a different nested layout; when it is no longer shown, the component focused before it gets the focus back. |
hasFocus(): bool |
True if keys go to it or through it to a descendant, also while it is not shown yet: it follows the path the keys take. False once disposed. Asked of a new instance that a render handed over to the one running at its place, the running one's answer; asked by a render, that render runs again when the answer changes. |
running(): ?Component |
The instance that runs for this one: itself once started; for a new instance a render made (new Editor() in a closure) that was matched by its place or key: to the instance running there, that one; null for one never placed. Ask the running one what it shows: $editor->running()?->value(). |
on(string $event, Closure $listener): Closure |
Listen to an event of the component (the built-ins document theirs: Log and ListView emit scroll); returns a function that removes the listener. See "Showing another component's state". |
parent(): ?Component |
The parent, null for a root. |
error(): ?Throwable |
The exception that ended it, if one did. |
child(string $key, ?Closure $create = null): ?Component (protected) |
The child under $key, created with $create and started the first time it is asked for. Without $create: the child of that key, or null when there is none or it was disposed ($this->child('dialog')?->dispose() in a handler). After a render that returned a layout, children it neither asked for (either way) nor shows are disposed: one whose surface the layout shows is kept, however it got there. $create may return a component started already (another's child, given to a container): it is shown here and stays its owner's. |
keep(string $key, Closure $create): Component (protected) |
A child that lives until it is stopped, whatever the renders show: the panes of hidden tabs, a background Terminal. Made and started the first time; child($key) finds it; end it with stop() or dispose(). |
emit(string $event, mixed ...$data) (protected) |
Call the listeners of $event (see on()); document the events your component emits. hasListeners(string $event): bool tells whether anyone listens, to skip work only a listener needs. A listener made by a component renders that component afterwards. |
call(?Closure $callback, mixed ...$args): mixed (protected) |
Call a callback the component was given (onSubmit); the component that made it renders afterwards. Use it for the callbacks of your own widgets. |
overlay(Element $element, ?Rect $anchor = null, string|Rect $place = 'below', bool $modal = false): Overlay (protected) |
Show an element floating over everything (see "Overlays and dialogs"). |
dialog(Dialog $dialog, ?string $key = null): object (protected) |
Show a modal dialog in the middle of the screen; an awaitable of its answer (see "Overlays and dialogs"). With a $key, one of that key that is open already gets the focus and its awaitable is returned instead (one at a time). |
inOverlay(): ?Overlay |
The overlay this component is shown in, or null. |
children(): array |
The child components by key: child() keys, key: attributes, and keys of the library's (beginning with "\0") for the rest. How a parent or a test reaches a child: $app->children()['log']. |
setStyleState(string $state, bool $on = true), hasStyleState(string $state): bool |
A state of its own for the stylesheet (see "Styles"). |
canFocus(): bool (protected) |
Whether focus() gives it the keys itself or passes them on (see "Focus"). |
wantsPointer(): bool (protected) |
Whether it follows the mouse (see "The mouse"); true by itself with a :hover rule for it or one of its parts. |
measureElement(Element $element, int $cols, int $rows): ?array (protected) |
The size an element tree needs, as the library lays it out: for measure() of a component as large as the tree it renders. |
layoutElement(Element $element): Layout (protected) |
An element tree laid out as a layout, not set: for a component that shows a tree on a surface of its own (as ScrollView does). |
state (protected State) |
The component's state (see "State: rendering by itself"). |
go(Closure $task) (protected) |
Another coroutine of the component: contained like run(); the component's end waits for it; when it ends after it waited, the component renders. |
sleep(float $seconds): bool (protected) |
Sleep about $seconds, less if disposed meanwhile; returns isMounted(). What the coroutine did before shows in the next frame. The sleep ends at a tick of the frame clock (60 a second): the first after the time asked for, or one at most 2 ms before it, so many components' loops wake together and a sleep(0.1) loop still makes ten steps a second. For timing to the millisecond, use fiber\sleep(). |
protected function render(): ?Layout
{
return Split::vertical()
->add($this->child('log', fn() => new Log())->surface)
->add($this->child('input', fn() => new TextInput(prompt: '> '))->surface, 1);
}Children need not be direct: a layout can nest layouts (Split::vertical()->add(Split::horizontal() ->add(...))), or hold a plain surface with a layout of its own; focus goes through either.
A container given an existing child. A component made with child() can be handed to a
container such as Box (or one of yours that takes a component and calls child($key, fn() => $this->content) in its mount()). The container shows it and passes keys to it; it stays
yours: when the box goes, the child stays, with its content.
$log = $this->child('log', fn() => new Log());
return Split::vertical()->add($this->child('box', fn() => new Box($log, 'output'))->surface);A widget whose content is taller than itself (a document, a list it draws, a log) uses the
trait Component\Scrollable for its scrolling, so it scrolls like every other one: Log and
Markdown do. The widget says how many rows its content has (contentRows()) and draws rows
from scrollTop(); the trait gives the rest:
contentRows(): int (abstract, protected) |
How many rows the content has, at the width the widget has now. |
viewRows(): int (protected) |
How many it shows at once: the widget's height by default. |
scrollTop(): int |
The content row at the top of the view (0 the first). |
scrollTo(int $row): bool, scrollBy(int $rows): bool |
Move the view (clamped; it renders again); false when it did not move. |
lineUp, lineDown, pageUp, pageDown, toTop, toBottom |
The actions of the keys (a page is the height less one row); false when they could not move, so the key goes on. |
followTail(bool $on = true) (protected) |
Follow the end while content arrives, until the user scrolls up; back at the end it follows again. |
follow(), isFollowing(): bool |
Follow the end again; whether it does. |
scrollBindings(): array (protected) |
Up/Down, Page Up/Page Down, Home/End for bindings(): [...$this->scrollBindings(), 'q' => 'close']. |
scrollWheel(Mouse $mouse): bool (protected) |
For onMouse(): the wheel scrolls three rows a click. |
scrolled() (protected) |
Emits the event scroll (with the widget) when the rows in view or the following changed; call it after drawing. |
scrollbar(int $x): string (protected) |
Escape sequences drawing a scrollbar in column $x, the view's height: the track in ::scrollbar, the thumb in ::scrollbar:thumb; blank when everything shows. |
scrollState(), setScrollState(int $top, bool $following) (protected) |
Where the position is kept; override both to keep it elsewhere (Log scrolls a viewport). |
final class Lines extends Component
{
use Scrollable;
public function __construct(private array $lines = [])
{
$this->followTail();
}
protected function contentRows(): int
{
return count($this->lines);
}
protected function bindings(): array
{
return $this->scrollBindings();
}
protected function onMouse(Mouse $mouse): bool
{
return $this->scrollWheel($mouse);
}
protected function render(): null
{
$out = "\e[2J";
foreach (array_slice($this->lines, $this->scrollTop(), $this->viewRows()) as $y => $line) {
$out .= Ansi::moveTo(0, $y) . Text::truncate($line, $this->surface->cols() - 1);
}
$this->surface->write($out . $this->scrollbar($this->surface->cols() - 1));
$this->scrolled();
return null;
}
}A render() that reads another component's state (a pane title with the lines a Log shows, a
"12-30 of 200" next to a ListView) must run again when that state changes. The built-ins emit
an event for what others commonly show; listen with on(): a listener made by your component
renders it again after it ran (renders are batched, so a burst of events gives one), and the
requestRender() below is only there to say so:
protected function mount(): void
{
$this->log = $this->child('log', fn() => new Log());
$this->log->on('scroll', fn() => $this->requestRender());
}
protected function render(): ?Layout
{
$title = sprintf('lines %d-%d of %d', $this->log->topLine() + 1, $this->log->bottomLine() + 1, count($this->log->lines()));
return (new Stack())->add($this->log->surface, title: $title);
}Your own components emit events with emit(); document which.
An exception in a child's hooks, handlers or coroutines is contained to that child: it is
disposed with its children (their programs hung up) and its rectangle shows the error
(✖ RuntimeException: message, wrapped to the rectangle and cut with "…" when it does not fit);
the rest of the program keeps running. The same holds for a closure given as element content
(new Text(fn() => ...), a Box title): its exception shows in the element's place. An exception in the root
component ends the program (see "How a program ends").
A component that shows text (a log, a chat history, a help page, a list) keeps the text and
lays it out for the width it has now: the model is the text, the layout is what render()
makes of it for $this->surface->cols(), every time. A surface is a terminal and does not
reflow, so text written once for one width is cut when the surface narrows and stays cropped
when it widens; render runs again after every resize, so a component that lays its text out in
render() is always right, from the first size it gets on.
final class Help extends Component
{
public function __construct(private string $text) {}
protected function render(): ?Layout
{
$rows = Text::wrap($this->text, $this->surface->cols(), words: true);
$this->surface->write("\e[0m\e[2J\e[H" . implode("\r\n", array_slice($rows, 0, $this->surface->rows())));
return null;
}
}Log and ListView are built this way; a terminal pane (Terminal) is not, on purpose: a
program in a terminal draws for the size it is told and redraws on SIGWINCH.
All in phasync\Tui\Component\. The widgets of the components layer (Editor, Table,
Select, Dialog, Confirm) are described in widgets.
A command in a pseudo-terminal, as a component: new Terminal(string|array $command, ?string $cwd = null, array $env = [], ?Closure $onExit = null, ?array $size = null, ?string $key = null, ?string $id = null, string|array|null $class = null, ?array $style = null), so it goes in an element tree like any
widget (new Terminal('htop', onExit: fn(int $code) => $s->status = "htop: $code", class: 'grow')). The program gets the component's surface
as its terminal: size and resizes, keys and pastes while focused, answers to its queries. The
program starts when the component is sized (the render of its parent that shows it), in a pty
that already has the pane's size, so full-screen programs that read their size once (vim, htop,
less) fill their pane; until then pty() is null (exited() can be awaited from the start).
A program that must run before any render shows it (a shell opened in the background, kept
with keep()) is given a size: new Terminal('sh', size: [80, 24]) starts at mount, 80x24, and
is resized (SIGWINCH) when a render places it.
When the program exits, the window keeps its last output and the end is announced:
$onExit(int $code, Terminal $terminal) is called (in a coroutine of the component, so it may
wait, and a failure in it is contained), the event exit is emitted with the code and the
component ($terminal->on('exit', fn(int $code) => ...)), and the exited() awaitable resolves with the code
(fiber\await($terminal->exited())). The host can then close the pane, show the status, or
restart(): the command runs again in the same window (a running program is hung up first,
without being announced; the terminal is reset). Disposing the component, and so quitting the
program, hangs the program up with everything it started, a shell's foreground job included
(Pty::hangUp(): SIGHUP, then SIGKILL after half a second). pty(): ?Pty,
isRunning(), exitCode().
Keys. While it has the focus, its program gets every key the components around it would
take with plain bindings (it takesEveryKey(), see "Order" above): a shell needs Ctrl-D,
Ctrl-C, Ctrl-R, Ctrl-A/E/K/U/W, Tab, Escape and the arrows, an editor in it Ctrl-S, Ctrl-Q and
the function keys. A key the application needs while a shell has the focus is bound
'first' => true (or 'always' => true), and it is then not the shell's, so pick ones a shell
does not use, as tmux does: Ctrl-Tab and Alt-number (what Tabs binds), a prefix key such as
Ctrl-B or Ctrl-Space, function keys the program does not read. Avoid binding Ctrl-A to Ctrl-Z,
Escape, Tab and the arrows first around a Terminal.
Lines of text with scrollback that follows the end: new Log(int $scrollback = 10000, bool $wordWrap = false). It keeps its lines as given and lays them out for its current width,
wrapping by display width and grapheme (at any character, or between words with $wordWrap),
and lays them out again on every resize and when it gets its first real size, so nothing is
lost when the terminal narrows. The newest $scrollback lines are kept; the view scrolls back
$scrollback rows. (After a resize the visible part is laid out at once, a long scrollback a
moment later.)
| Method | What it does |
|---|---|
append(string $text) |
Add text; "\n" ends a line, text without one continues the last line. Escape sequences style it. |
setLines(array $lines) |
Replace all lines (without "\n"), laid out once: the way to filter, re-style, edit or re-sort what is shown. A view following the end keeps following. One scrolled back stays on the line at its top: on the same text if it is among the new lines (filtering), else on the same line number (re-styling or editing lines in place; the last line if there are fewer now). |
clear() |
Remove all text. |
lines(): list<string> |
The lines as given (escape sequences included), oldest first; an unfinished last line included. |
setWordWrap(bool $on), wordWrap(): bool |
Wrap between words or at any character; lays out again. |
scrollToLine(int $line) |
Show line $line (an index into lines(), 0 the oldest kept) at the top: "go to line N". Clamped; near the end it shows the last lines and follows. |
topLine(): int, bottomLine(): int |
The first and last line the view shows (in part), as indexes into lines(), also while it follows: "lines 120-160 of 300". |
scrollBy(int $rows), scrollTo(int $row), scrollTop(): int |
Rows of the laid-out text (a long line takes several, depending on the width), 0 the oldest row kept: for paging by the window's height. Scrolling to the end follows again. |
follow(), isFollowing() |
Follow the end again; true while it does. |
lineUp, lineDown, pageUp, pageDown, toTop, toBottom |
The actions of Up, Down, Page Up, Page Down, Home and End, when the log has the focus (see Scrollable). |
Lines, not rows. An application addresses the text in lines, the numbers of lines():
which rows a line takes depends on the width and the wrapping, and stays the log's business.
$log->scrollToLine($n - 1); // go to line N
$bar->set(sprintf('line %d of %d', $log->topLine() + 1, count($log->lines())));The mouse wheel scrolls it, the keys above do when it has the focus, and a drag selects and
copies. new Log(scrollbar: true) shows a scrollbar in its last column (part styles
Log::scrollbar, Log::scrollbar:thumb). A view scrolled back stays on the same
line when the log is laid out again (a resize, a change of wrapping, setLines()).
Event scroll (with the Log): the lines in view (topLine(), bottomLine()) or
isFollowing() changed: by the wheel, a scroll call, text arriving while it follows the end,
setLines(), a resize. $log->on('scroll', fn() => $this->requestRender()) keeps a title or
status line showing them current.
Filtering. Keep the full list yourself and give the log what should show; setLines() lays it
out once, and a reader scrolled back keeps their place when that line is still shown:
$this->all[] = $line; // every line, as it arrives
$log->setLines(array_values(array_filter($this->all, fn(string $l) => str_contains($l, $this->query))));A single line of editable text: new TextInput(string $value = '', string $prompt = '', string $placeholder = '', ?Closure $onSubmit = null, ?Closure $onChange = null, ?Style $style = null, bool $history = false); both callbacks receive (string $value, TextInput $input). Enter
calls $onSubmit with the text: the submit event, no key handling of your own needed; clear
the field in it with setValue('') if you want. With $history, submitted lines are
remembered and Up/Down walk through them as in a shell (the line being typed comes back after
the newest); history() and setHistory(array $lines) save and restore them. Without history,
Up and Down go on to the parent. Keys as in a shell: Left/Right
(Ctrl-B/Ctrl-F), Home/End (Ctrl-A/Ctrl-E), Ctrl-Left/Ctrl-Right or Alt-B/Alt-F by word,
Backspace, Delete (Ctrl-D), Ctrl-W or Alt-Backspace, Ctrl-U, Ctrl-K, Enter submits. Pastes are
inserted with line breaks as spaces. Grapheme-aware; scrolls sideways; shows the cursor while
focused. value(), setValue(), cursor(), setCursor(), setPrompt().
Rows with one selected, scrolling to keep it in view: a member list, a file tree, a process
list, a pick list. new ListView(array $items = [], ?Closure $onSelect = null, ?Closure $onActivate = null, ?Style $selectedStyle = null). Items are labels by key (a list, or keys of
your own: file names, pids); a label is one row and may carry colours; a long one is cut with
"…". The selected row is drawn in $selectedStyle (inverse video by default) over the label's
colours.
Up/Down (Ctrl-P/Ctrl-N), Page Up/Page Down, Home/End move the selection, Enter activates it; a
click selects a row and a click on the selected row activates it; the wheel scrolls.
$onSelect is called when the selection changes, $onActivate on activation; both receive
(int|string $key, string $label, ListView $list).
| Method | What it does |
|---|---|
setItems(array $items) |
Replace the items; the selection stays on the same key if it is still there, else on the same position. |
items(): array |
Label by key. |
selected(): int|string|null, selectedLabel(): ?string |
The selected key and label; null when empty. |
select(int|string $key): bool |
Select it and scroll it into view; false when there is no such key. |
topIndex(): int, bottomIndex(): int |
The positions in items() (0 the first) of the first and last item in view: "12-30 of 200". |
Event scroll (with the ListView): the items in view changed (the wheel, a selection
moved out of view, new items, a resize).
$list = $this->child('members', fn() => new ListView(
['frode' => "\e[1mfrode\e[0m (admin)", 'ivar' => 'ivar'],
onActivate: fn(string $name) => $this->openChat($name),
));One row with text on the left and right: new StatusBar(string $left = '', string $right = '', ?Style $style = null) (inverse video by default), set($left, $right = ''), left(),
right(). When both do not fit, the left one is shortened.
A border with a title around another component: new Box(Component $content, string $title = '', ?Style $style = null, ?Style $focusStyle = null); content(), setTitle(), title(). The
border is drawn in $focusStyle while the content has the focus. The content is a new
component (the box starts it; it ends with the box) or one you started with child() already
(the box shows it; it stays yours, see "A container given an existing child").