diff --git a/README.md b/README.md
index 998c76c7..5008e43f 100644
--- a/README.md
+++ b/README.md
@@ -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) |
diff --git a/docs/architecture/dataflow-collect-dark.svg b/docs/architecture/dataflow-collect-dark.svg
index 74ec2a6e..55917d13 100644
--- a/docs/architecture/dataflow-collect-dark.svg
+++ b/docs/architecture/dataflow-collect-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-collect.puml b/docs/architecture/dataflow-collect.puml
index 452ee519..d90fda27 100644
--- a/docs/architecture/dataflow-collect.puml
+++ b/docs/architecture/dataflow-collect.puml
@@ -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)
diff --git a/docs/architecture/dataflow-collect.svg b/docs/architecture/dataflow-collect.svg
index d056f292..1c329d3a 100644
--- a/docs/architecture/dataflow-collect.svg
+++ b/docs/architecture/dataflow-collect.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-tui-dark.svg b/docs/architecture/dataflow-tui-dark.svg
index 273b475d..14db56e7 100644
--- a/docs/architecture/dataflow-tui-dark.svg
+++ b/docs/architecture/dataflow-tui-dark.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/architecture/dataflow-tui.puml b/docs/architecture/dataflow-tui.puml
index 279138d3..dc11b817 100644
--- a/docs/architecture/dataflow-tui.puml
+++ b/docs/architecture/dataflow-tui.puml
@@ -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
@@ -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)?
diff --git a/docs/architecture/dataflow-tui.svg b/docs/architecture/dataflow-tui.svg
index b1715967..3d457a13 100644
--- a/docs/architecture/dataflow-tui.svg
+++ b/docs/architecture/dataflow-tui.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/content/field-behaviour.mdx b/docs/content/field-behaviour.mdx
index 17de5059..4935578a 100644
--- a/docs/content/field-behaviour.mdx
+++ b/docs/content/field-behaviour.mdx
@@ -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';
@@ -79,6 +79,45 @@ Emptying the name shows the label-derived message in the editor; leaving the bas
+## 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:
diff --git a/docs/content/progress.mdx b/docs/content/progress.mdx
index 6c242832..30ee2eae 100644
--- a/docs/content/progress.mdx
+++ b/docs/content/progress.mdx
@@ -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
@@ -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 {
diff --git a/docs/content/widgets/select.mdx b/docs/content/widgets/select.mdx
index 31058167..9b1e72f2 100644
--- a/docs/content/widgets/select.mdx
+++ b/docs/content/widgets/select.mdx
@@ -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).
## Option descriptions
diff --git a/playground/19-dynamic-options.php b/playground/19-dynamic-options.php
new file mode 100644
index 00000000..7664ca84
--- /dev/null
+++ b/playground/19-dynamic-options.php
@@ -0,0 +1,62 @@
+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);
+}
diff --git a/playground/README.md b/playground/README.md
index 18ec1c20..2ce3dab1 100644
--- a/playground/README.md
+++ b/playground/README.md
@@ -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
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index 0be2e537..2094dc6e 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -91,6 +91,11 @@ final class FieldBuilder {
*/
protected ?\Closure $optionsSource = NULL;
+ /**
+ * A resolver for the options, or NULL when they do not follow the answers.
+ */
+ protected ?\Closure $optionsResolver = NULL;
+
/**
* The query length below which the query source is not called.
*/
@@ -1087,23 +1092,45 @@ public function heading(string $label): self {
}
/**
- * Add several options from a value => label map, or a loader for them.
+ * Add several options from a value => label map, or a callback for them.
+ *
+ * A callback's own signature says when it runs. One that asks for the run
+ * context follows the collected answers: it is called again whenever they
+ * change, so one field's choices can narrow by another's answer - a basket
+ * that stops offering what the chosen category does not hold. It runs as
+ * part of the form settling, before anything is drawn or validated, so the
+ * narrowed set is the one every surface sees: the panel, headless
+ * collection, the schema and the validator. A value the narrowed set no
+ * longer offers is dropped from the answers - a ranking is completed back to
+ * a full permutation and a toggle falls back to its first state - unless it
+ * was supplied headlessly, which is reported instead. Keep such a callback
+ * cheap: it runs for the whole form, not once per panel.
+ *
+ * A callback that asks for nothing loads one list, once, lazily when the
+ * field's panel opens - showing a themed "Loading…" beside the field until
+ * it returns, and running after the panel's `->preload()` so it can read
+ * what preload prepared.
*
* @param array|\Closure $options
- * The options keyed by value with a label, or an
- * `fn(): array` that loads them on demand. A loader resolves
- * lazily when the field's panel opens - showing a themed "Loading…" beside
- * the field until it returns. A loader suits a field whose default is empty
- * or explicit (select, search, suggest); a toggle or reorder derives its
- * default from the options, so with a loader it should declare an explicit
- * `->default()`.
+ * The options keyed by value with a label, or a callback returning that
+ * same map: `fn (Context $context): array` to follow the
+ * answers, or `fn (): array` to load them once. Either way
+ * a toggle or reorder derives its default from the options, so one whose
+ * options arrive later should declare an explicit `->default()`.
*
* @return $this
* The builder.
*/
public function options(array|\Closure $options): self {
if ($options instanceof \Closure) {
- $this->optionsLoader = $options;
+ // Reading the signature here, rather than at every call, keeps the two
+ // lifecycles apart without a reflection call mid-session.
+ if ((new \ReflectionFunction($options))->getNumberOfParameters() > 0) {
+ $this->optionsResolver = $options;
+ }
+ else {
+ $this->optionsLoader = $options;
+ }
return $this;
}
@@ -1255,6 +1282,7 @@ public function build(): Field {
envAliases: $this->envAliases,
ghost: $this->ghost,
ratingCaptions: $this->captions,
+ optionsResolver: $this->optionsResolver,
);
}
diff --git a/src/Builder/Form.php b/src/Builder/Form.php
index e2524a0d..6b01d4a2 100644
--- a/src/Builder/Form.php
+++ b/src/Builder/Form.php
@@ -263,6 +263,12 @@ protected function assertToggleOptions(FormDefinition $form): void {
continue;
}
+ // Rows that arrive later cannot be counted here, and the default they
+ // would be checked against is the one they will settle on.
+ if (!$field->hasSettledOptions()) {
+ continue;
+ }
+
if (count($field->options) !== 2) {
throw new FormException(sprintf('Toggle field "%s" must have exactly two options, %d given.', $field->id, count($field->options)));
}
@@ -297,6 +303,11 @@ protected function assertReorderOptions(FormDefinition $form): void {
continue;
}
+ // Rows that arrive later are not there to be counted or vetted here.
+ if (!$field->hasSettledOptions()) {
+ continue;
+ }
+
foreach ($field->options as $option) {
if (!$option->selectable()) {
throw new FormException(sprintf('Reorder field "%s" allows only plain options - no headings, separators or disabled rows.', $field->id));
diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php
index 72d668e0..c85b3f93 100644
--- a/src/Engine/Engine.php
+++ b/src/Engine/Engine.php
@@ -38,6 +38,21 @@ class Engine {
*/
protected Deriver $deriver;
+ /**
+ * What each field's dynamic option set was last resolved from, and to.
+ *
+ * A settling pass that leaves the resolver's whole input as it was - the
+ * answers and the rest of the run context - leaves the options it produced
+ * valid, so it is called again only when what it reads has actually changed.
+ * The rows are remembered alongside that input because a field's options are
+ * settled state anyone may write: a schema surface resolving them against a
+ * context of its own retires this memo rather than leaving the engine
+ * convinced they are still its own.
+ *
+ * @var array,run:array{string,bool,string},rows:list<\DrevOps\Tui\Model\Option>}>
+ */
+ protected array $optionMemo = [];
+
/**
* Construct an engine.
*
@@ -74,7 +89,7 @@ public function collect(array $inputs, Context $context): Answers {
[$values, $sources] = $this->resolveAll($fields, $inputs, $context);
$values = $this->transformInputs($fields, $values, $sources);
[$rules, $pinned] = $this->deriveRules($fields, $sources);
- [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned);
+ [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned, $context, $this->suppliedInputs($sources));
$this->loadQueryOptions($fields, $values, $active);
$this->guardInputs($fields, $values, $sources, $active);
@@ -147,10 +162,7 @@ protected function loadQueryOptions(array $fields, array $values, array $active)
// the field, but headlessly there is nobody to retype the query, so
// the collection fails - as an engine error like every other, rather
// than as whatever the consumer's backend happened to throw.
- throw new EngineException(Translator::t('Could not load options for field "@id": @error', [
- '@id' => $field->id,
- '@error' => $throwable->getMessage(),
- ]), $throwable->getCode(), $throwable);
+ throw $this->optionsError($field, $throwable);
}
foreach ($resolved as $row) {
@@ -162,6 +174,106 @@ protected function loadQueryOptions(array $fields, array $values, array $active)
}
}
+ /**
+ * Resolve every dynamic option set against the answers, in place.
+ *
+ * A resolver reads the answers, so it is called again whenever they change
+ * and skipped when they have not - a settling pass that alters nothing costs
+ * nothing. The resolved set then decides the field's value: one that is no
+ * longer offered is dropped, a ranking is completed and a toggle falls back,
+ * so the answers never name an option that is not on offer. A value the
+ * caller supplied is left alone for the input guard to report, rather than
+ * disappearing without a word.
+ *
+ * @param \DrevOps\Tui\Model\Field[] $fields
+ * The fields.
+ * @param array $values
+ * The current values keyed by field id.
+ * @param array $active
+ * Which fields are active, keyed by field id.
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run context the resolvers are called with.
+ * @param array $supplied
+ * Field ids whose value was supplied by the caller.
+ *
+ * @return array
+ * The values, reconciled against the resolved option sets.
+ *
+ * @throws \DrevOps\Tui\Engine\EngineException
+ * When a resolver cannot answer.
+ */
+ protected function resolveDynamicOptions(array $fields, array $values, array $active, Context $context, array $supplied): array {
+ $answers = $this->activeAnswers($fields, $values, $active);
+ $resolved = new Context($context->directory, $answers, $context->update, $context->version);
+
+ // Everything the resolver is handed, so a second run against another
+ // directory - or in update mode - is not answered from the memo of the
+ // first one.
+ $run = [$context->directory, $context->update, $context->version];
+
+ foreach ($fields as $field) {
+ if (!$field->optionsResolver instanceof \Closure) {
+ continue;
+ }
+
+ $memo = $this->optionMemo[$field->id] ?? NULL;
+ if ($memo !== NULL && $memo['answers'] === $answers && $memo['run'] === $run && $memo['rows'] === $field->options) {
+ continue;
+ }
+
+ try {
+ $field->options = Option::resolved(($field->optionsResolver)($resolved));
+ }
+ catch (\Throwable $throwable) {
+ throw $this->optionsError($field, $throwable);
+ }
+
+ $this->optionMemo[$field->id] = ['answers' => $answers, 'run' => $run, 'rows' => $field->options];
+
+ if ($supplied[$field->id] ?? FALSE) {
+ continue;
+ }
+
+ $values[$field->id] = $field->reconcileValue($values[$field->id] ?? NULL);
+ }
+
+ return $values;
+ }
+
+ /**
+ * The fields whose value the caller supplied, keyed by field id.
+ *
+ * @param array $sources
+ * The initial source per field id.
+ *
+ * @return array
+ * TRUE for each field answered by a supplied input.
+ */
+ protected function suppliedInputs(array $sources): array {
+ return array_map(static fn(Source $source): bool => $source === Source::Input, $sources);
+ }
+
+ /**
+ * The engine error for consumer option code that could not answer.
+ *
+ * @param \DrevOps\Tui\Model\Field $field
+ * The field whose options were being resolved.
+ * @param \Throwable $throwable
+ * What the consumer code threw.
+ *
+ * @return \DrevOps\Tui\Engine\EngineException
+ * The engine error naming the field.
+ */
+ protected function optionsError(Field $field, \Throwable $throwable): EngineException {
+ // Not every code is an integer - a database driver's SQLSTATE is a string -
+ // and consumer code decides which exception arrives here, so it is coerced
+ // rather than allowed to fail the conversion instead of reporting.
+ return new EngineException(Translator::t('Could not load options for field "@id": @error', [
+ '@id' => $field->id,
+ '@error' => $throwable->getMessage(),
+ ]), (int) $throwable->getCode(), $throwable);
+ }
+
/**
* The queries that look up a field's supplied value, one per item.
*
@@ -206,7 +318,7 @@ public function resolveState(array $inputs, Context $context): array {
[$values, $sources] = $this->resolveAll($fields, $inputs, $context);
$values = $this->transformInputs($fields, $values, $sources);
[$rules, $pinned] = $this->deriveRules($fields, $sources);
- [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned);
+ [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned, $context, $this->suppliedInputs($sources));
$all = array_fill_keys(array_keys($sources), TRUE);
@@ -225,14 +337,19 @@ public function resolveState(array $inputs, Context $context): array {
* The current values keyed by field id.
* @param array $pinned
* Derive-ruled field ids that must not be recomputed.
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run context the dynamic option sets resolve against.
*
* @return array{array,array}
* The active map and the settled values.
*/
- public function settle(array $values, array $pinned): array {
+ public function settle(array $values, array $pinned, Context $context): array {
$fields = $this->form->fields();
- return $this->stabilize($fields, $values, $this->ruleMap($fields), $pinned);
+ // Nothing here was supplied: an edited value is the live one the user is
+ // working with, so a narrowed option set reconciles it rather than
+ // reporting it the way a headless input would be reported.
+ return $this->stabilize($fields, $values, $this->ruleMap($fields), $pinned, $context, []);
}
/**
@@ -542,11 +659,15 @@ protected function discoverValue(Field $field, Context $context): mixed {
* Derive rules keyed by field id.
* @param array $pinned
* Field ids that must not be re-derived (input or detected).
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run context the dynamic option sets resolve against.
+ * @param array $supplied
+ * Field ids whose value was supplied by the caller, keyed by field id.
*
* @return array{array,array}
* The active map and the settled values.
*/
- protected function stabilize(array $fields, array $values, array $derive_rules, array $pinned): array {
+ protected function stabilize(array $fields, array $values, array $derive_rules, array $pinned, Context $context, array $supplied): array {
$active = [];
foreach ($fields as $field) {
$active[$field->id] = TRUE;
@@ -557,6 +678,10 @@ protected function stabilize(array $fields, array $values, array $derive_rules,
// the activation and fix-up interplay.
$limit = count($fields) + 2;
for ($i = 0; $i <= $limit; $i++) {
+ // Options resolve first: a set that follows the answers decides what the
+ // conditions below then see, and what a value is still allowed to be.
+ $values = $this->resolveDynamicOptions($fields, $values, $active, $context, $supplied);
+
$derived = $this->deriver->derive($derive_rules, $values, $pinned);
$next_active = [];
diff --git a/src/Model/Field.php b/src/Model/Field.php
index 8c6f797b..70fc5677 100644
--- a/src/Model/Field.php
+++ b/src/Model/Field.php
@@ -12,11 +12,13 @@
/**
* A single question in the configuration model.
*
- * The definition is immutable except for three concerns resolved once and
- * written back: a field may declare an options loader whose result is cached
- * (`$options`, `$optionsLoader`), a progress row tracks its live indicator
- * (`$progressCurrent`, `$progressLabel`) as its work advances, and the owning
- * form definition stamps the field's place in the condition graph
+ * The definition is immutable except for three resolved concerns written back:
+ * options a field declares indirectly - through a loader resolved once
+ * (`$optionsLoader`), a resolver re-run as the answers change
+ * (`$optionsResolver`) or a query source re-run as the query changes
+ * (`$optionsSource`) - land in `$options`, a progress row tracks its live
+ * indicator (`$progressCurrent`, `$progressLabel`) as its work advances, and
+ * the owning form definition stamps the field's place in the condition graph
* (`$conditionalDepth`). Those five properties are the only mutable state.
*
* @package DrevOps\Tui\Model
@@ -203,6 +205,11 @@ final class Field {
* Rating only: the caption of a point on the scale, keyed by the point. The
* scale is the range in {@see $bounds}; a caption is decoration over it, so
* points may be captioned sparsely and an uncaptioned point still answers.
+ * @param \Closure|null $optionsResolver
+ * An `fn(Context $context): array` resolving the options
+ * from the answers collected so far, or NULL for options that do not follow
+ * them. Unlike a loader it is called again whenever the answers change, so
+ * one field's choices can narrow by another's answer.
*/
public function __construct(
public readonly string $id,
@@ -249,6 +256,7 @@ public function __construct(
public readonly array $envAliases = [],
public readonly bool $ghost = FALSE,
public readonly array $ratingCaptions = [],
+ public readonly ?\Closure $optionsResolver = NULL,
) {
$this->assertEnvNames();
$this->assertRatingCaptions();
@@ -271,6 +279,14 @@ public function __construct(
throw new FormException(sprintf('Field "%s" declares a minimum query length but no query source to apply it to.', $this->id));
}
+ if (($options !== [] || $optionsLoader instanceof \Closure || $this->optionsResolver instanceof \Closure) && !$this->type->supportsOptions()) {
+ throw new FormException(sprintf('Field "%s" of type "%s" shows no options; only select, search, suggest, toggle and reorder fields have a list.', $this->id, $this->type->value));
+ }
+
+ if ($this->optionsResolver instanceof \Closure && ($options !== [] || $optionsLoader instanceof \Closure || $this->optionsSource instanceof \Closure)) {
+ throw new FormException(sprintf('Field "%s" resolves its options from the answers and declares another set of options as well; the resolved set replaces them, so declare only one.', $this->id));
+ }
+
if ($this->placeholder !== '' && !$this->type->supportsPlaceholder()) {
throw new FormException(sprintf('Field "%s" of type "%s" shows no placeholder; only text, number, textarea, password, suggest and search fields have an input buffer to ghost.', $this->id, $this->type->value));
}
@@ -432,6 +448,73 @@ public function selectableValues(): array {
return Option::selectableValues($this->options);
}
+ /**
+ * Whether the field's option rows stand as declared.
+ *
+ * @return bool
+ * FALSE while a loader, a resolver or a query source still owes the field
+ * its rows, so there is nothing yet to count or to check a default against.
+ */
+ public function hasSettledOptions(): bool {
+ return !$this->optionsLoader instanceof \Closure && !$this->optionsResolver instanceof \Closure && !$this->optionsSource instanceof \Closure;
+ }
+
+ /**
+ * Whether the field's option set follows the answers rather than standing.
+ *
+ * @return bool
+ * TRUE when the options are resolved from the collected answers or from a
+ * live query, so no one list describes the field.
+ */
+ public function hasDynamicOptions(): bool {
+ return $this->optionsResolver instanceof \Closure || $this->optionsSource instanceof \Closure;
+ }
+
+ /**
+ * A value restated against the option set as it now stands.
+ *
+ * A choice value outlives the options it was picked from: a set resolved
+ * from the answers narrows as they change, leaving a value that is no longer
+ * offered, a ranking that no longer covers the set, or a toggle sitting on a
+ * state that is gone. This drops what the set no longer holds, completes a
+ * ranking back to a full permutation and returns a toggle to its first
+ * state, so a value always describes the options in front of it.
+ *
+ * A suggest field's options are hints rather than a closed set, so its value
+ * is never reconciled against them.
+ *
+ * @param mixed $value
+ * The current value.
+ *
+ * @return mixed
+ * The value the current options can carry.
+ */
+ public function reconcileValue(mixed $value): mixed {
+ if (!$this->type->supportsOptions() || $this->type === FieldType::Suggest) {
+ return $value;
+ }
+
+ $selectable = $this->selectableValues();
+
+ if ($this->type === FieldType::Reorder) {
+ return self::canonicalOrder($selectable, self::stringList($value));
+ }
+
+ if ($this->isMultiChoice()) {
+ return array_values(array_filter(self::stringList($value), static fn(string $item): bool => in_array($item, $selectable, TRUE)));
+ }
+
+ $current = is_scalar($value) ? (string) $value : '';
+
+ if (in_array($current, $selectable, TRUE)) {
+ return $current;
+ }
+
+ // A toggle is always in one of its states, so a value the set no longer
+ // offers falls back to the first option rather than to nothing.
+ return $this->type === FieldType::Toggle ? ($selectable[0] ?? '') : '';
+ }
+
/**
* The whole message for an empty value on a required field, else NULL.
*
@@ -547,9 +630,9 @@ public function templateParts(mixed $value): array {
*/
public function optionError(mixed $value): ?string {
// A field that declares no options constrains nothing - but one whose
- // options come from a query is constrained by whatever the query answered,
- // and answering with nothing means the value does not exist.
- if (!$this->type->constrainsToOptions() || ($this->options === [] && !$this->optionsSource instanceof \Closure)) {
+ // options follow a query or the answers is constrained by whatever they
+ // resolved to, and resolving to nothing means the value does not exist.
+ if (!$this->type->constrainsToOptions() || ($this->options === [] && !$this->hasDynamicOptions())) {
return NULL;
}
diff --git a/src/Model/FieldType.php b/src/Model/FieldType.php
index 56c34a51..3a26143e 100644
--- a/src/Model/FieldType.php
+++ b/src/Model/FieldType.php
@@ -123,6 +123,26 @@ public function constrainsToOptions(): bool {
], TRUE);
}
+ /**
+ * Whether a field of this type shows a list of options at all.
+ *
+ * Wider than {@see constrainsToOptions()}: a suggest field's options are
+ * autocomplete hints rather than a closed set, but it still has a list to
+ * declare.
+ *
+ * @return bool
+ * TRUE for every type that draws an option list.
+ */
+ public function supportsOptions(): bool {
+ return in_array($this, [
+ self::Select,
+ self::Search,
+ self::Suggest,
+ self::Toggle,
+ self::Reorder,
+ ], TRUE);
+ }
+
/**
* Whether a field of this type may collect several values via `->multiple()`.
*
diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php
index 4cc83925..4d922063 100644
--- a/src/Render/PanelController.php
+++ b/src/Render/PanelController.php
@@ -8,6 +8,7 @@
use DrevOps\Tui\Answers\Answers;
use DrevOps\Tui\Answers\Provenance;
use DrevOps\Tui\Engine\Engine;
+use DrevOps\Tui\Handler\Context;
use DrevOps\Tui\Handler\HandlerRegistry;
use DrevOps\Tui\Model\Field;
use DrevOps\Tui\Model\FieldType;
@@ -218,6 +219,14 @@ class PanelController {
* An optional start banner (logo) shown before the interactive loop.
* @param string $version
* An optional version string shown below the banner.
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run context each settling passes to the closures that read it - the
+ * option sets resolved from the answers; defaults to a bare context.
+ * @param \DrevOps\Tui\Engine\Engine|null $engine
+ * The engine settling the form; NULL builds one over the same form and
+ * handlers. Passing the one that resolved the initial state keeps a single
+ * engine over the run, so what it has already resolved is not resolved
+ * again on the first settling.
*/
public function __construct(
protected FormDefinition $form,
@@ -231,6 +240,8 @@ public function __construct(
protected bool $clearOnExit = TRUE,
protected string $banner = '',
protected string $version = '',
+ protected Context $context = new Context(),
+ ?Engine $engine = NULL,
) {
$this->keymap = $keymap ?? KeyMapManager::create();
$this->externalEditor = $external_editor ?? new ExternalEditor();
@@ -238,7 +249,7 @@ public function __construct(
$this->nav = $this->keymap->navigation();
$this->scroller = new Scroller();
$this->navigator = new Navigator(new Panel('hub', $form->title, '', panels: $form->panels, layout: $form->layout));
- $this->engine = new Engine($form, $handlers ?? new HandlerRegistry());
+ $this->engine = $engine ?? new Engine($form, $handlers ?? new HandlerRegistry());
// Settle once at construction so the activation map exists before the
// first frame and a seeded value set is coherent with the form logic.
@@ -851,12 +862,13 @@ protected function handleEditing(Key $key): void {
/**
* Re-settle the form logic over the current values and clamp the cursor.
*
- * Runs the engine's settling - derive rules, conditional activation and
- * fix-ups - so an interactive change propagates exactly as a headless input
- * does, then keeps the cursor inside the possibly-changed item range.
+ * Runs the engine's settling - option sets resolved from the answers, derive
+ * rules, conditional activation and fix-ups - so an interactive change
+ * propagates exactly as a headless input does, then keeps the cursor inside
+ * the possibly-changed item range.
*/
protected function resettle(): void {
- [$this->active, $this->values] = $this->engine->settle($this->values, $this->pinnedDerives());
+ [$this->active, $this->values] = $this->engine->settle($this->values, $this->pinnedDerives(), $this->context);
// The refusal describes the values as they were when submit was pressed, so
// a change of any kind retires it rather than leaving it to contradict what
diff --git a/src/Schema/AgentHelp.php b/src/Schema/AgentHelp.php
index 7cd66dac..ee1b5b9d 100644
--- a/src/Schema/AgentHelp.php
+++ b/src/Schema/AgentHelp.php
@@ -208,7 +208,13 @@ protected function property(Field $field): array {
* field is not constrained to a closed set.
*/
protected function optionValues(Field $field): array {
- return $field->type->constrainsToOptions() ? $field->selectableValues() : [];
+ if (!$field->type->constrainsToOptions()) {
+ return [];
+ }
+
+ OptionsResolver::resolve($field, $this->context);
+
+ return $field->selectableValues();
}
}
diff --git a/src/Schema/OptionsResolver.php b/src/Schema/OptionsResolver.php
new file mode 100644
index 00000000..f9f9813b
--- /dev/null
+++ b/src/Schema/OptionsResolver.php
@@ -0,0 +1,51 @@
+optionsResolver instanceof \Closure) {
+ return;
+ }
+
+ try {
+ $field->options = Option::resolved(($field->optionsResolver)($context));
+ }
+ catch (\Throwable) {
+ // A field's options are settled state that outlives one call, so a set
+ // resolved for some earlier context is still sitting there. Nothing can
+ // be said about this one, and saying the last one instead would be a
+ // description of the wrong form.
+ $field->options = [];
+ }
+ }
+
+}
diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php
index d39088c4..3d77afff 100644
--- a/src/Schema/SchemaGenerator.php
+++ b/src/Schema/SchemaGenerator.php
@@ -18,7 +18,10 @@
* variables that answer it and the `when`, `derive` and `discover` rules, so
* external tooling can drive or validate the form without loading the PHP
* declaration. A closure default is resolved against the context (see
- * {@see DefaultResolver}) so a computed default advertises a real value.
+ * {@see DefaultResolver}) so a computed default advertises a real value, and
+ * options that follow the answers resolve against the same context (see
+ * {@see OptionsResolver}) and are flagged as `options_dynamic`, so tooling can
+ * tell a field with no options from one whose options are not fixed.
*
* @package DrevOps\Tui\Schema
*/
@@ -64,6 +67,7 @@ public function generate(): array {
'hint' => $field->hint,
'placeholder' => $field->placeholder,
'options' => $this->options($field),
+ 'options_dynamic' => $field->hasDynamicOptions(),
'default' => DefaultResolver::resolve($field, $this->context),
'required' => $field->required,
'env' => $names->isAdvertisable($field) ? $names->canonical($field) : NULL,
@@ -99,6 +103,8 @@ public function generate(): array {
* separators, headings and disabled options are excluded.
*/
protected function options(Field $field): array {
+ OptionsResolver::resolve($field, $this->context);
+
$out = [];
foreach ($field->options as $option) {
diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php
index b6d5c3a0..e4e9b240 100644
--- a/src/Schema/SchemaValidator.php
+++ b/src/Schema/SchemaValidator.php
@@ -4,6 +4,7 @@
namespace DrevOps\Tui\Schema;
+use DrevOps\Tui\Handler\Context;
use DrevOps\Tui\Model\FormDefinition;
use DrevOps\Tui\Model\Field;
use DrevOps\Tui\Translation\Translator;
@@ -12,8 +13,10 @@
* Validates an answer set against the configuration.
*
* Checks value types, option membership and required questions, and skips
- * questions whose `when` condition is not met by the answer set. Returns a
- * list of actionable error messages (empty when the set is valid).
+ * questions whose `when` condition is not met by the answer set. A question
+ * whose options follow the answers is checked against the set those very
+ * answers resolve to. Returns a list of actionable error messages (empty when
+ * the set is valid).
*
* @package DrevOps\Tui\Schema
*/
@@ -24,8 +27,11 @@ class SchemaValidator {
*
* @param \DrevOps\Tui\Model\FormDefinition $form
* The configuration to validate against.
+ * @param \DrevOps\Tui\Handler\Context $context
+ * The run context an options resolver is evaluated against, its answers
+ * replaced by the set under validation; defaults to an empty context.
*/
- public function __construct(protected FormDefinition $form) {
+ public function __construct(protected FormDefinition $form, protected Context $context = new Context()) {
}
/**
@@ -62,6 +68,12 @@ public function validate(array $answers): array {
continue;
}
+ // Options that follow the answers describe what this very answer set
+ // allows, so they are settled against it - carried on the run context, so
+ // a resolver reading the directory or the update flag sees what it would
+ // see during collection - before membership is checked.
+ OptionsResolver::resolve($field, new Context($this->context->directory, $answers, $this->context->update, $this->context->version));
+
$error = $this->validateValue($field, $answers[$field->id]);
if ($error !== NULL) {
$errors[] = $error;
diff --git a/src/Tui.php b/src/Tui.php
index 040b4cb3..692a78e5 100644
--- a/src/Tui.php
+++ b/src/Tui.php
@@ -517,10 +517,12 @@ public function controller(array $options, string $theme = '', string $banner =
// Restore this facade's language before rendering (see collect()).
Translator::setShared($this->translator);
+ $context = $this->context($directory, $update, $version);
+
// The full state, not collect()'s active-only answers: an inactive field
// keeps its settled value, so a condition satisfied mid-session surfaces
// the field with its default rather than an empty value.
- [$values, $provenance] = $this->engine->resolveState([], $this->context($directory, $update, $version));
+ [$values, $provenance] = $this->engine->resolveState([], $context);
$banner_text = $banner !== '' ? $banner : $this->form->banner;
@@ -535,6 +537,8 @@ public function controller(array $options, string $theme = '', string $banner =
clearOnExit: $this->clearOnExit,
banner: $banner_text,
version: $version,
+ context: $context,
+ engine: $this->engine,
);
}
@@ -596,12 +600,15 @@ public function agentHelp(?Context $context = NULL): string {
*
* @param array $answers
* The answers to validate.
+ * @param \DrevOps\Tui\Handler\Context|null $context
+ * The context an options resolver is evaluated against, its answers
+ * replaced by the ones under validation; NULL uses an empty context.
*
* @return list
* The validation errors (empty when valid).
*/
- public function validate(array $answers): array {
- return (new SchemaValidator($this->form))->validate($answers);
+ public function validate(array $answers, ?Context $context = NULL): array {
+ return (new SchemaValidator($this->form, $context ?? new Context()))->validate($answers);
}
/**
diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php
new file mode 100644
index 00000000..de943907
--- /dev/null
+++ b/tests/phpunit/Unit/DynamicOptionsTest.php
@@ -0,0 +1,578 @@
+>
+ */
+ protected const array CATALOG = [
+ 'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'],
+ 'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'],
+ ];
+
+ /**
+ * The contexts the scripted resolver was called with, in order.
+ *
+ * @var list<\DrevOps\Tui\Handler\Context>
+ */
+ protected array $contexts = [];
+
+ public function testResolvesTheOptionsOfTheAnsweredCategory(): void {
+ $answers = (new Tui($this->form()))->collect('{"category":"vegetable","item":"carrot"}');
+
+ $this->assertSame('carrot', $answers->value('item'));
+ $this->assertSame('vegetable', $this->lastContext()->answers['category']);
+ }
+
+ public function testRejectsValueTheResolvedSetDoesNotHold(): void {
+ // Apple is a real option - just not one this category resolves to.
+ $this->expectException(EngineException::class);
+ $this->expectExceptionMessageMatches('/not one of: carrot, potato, tomato/');
+
+ (new Tui($this->form()))->collect('{"category":"vegetable","item":"apple"}');
+ }
+
+ public function testRejectsValueWhenTheSetResolvesToNothing(): void {
+ // With no list to offer, naming the value is all that can honestly be said.
+ $this->expectException(EngineException::class);
+ $this->expectExceptionMessageMatches('/"apple" was not found/');
+
+ (new Tui($this->form(static fn(): array => [])))->collect('{"category":"fruit","item":"apple"}');
+ }
+
+ public function testResolverReadsTheWholeRunContext(): void {
+ (new Tui($this->form()))->collect('{"category":"fruit"}', 'orchard', TRUE, '2.0');
+
+ $context = $this->lastContext();
+ $this->assertSame('orchard', $context->directory);
+ $this->assertTrue($context->update);
+ $this->assertSame('2.0', $context->version);
+ }
+
+ public function testResolverReadsDerivedAnswers(): void {
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void {
+ $p->text('label', 'Order label')->default('Vegetable');
+ $p->text('category', 'Category')->derive(new Derive('{{label}}', transform: 'lower'));
+ $p->select('item', 'Item')->options($this->resolver());
+ });
+
+ // The derived category settles before the options resolve, so the resolver
+ // sees the computed value rather than the empty one it started as.
+ $answers = (new Tui($form))->collect('{"item":"carrot"}');
+
+ $this->assertSame('carrot', $answers->value('item'));
+ }
+
+ public function testDefaultOutsideTheResolvedSetIsDropped(): void {
+ $form = $this->form(NULL, static fn(FieldBuilder $field): FieldBuilder => $field->default('apple'));
+
+ $answers = (new Tui($form))->collect('{"category":"vegetable"}');
+
+ $this->assertSame('', $answers->value('item'));
+ }
+
+ public function testDefaultInsideTheResolvedSetStands(): void {
+ $form = $this->form(NULL, static fn(FieldBuilder $field): FieldBuilder => $field->default('apple'));
+
+ $answers = (new Tui($form))->collect('{"category":"fruit"}');
+
+ $this->assertSame('apple', $answers->value('item'));
+ }
+
+ public function testMultipleKeepsOnlyTheValuesTheSetStillHolds(): void {
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void {
+ $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit');
+ $p->select('basket', 'Basket')->multiple()->default(['apple', 'carrot', 'cherry'])->options($this->resolver());
+ });
+
+ $answers = (new Tui($form))->collect('{"category":"fruit"}');
+
+ $this->assertSame(['apple', 'cherry'], $answers->value('basket'));
+ }
+
+ public function testRankingIsCompletedToTheResolvedOptions(): void {
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void {
+ $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('vegetable');
+ $p->reorder('ranking', 'Ranking')->options($this->resolver());
+ });
+
+ // A reorder is always a full permutation, so the resolved set becomes the
+ // ranking even though the field was declared without one.
+ $answers = (new Tui($form))->collect('{}');
+
+ $this->assertSame(['carrot', 'potato', 'tomato'], $answers->value('ranking'));
+ }
+
+ public function testToggleFallsBackToTheFirstResolvedOption(): void {
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void {
+ $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('vegetable');
+ $p->toggle('item', 'Item')->options($this->resolver());
+ });
+
+ // A toggle is always in one of its states, and it has none until the
+ // resolver hands it some.
+ $answers = (new Tui($form))->collect('{}');
+
+ $this->assertSame('carrot', $answers->value('item'));
+ }
+
+ public function testSuggestKeepsValueTheResolvedHintsDoNotHold(): void {
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void {
+ $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit');
+ $p->suggest('item', 'Item')->options($this->resolver());
+ });
+
+ // A suggest field's options are hints, never a closed set, so a value
+ // outside them is the user's own rather than a mistake.
+ $answers = (new Tui($form))->collect('{"item":"Quince"}');
+
+ $this->assertSame('Quince', $answers->value('item'));
+ }
+
+ public function testResolverReturningSomethingElseDegradesToNoOptions(): void {
+ $form = $this->form(static fn(): mixed => 'not a map');
+
+ $this->expectException(EngineException::class);
+ $this->expectExceptionMessageMatches('/"apple" was not found/');
+
+ (new Tui($form))->collect('{"category":"fruit","item":"apple"}');
+ }
+
+ public function testThrowingResolverBecomesAnEngineError(): void {
+ $form = $this->form(static function (): array {
+ throw new \RuntimeException('The pantry is unreachable.');
+ });
+
+ $this->expectException(EngineException::class);
+ $this->expectExceptionMessageMatches('/Could not load options for field "item": The pantry is unreachable\./');
+
+ (new Tui($form))->collect('{"category":"fruit"}');
+ }
+
+ public function testInteractiveListNarrowsToTheChosenCategory(): void {
+ $tester = $this->tester($this->form());
+ // Open the category, step to Vegetable, take it, then open the item.
+ $tester->run($this->enter(), $this->enter(), $this->down(), $this->enter(), $this->down(), $this->enter());
+
+ $display = $tester->display();
+ $this->assertStringContainsString('Carrot', $display);
+ $this->assertStringNotContainsString('Apple', $display);
+ }
+
+ public function testInteractiveSelectionIsDroppedOnceTheSetNarrows(): void {
+ $tester = $this->tester($this->form());
+ // Take Apple under Fruit, then go back and switch the category: the choice
+ // the new category does not offer is not left standing in the answers.
+ $answers = $tester->run(
+ $this->enter(),
+ $this->down(),
+ $this->enter(),
+ $this->enter(),
+ $this->up(),
+ $this->enter(),
+ $this->down(),
+ $this->enter(),
+ );
+
+ $this->assertSame('vegetable', $answers->value('category'));
+ $this->assertSame('', $answers->value('item'));
+ }
+
+ public function testCallbackAskingForNothingStillLoadsOnce(): void {
+ $calls = 0;
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p) use (&$calls): void {
+ $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit');
+ $p->select('item', 'Item')->options(function () use (&$calls): array {
+ $calls++;
+
+ return ['apple' => 'Apple'];
+ });
+ });
+
+ // The callback asks for no context, so it is the loader it has always been:
+ // resolved once when the panel opens, and left alone when an answer it
+ // cannot read changes under it.
+ $tester = $this->tester($form);
+ $tester->run($this->enter(), $this->enter(), $this->down(), $this->enter(), $this->down(), $this->enter());
+
+ $this->assertSame(1, $calls);
+ $this->assertStringContainsString('Apple', $tester->display());
+ }
+
+ public function testResolverIsNotCalledAgainWhileTheAnswersStand(): void {
+ $tester = $this->tester($this->form());
+ // Moving the cursor and opening an editor changes no answer, so the options
+ // in front of the user are still the ones already resolved.
+ $tester->run($this->down(), $this->down(), $this->up(), $this->enter());
+
+ $this->assertCount(1, $this->contexts);
+ }
+
+ public function testValidatorChecksMembershipAgainstTheResolvedSet(): void {
+ $tui = new Tui($this->form());
+
+ $this->assertSame([], $tui->validate(['category' => 'vegetable', 'item' => 'carrot']));
+ $this->assertSame(['Question "item": value "apple" is not one of: carrot, potato, tomato.'], $tui->validate(['category' => 'vegetable', 'item' => 'apple']));
+ }
+
+ public function testValidatorCarriesTheRunContextToTheResolver(): void {
+ // The resolver sees what it would see during collection, so one reading the
+ // directory or the update flag does not judge a value against a set that
+ // only a bare context produces.
+ (new Tui($this->form()))->validate(['category' => 'vegetable', 'item' => 'carrot'], new Context('orchard', [], TRUE, '2.0'));
+
+ $context = $this->lastContext();
+ $this->assertSame('orchard', $context->directory);
+ $this->assertTrue($context->update);
+ $this->assertSame('2.0', $context->version);
+ $this->assertSame('vegetable', $context->answers['category']);
+ }
+
+ public function testResolverIsCalledAgainForAnotherRunContext(): void {
+ $tui = new Tui($this->form());
+
+ // Same answers, another directory: a resolver reading the context has a
+ // different question to answer, so the first run's options are not reused.
+ $tui->collect('{"category":"fruit"}', 'orchard');
+ $tui->collect('{"category":"fruit"}', 'market');
+
+ $this->assertSame(['orchard', 'market'], array_map(static fn(Context $context): string => $context->directory, $this->contexts));
+ }
+
+ public function testFailedResolutionLeavesNoOptionsForSchemaSurfaces(): void {
+ // The first context resolves; the second cannot. Its description must not
+ // fall back to the set the first one produced.
+ $form = $this->form(static function (Context $context): array {
+ if (($context->answers['category'] ?? '') === 'vegetable') {
+ throw new \RuntimeException('The pantry is unreachable.');
+ }
+
+ return self::CATALOG['fruit'];
+ });
+
+ $tui = new Tui($form);
+ $this->assertSame(['apple', 'banana', 'cherry'], $this->schemaOptions($tui->schema(new Context(answers: ['category' => 'fruit'])), 'item'));
+ $this->assertSame([], $this->schemaOptions($tui->schema(new Context(answers: ['category' => 'vegetable'])), 'item'));
+ }
+
+ public function testSchemaResolvesTheOptionsOfTheGivenContext(): void {
+ $schema = (new Tui($this->form()))->schema(new Context(answers: ['category' => 'vegetable']));
+
+ $this->assertSame(['carrot', 'potato', 'tomato'], $this->schemaOptions($schema, 'item'));
+ $this->assertTrue($this->schemaFlag($schema, 'item', 'options_dynamic'));
+ $this->assertFalse($this->schemaFlag($schema, 'category', 'options_dynamic'));
+ }
+
+ public function testSchemaFlagsOptionsThatFollowTheQuery(): void {
+ $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void {
+ $p->search('veg', 'Vegetable')->optionsFrom(static fn(string $query): array => []);
+ });
+
+ $this->assertTrue($this->schemaFlag((new Tui($form))->schema(), 'veg', 'options_dynamic'));
+ }
+
+ public function testAgentHelpEnumeratesTheResolvedValues(): void {
+ $help = (new Tui($this->form()))->agentHelp(new Context(answers: ['category' => 'vegetable']));
+
+ $this->assertStringContainsString('carrot', $help);
+ $this->assertStringNotContainsString('apple', $help);
+ }
+
+ public function testReconcilingLeavesNonChoiceValueAlone(): void {
+ $field = new Field('name', 'Order name', '', FieldType::Text, 'Pear');
+
+ $this->assertSame('Pear', $field->reconcileValue('Pear'));
+ }
+
+ #[DataProvider('dataProviderRejectedDeclarationFailsWhenTheFormIsBuilt')]
+ public function testRejectedDeclarationFailsWhenTheFormIsBuilt(\Closure $declare, string $message): void {
+ $this->expectException(FormException::class);
+ $this->expectExceptionMessageMatches($message);
+
+ Form::create('Order')->panel('order', 'New order', $declare)->build();
+ }
+
+ /**
+ * Declarations an options resolver cannot be combined with.
+ *
+ * @return \Iterator
+ * The panel declaration and the expected message pattern, per case.
+ */
+ public static function dataProviderRejectedDeclarationFailsWhenTheFormIsBuilt(): \Iterator {
+ $resolver = static fn(Context $context): array => [];
+
+ yield 'a resolver on a type with no option list' => [
+ static function (PanelBuilder $p) use ($resolver): void {
+ $p->text('item', 'Item')->options($resolver);
+ },
+ '/of type "text" shows no options/',
+ ];
+
+ yield 'a fixed list on a type with no option list' => [
+ static function (PanelBuilder $p): void {
+ $p->number('quantity', 'Quantity')->options(['1' => 'One']);
+ },
+ '/of type "number" shows no options/',
+ ];
+
+ yield 'a loader on a type with no option list' => [
+ static function (PanelBuilder $p): void {
+ $p->text('item', 'Item')->options(static fn(): array => ['apple' => 'Apple']);
+ },
+ '/of type "text" shows no options/',
+ ];
+
+ yield 'alongside static options' => [
+ static function (PanelBuilder $p) use ($resolver): void {
+ $p->select('item', 'Item')->options(['apple' => 'Apple'])->options($resolver);
+ },
+ '/declare only one/',
+ ];
+
+ yield 'alongside an option loader' => [
+ static function (PanelBuilder $p) use ($resolver): void {
+ $p->select('item', 'Item')->options(static fn(): array => ['apple' => 'Apple'])->options($resolver);
+ },
+ '/declare only one/',
+ ];
+
+ yield 'alongside a query source' => [
+ static function (PanelBuilder $p) use ($resolver): void {
+ $p->search('item', 'Item')->optionsFrom(static fn(string $query): array => [])->options($resolver);
+ },
+ '/declare only one/',
+ ];
+ }
+
+ /**
+ * A single-panel order form whose item options follow the chosen category.
+ *
+ * @param \Closure|null $answer
+ * The `fn (Context $context): mixed` the resolver answers with; NULL reads
+ * the catalog of the answered category.
+ * @param \Closure|null $declare
+ * A `fn (FieldBuilder): FieldBuilder` adding further declarations.
+ *
+ * @return \DrevOps\Tui\Builder\Form
+ * The form.
+ */
+ protected function form(?\Closure $answer = NULL, ?\Closure $declare = NULL): Form {
+ return Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p) use ($answer, $declare): void {
+ $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit');
+ $field = $p->select('item', 'Item')->options($this->resolver($answer));
+
+ if ($declare instanceof \Closure) {
+ $declare($field);
+ }
+ });
+ }
+
+ /**
+ * An options resolver that records the context before it answers.
+ *
+ * @param \Closure|null $answer
+ * The `fn (Context $context): mixed` to answer with; NULL reads the catalog
+ * of the answered category.
+ *
+ * @return \Closure
+ * The recording resolver.
+ */
+ protected function resolver(?\Closure $answer = NULL): \Closure {
+ return function (Context $context) use ($answer): mixed {
+ $this->contexts[] = $context;
+
+ if ($answer instanceof \Closure) {
+ return $answer($context);
+ }
+
+ $category = $context->answers['category'] ?? '';
+
+ return is_string($category) ? (self::CATALOG[$category] ?? []) : [];
+ };
+ }
+
+ /**
+ * The option values a generated schema advertises for a prompt.
+ *
+ * @param array $schema
+ * The generated schema.
+ * @param string $id
+ * The prompt id.
+ *
+ * @return list
+ * The advertised option values, in order.
+ */
+ protected function schemaOptions(array $schema, string $id): array {
+ $options = $this->schemaFor($schema, $id)['options'] ?? NULL;
+
+ if (!is_array($options)) {
+ $this->fail(sprintf('The prompt "%s" carries no options.', $id));
+ }
+
+ $values = [];
+
+ foreach ($options as $option) {
+ $value = is_array($option) ? ($option['value'] ?? NULL) : NULL;
+
+ if (!is_string($value)) {
+ $this->fail(sprintf('The prompt "%s" carries an option with no value.', $id));
+ }
+
+ $values[] = $value;
+ }
+
+ return $values;
+ }
+
+ /**
+ * A boolean a generated schema carries for a prompt.
+ *
+ * @param array $schema
+ * The generated schema.
+ * @param string $id
+ * The prompt id.
+ * @param string $key
+ * The key of the boolean.
+ *
+ * @return bool
+ * The advertised value.
+ */
+ protected function schemaFlag(array $schema, string $id, string $key): bool {
+ $flag = $this->schemaFor($schema, $id)[$key] ?? NULL;
+
+ if (!is_bool($flag)) {
+ $this->fail(sprintf('The prompt "%s" carries no "%s" flag.', $id, $key));
+ }
+
+ return $flag;
+ }
+
+ /**
+ * The prompt entry a generated schema carries for a field.
+ *
+ * @param array $schema
+ * The generated schema.
+ * @param string $id
+ * The prompt id.
+ *
+ * @return array
+ * The prompt entry.
+ */
+ protected function schemaFor(array $schema, string $id): array {
+ $prompts = $schema['prompts'] ?? NULL;
+
+ if (!is_array($prompts)) {
+ $this->fail('The schema carries no prompts.');
+ }
+
+ foreach ($prompts as $prompt) {
+ if (is_array($prompt) && ($prompt['id'] ?? NULL) === $id) {
+ return $prompt;
+ }
+ }
+
+ $this->fail(sprintf('The schema carries no prompt "%s".', $id));
+ }
+
+ /**
+ * The context of the resolver's most recent call.
+ *
+ * @return \DrevOps\Tui\Handler\Context
+ * The context.
+ */
+ protected function lastContext(): Context {
+ $this->assertNotEmpty($this->contexts);
+
+ return $this->contexts[count($this->contexts) - 1];
+ }
+
+ /**
+ * A tester rendering the form deterministically at a workable height.
+ *
+ * @param \DrevOps\Tui\Builder\Form $form
+ * The form.
+ *
+ * @return \DrevOps\Tui\Testing\TuiTester
+ * The tester.
+ */
+ protected function tester(Form $form): TuiTester {
+ return (new TuiTester($form))->rows(16);
+ }
+
+ /**
+ * The Enter key, as its own read.
+ *
+ * @return \DrevOps\Tui\Input\Key
+ * The key.
+ */
+ protected function enter(): Key {
+ return Key::named(KeyName::Enter);
+ }
+
+ /**
+ * The Down key, as its own read.
+ *
+ * @return \DrevOps\Tui\Input\Key
+ * The key.
+ */
+ protected function down(): Key {
+ return Key::named(KeyName::Down);
+ }
+
+ /**
+ * The Up key, as its own read.
+ *
+ * @return \DrevOps\Tui\Input\Key
+ * The key.
+ */
+ protected function up(): Key {
+ return Key::named(KeyName::Up);
+ }
+
+}
diff --git a/tests/phpunit/Unit/Engine/EngineConditionalTest.php b/tests/phpunit/Unit/Engine/EngineConditionalTest.php
index 4d28bb9b..a3267d3c 100644
--- a/tests/phpunit/Unit/Engine/EngineConditionalTest.php
+++ b/tests/phpunit/Unit/Engine/EngineConditionalTest.php
@@ -158,10 +158,10 @@ public function testResolveStateKeepsInactiveFields(): void {
public function testSettleReappliesFormLogic(): void {
$engine = $this->gatedEngine();
- [$active] = $engine->settle(['theme' => 'custom', 'custom_theme' => 'mytheme'], []);
+ [$active] = $engine->settle(['theme' => 'custom', 'custom_theme' => 'mytheme'], [], new Context());
$this->assertSame(['theme' => TRUE, 'custom_theme' => TRUE], $active);
- [$active] = $engine->settle(['theme' => 'olivero', 'custom_theme' => 'mytheme'], []);
+ [$active] = $engine->settle(['theme' => 'olivero', 'custom_theme' => 'mytheme'], [], new Context());
$this->assertFalse($active['custom_theme']);
}
diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
index f045a854..d8aaf881 100644
--- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
+++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
@@ -46,6 +46,7 @@ public function testGenerate(): void {
['value' => 'standard', 'label' => 'Standard', 'description' => 'Std'],
['value' => 'minimal', 'label' => 'Minimal', 'description' => ''],
],
+ 'options_dynamic' => FALSE,
'default' => 'standard',
'required' => TRUE,
'env' => NULL,
@@ -73,6 +74,7 @@ public function testGenerate(): void {
'hint' => 'Leave empty to follow the profile.',
'placeholder' => 'E.g. Golden Beetroot',
'options' => [],
+ 'options_dynamic' => FALSE,
'default' => '',
'required' => FALSE,
'env' => NULL,
@@ -100,6 +102,7 @@ public function testGenerate(): void {
'hint' => '',
'placeholder' => '',
'options' => [],
+ 'options_dynamic' => FALSE,
'default' => 0,
'required' => FALSE,
'env' => NULL,
@@ -127,6 +130,7 @@ public function testGenerate(): void {
'hint' => '',
'placeholder' => '',
'options' => [],
+ 'options_dynamic' => FALSE,
'default' => '',
'required' => FALSE,
'env' => NULL,
@@ -171,6 +175,7 @@ public function testDescribesTemplateShape(): void {
'hint' => '',
'placeholder' => '',
'options' => [],
+ 'options_dynamic' => FALSE,
'default' => 'valley-a',
'required' => FALSE,
'env' => NULL,
@@ -219,6 +224,7 @@ public function testExcludesNonSelectableOptions(): void {
'options' => [
['value' => 'standard', 'label' => 'Standard', 'description' => ''],
],
+ 'options_dynamic' => FALSE,
'default' => '',
'required' => FALSE,
'env' => NULL,