Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Every feature has a reference page and a runnable, self-contained example in [`p
| ⚙️ Declared behavior | `->required()` rejects an empty value with a label-derived or declared message; dynamic defaults, validation and transforms as field closures, or as per-field handler classes resolved by naming convention | [field behavior](https://phptui.dev/field-behaviour) | [`06-field-behaviour-*`](playground) |
| 🔍 Discovery | Update mode detects defaults from an existing directory: dotenv keys, JSON dot-paths, path checks, directory scans | [discovery](https://phptui.dev/field-behaviour#discovery) | [`07-discovery`](playground/07-discovery.php) |
| ⏳ Progress | A `progress()` primitive wraps slow work: a spinner when the length is unknown, a determinate bar when known - theme-drawn, animating on a TTY, degrading to a plain line when piped or headless | [progress](https://phptui.dev/progress) | [`15-progress-*`](playground) |
| 🎯 Answer-driven options | An `->options()` callback that asks for the run context resolves a choice field's list from the answers collected so far, so one field narrows by another - re-resolved as they change, honored by the panel, headless collection, the schema and the validator alike, and a choice the narrowed list drops does not survive in the answers, while one supplied headlessly is reported rather than dropped | [options from the answers](https://phptui.dev/field-behaviour#options-from-the-answers) | [`19-dynamic-options`](playground/19-dynamic-options.php) |
| 🌐 Remote-backed options | `->optionsFrom()` resolves a search or suggest field's candidates from the live query - a themed `Loading…` while it runs, a typing burst settling into one call, a per-query cache, and `->minQuery()` holding it back until the query is worth sending | [options from a query](https://phptui.dev/progress#options-from-a-query) | [`17-query-options`](playground/17-query-options.php) |
| 🧾 Output | An `output()` primitive draws the chrome around a form: boxes and cards, tables, five status lines, definition lists, wrapped text, rules and a banner - theme-drawn, dropping their color when piped or redirected | [output](https://phptui.dev/output) | [`18-output-*`](playground) |
| 📦 Self-describing answers | Answers carry provenance; `toSummary()` renders a badged, panel-grouped report and `toJson()` the machine result; `schema()`, `validate()` and `agentHelp()` describe the form itself | [self-describing answers](https://phptui.dev/headless-collection#self-describing-answers) | [`08-headless-*`](playground) |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/dataflow-collect-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions docs/architecture/dataflow-collect.puml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ loop each field with a supplied input
note right of Eng: inputs normalize first, so derivation,\nactivation and fix-ups see the final value
end

loop each field whose options follow the answers
Eng -> B: options callback(context)
B --> Eng: value => label map
note right of Eng: resolved before conditions evaluate; a value the\nset no longer holds is dropped unless it was supplied
end

Eng -> Der: derive(rules, values, pinned)
activate Der
Der --> Eng: derived values (fixpoint)
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/dataflow-collect.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/architecture/dataflow-tui-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions docs/architecture/dataflow-tui.puml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
' Traced from src/Render/PanelController.php (run -> frame/handle), src/Theme/
' (frame, and renderModal composited via src/Render/Overlay), src/Input/
' (KeyMap resolves a key press to a semantic action) and src/Engine/Engine.php
' (settle re-runs derives, conditions and fix-ups after an accepted edit).
' (settle re-resolves answer-driven options and re-runs derives, conditions and
' fix-ups after an accepted edit).
' Regenerate every SVG with: plantuml -tsvg docs/architecture/*.puml
!theme plain
skinparam backgroundColor white
Expand Down Expand Up @@ -57,8 +58,8 @@ loop until done
KM --> W: bound action (accept, move, toggle...)
W --> PC: value, complete or cancel
opt edit accepted
PC -> Eng: settle(values, pinned)
Eng --> PC: settled values + active map\n(derives recompute, conditions\nshow/hide fields, fix-ups apply)
PC -> Eng: settle(values, pinned, context)
Eng --> PC: settled values + active map\n(options resolve from the answers,\nderives recompute, conditions\nshow/hide fields, fix-ups apply)
end
else navigating
PC -> KM: matches(key, action)?
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/dataflow-tui.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 41 additions & 2 deletions docs/content/field-behaviour.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Field behavior
description: 'Guide an answer with a description, a hint and a placeholder; mark fields required, and declare dynamic defaults, validation and transforms as closures on the field - or in handler classes - and detect defaults with discovery rules.'
keywords: ['description', 'hint', 'placeholder', 'required', 'validation', 'transform', 'dynamic default', 'discovery', 'handler']
description: 'Guide an answer with a description, a hint and a placeholder; mark fields required, narrow one field options by another answer, and declare dynamic defaults, validation and transforms as closures on the field - or in handler classes - and detect defaults with discovery rules.'
keywords: ['description', 'hint', 'placeholder', 'required', 'validation', 'transform', 'dynamic default', 'dynamic options', 'discovery', 'handler']
---

import ThemedImage from '@theme/ThemedImage';
Expand Down Expand Up @@ -79,6 +79,45 @@ Emptying the name shows the label-derived message in the editor; leaving the bas
<ThemedImage sources={{light: useBaseUrl('/field-behaviour-light-animated.svg'), dark: useBaseUrl('/field-behaviour-dark-animated.svg')}} alt="Declared field behavior: an emptied required field refused in its editor, then the submit withheld for an untouched required basket" width="100%" />
</p>

## Options from the answers

A choice field's list does not have to be fixed. Hand `->options()` a callback that asks for the run context, and one field's choices narrow by another's answer:

```php
use DrevOps\Tui\Handler\Context;

$catalog = [
'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'],
'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'],
];

