Progress
Collection sometimes triggers slow work: a discovery scan of a target directory, a computed option set, a value resolved against external state. Left silent, the form looks frozen. The progress() primitive wraps that work and shows it running - a spinner when the length is unknown, a determinate bar when it is - and passes the callback's result straight back.
progress() is one of the facade's primitives: it collects no answer and never runs inside the interactive panel. It is for slow work that happens around the form - before it opens, after it closes, or wrapping a slow ->default / ->discover. The active theme draws it, so it matches the panel's look and honours the colour and Unicode switches. Off a TTY, or in headless collection, it degrades to a single plain caption line with no control sequences. The callback receives the primitive and drives it with advance().
use DrevOps\Tui\Primitive\Progress;
// No total: an indeterminate spinner. Each advance() ticks a frame.
$total = $tui->progress(null, 'Counting the baskets', function (Progress $progress): int {
$count = 0;
foreach (shelves() as $shelf) {
$count += count_baskets($shelf); // slow, one shelf at a time
$progress->advance(); // tick the spinner between shelves
}
return $count;
});
// A known total: a determinate bar. Each advance() fills one step.
$tui->progress(count($items), 'Packing the order', function (Progress $progress) use ($items): void {
foreach ($items as $item) {
pack($item);
$progress->advance('packed ' . $item);
}
});
Spinner
With a null total the indicator is an animated spinner: an accent glyph beside the caption, cycled one frame per advance(), until the callback returns.
Runnable in playground/15-progress-spinner.php.
In all four display modes - Unicode or ASCII, colour on or off:
| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Progress bar
With a known total the indicator is a determinate bar: it fills as it advances, showing a step count and a trailing label. Each advance() fills one step and can replace the label.
Runnable in playground/15-progress-bar.php.
| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Theme-drawn
The glyphs and the accent come from the active theme, the same way every widget does - the spinner glyph and the bar fill carry the theme's accent, and the theme picks Unicode or ASCII. So ->theme('ember') spins and fills in ember's orange, ->theme('frost') in frost's blue, with no extra configuration.
Degrading off a TTY
Feedback is chrome, not data, so it is drawn on standard error and animates only when standard error is an interactive terminal. Piped, redirected or collected headlessly, progress() prints the caption once as a plain line and emits no cursor, colour or carriage-return sequences, so a captured log stays clean:
php playground/15-progress-bar.php 2>&1 | cat
# Packing the order
Inside the form
progress() runs around the form. Four counterparts show feedback inside the interactive panel, drawn by the same theme.
- Loading a field's options.
->options()takes a callback instead of a fixed list; one that asks for no arguments resolves when the field's panel opens, the field showing a themedLoading…until it returns. Headless collection resolves it up front. - Preloading a panel.
->preload(closure)on a panel runs once, before the panel's fields first draw - prep the panel needs, fetched on entry rather than up front, so one fetch can feed several fields. - Options that follow the query.
->optionsFrom()is called again on every query change rather than once, for candidates that live behind a search API. - Options that follow the answers. The same
->options()callback, but asking for the run context: it is called again whenever the answers change, so one field's choices narrow by another's answer. It resolves during the form settling rather than on entry, so it shows no indicator - keep it cheap. - The progress widget. A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself. Unlike
progress(), it lives among the fields and collects no value.
$form->panel('order', 'New order', function (PanelBuilder $p) use ($pack): void {
// Resolved when the panel opens; the field shows "Loading…" until it returns.
$p->select('fruit', 'Fruit')->options(fn(): array => load_fruit());
// A row that runs its work in place when activated.
$p->progress('pack', 'Packing the box')->steps(6)->run($pack);
});
Runnable in playground/16-loading-data.php and playground/02-widgets-progress.php.
Options from a query
A loader resolves one list, once. When the candidates are too many to hold - a catalog behind a search API, a database lookup, an index - a search or suggest field can source them from the query instead, with ->optionsFrom(). (For a list that follows the answers rather than the query, see options from the answers.)
$form->panel('order', 'New order', function (PanelBuilder $p) use ($pantry): void {
// Called again whenever the query changes, so the list follows what is typed.
$p->search('veg', 'Vegetable')->optionsFrom(fn(string $query): array => $pantry->search($query));
// Silent until two characters are typed.
$p->suggest('extra', 'Add another')->optionsFrom(fn(string $query): array => $pantry->search($query))->minQuery(2);
});
The source returns the same value => label map ->options() takes, and also receives the answers collected so far, so one field's query can narrow by another's answer. Its result replaces the list wholesale and is not filtered again locally - the backend already did the matching, so a row whose label does not literally contain the query still shows.
The panel loop is synchronous, so the call blocks: the field paints a themed Loading… in place of its list, then repaints with the result. Three behaviors keep that from becoming a call per keystroke:
- A burst settles once. Keys that arrive together - fast typing, a paste - resolve to a single query rather than one per character.
- Repeats are cached. A query already answered in this editor session is served from the cache, so backspacing costs nothing.
->minQuery(n)holds the call back until the query isncharacters long, showing a prompt to keep typing instead of a list. Without it the field opens by asking the source for the empty query.
A source that throws does not end the session: the field shows Could not load options. in place of its list and stays editable, and the failed query is remembered so the same call is not retried on every frame.
Headless collection has nothing typed to query with, so it looks the supplied value up as the query and checks the value against what comes back - a value no query can produce is rejected, exactly as one outside a static option list is. A suggest field's candidates are hints rather than a closed set, so its value is not checked in either mode.
Runnable in playground/17-query-options.php.