$p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']);

$p->select('item', 'Item')->options(function (Context $c) use ($catalog): array {
// An answer is whatever was supplied until it is validated, so read it
// defensively before it indexes anything.
$category = $c->answers['category'] ?? '';

return is_string($category) ? ($catalog[$category] ?? []) : [];
});
```

**The callback's own signature says when it runs.** One that asks for the context follows the answers, as above; one that asks for nothing is the [loader](/progress#inside-the-form) it has always been - resolved once when the panel opens, showing a themed `Loading…` until it returns, and resolved up front when collection is headless and there is no panel to open. Either way it returns the same `value => label` map the fixed form takes, and the context carries the answers collected so far alongside the target directory, the update flag and the version.

Options are for the types that have a list - `select`, `search`, `suggest`, `toggle` and `reorder`. Declaring them on any other type raises a `FormException` when the form is built, as does declaring a resolver beside a fixed list, a loader or a [query source](/progress#options-from-a-query), since the resolved set replaces them.

A resolver runs as part of the form settling - the same pass that computes derived values, evaluates `when` conditions and applies fix-ups - so every surface sees one narrowed list:

- **Interactively**, changing the category re-resolves the item list before the next frame, so the editor offers exactly what the new category holds.
- **Headlessly**, a supplied value is checked against the list the payload's own answers resolve to. A value outside it throws an `EngineException` naming the value and what was allowed.
- **[`Tui::validate()`](/ai-agents)** checks membership against the set the answers under validation resolve to, and the [schema](/ai-agents) resolves the list against whatever context you pass it, flagging the field as `options_dynamic` so tooling can tell an empty list from one that is not fixed.

A choice the narrowed list no longer holds does not survive in the answers: it is dropped, a `reorder` ranking is completed back to a full permutation, and a `toggle` returns to its first state. Only a value supplied headlessly is left standing, so it is reported rather than disappearing without a word. A `suggest` field's options are hints rather than a closed set, so its value is never narrowed away.

Keep the resolver cheap. It runs for the whole form on every settle, not once per panel, and it is called again whenever the answers change - though a settle that changes nothing costs nothing, since the list already answers those answers. For a list that is expensive to build, drop the context parameter and it loads [once, on panel entry](/progress#inside-the-form); for one that lives behind a search API, [`->optionsFrom()`](/progress#options-from-a-query) follows the query instead.

Runnable in [`playground/19-dynamic-options.php`](https://github.com/drevops/tui/blob/main/playground/19-dynamic-options.php).

## Discovery

In update mode, `->discover()` rules detect defaults from an existing project directory: a `.env` key (`new Dotenv('SEASON')`), a JSON dot-path (`new JsonValue('basket.json', 'name')`), a path check (`new PathExists('harvest.csv')`), a directory scan (`new Scan('baskets', type: ScanType::Dir)`), or a custom `fn(Context $c): mixed` closure:
Expand Down
5 changes: 3 additions & 2 deletions docs/content/progress.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,10 @@ php playground/15-progress-bar.php 2>&1 | cat

`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; it resolves when the field's panel opens, the field showing a themed `Loading…` until it returns. Headless collection resolves it up front.
- **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 themed `Loading…` 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](#options-from-a-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](/field-behaviour#options-from-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](/widgets/progress).** 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.

```php
Expand All @@ -130,7 +131,7 @@ Runnable in [`playground/16-loading-data.php`](https://github.com/drevops/tui/bl

## 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](/widgets/search) or [suggest](/widgets/suggest) field can source them from the query instead, with `->optionsFrom()`:
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](/widgets/search) or [suggest](/widgets/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](/field-behaviour#options-from-the-answers).)

```php
$form->panel('order', 'New order', function (PanelBuilder $p) use ($pantry): void {
Expand Down
12 changes: 6 additions & 6 deletions docs/content/widgets/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ Runnable scripts: [`playground/02-widgets-select.php`](https://github.com/drevop

## Options

| Name | Description | Required | Default |
| ------------ | -------------------------------------------------------------------------------- | -------- | ------------ |
| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). | Yes | - |
| `default()` | Which option starts highlighted, by value. | No | First option |
| `pageSize()` | Options shown before the list pages around the cursor. | No | `10` |
| Name | Description | Required | Default |
| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ |
| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). Also takes a callback returning that map. | Yes | - |
| `default()` | Which option starts highlighted, by value. | No | First option |
| `pageSize()` | Options shown before the list pages around the cursor. | No | `10` |

For headings, separators and disabled options, see [Option groups](/widgets/option-groups).
For headings, separators and disabled options, see [Option groups](/widgets/option-groups). To narrow the choices by an earlier answer, see [options from the answers](/field-behaviour#options-from-the-answers).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Option descriptions

Expand Down
62 changes: 62 additions & 0 deletions playground/19-dynamic-options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/**
* @file
* Options resolved from the answers collected so far.
*
* A callback handed to ->options() that asks for the run context is called
* again whenever the answers change, so one field's choices can narrow by
* another's answer. It runs as part of the form settling, before anything is
* drawn or validated, so the narrowed list is what the panel offers, what a
* headless payload is checked against and what the schema advertises - and a
* choice the narrowed list no longer holds is dropped from the answers, unless
* it was supplied headlessly, which collection reports instead.
*
* Usage:
* php playground/19-dynamic-options.php
*/

declare(strict_types=1);

use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Handler\Context;
use DrevOps\Tui\InterruptException;
use DrevOps\Tui\Tui;

require __DIR__ . '/../vendor/autoload.php';

$catalog = [
'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'],
'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'],
];

$form = Form::create('Quick start')
->panel('order', 'New order', function (PanelBuilder $p) use ($catalog): void {
$p->text('name', 'Order name')->required();

$p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit');

// An answer is whatever was supplied until it is validated, so the category
// is read defensively before it indexes anything.
$stock = static function (Context $context) use ($catalog): array {
$category = $context->answers['category'] ?? '';

return is_string($category) ? ($catalog[$category] ?? []) : [];
};

// Called again whenever the answers change: pick another category and the
// item list follows, dropping an item the new category does not stock.
$p->select('item', 'Item')->options($stock);

// The same narrowing over several picks - the basket keeps only what the
// chosen category still offers.
$p->select('basket', 'Basket')->multiple()->options($stock);
});

try {
echo (new Tui($form))->run()->toJson() . PHP_EOL;
}
catch (InterruptException) {
exit(130);
}
1 change: 1 addition & 0 deletions playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Every interactive script also runs unattended: pipe stdin (or run it from CI) an
| `16-loading-data` | Loading a panel's data on demand: a field's `->options()` and a panel's `->preload()` taking a callback, resolved the first time the panel opens with a themed `Loading…` on the field. | [`16-loading-data.php`](16-loading-data.php) |
| `17-query-options` | Options that follow the query: `->optionsFrom()` called again on every query change with a themed `Loading…` while it runs, a per-query cache, and `->minQuery()` holding the call back until the query is long enough. | [`17-query-options.php`](17-query-options.php) |
| `18-output-*` | The output primitives - a titled box and card, an aligned table, the five status lines, a definition list, wrapped prose, rules and a banner - theme-drawn chrome for around a form run, dropping their colour when piped or redirected. | [`18-output-box.php`](18-output-box.php), [`18-output-status.php`](18-output-status.php), [`18-output-definitions.php`](18-output-definitions.php), [`18-output-table.php`](18-output-table.php), [`18-output-text.php`](18-output-text.php) |
| `19-dynamic-options` | Options that follow the answers: an `->options()` callback taking the run context, called again whenever they change, narrowing one field's choices by another's answer and dropping a choice the narrowed list no longer holds - reporting rather than dropping one that was supplied headlessly. | [`19-dynamic-options.php`](19-dynamic-options.php) |

## Running the examples

Expand Down
Loading
Loading