From 49f6e5d93d7250e72c1d6141079b384f7e7e9184 Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 19:51:24 +1000
Subject: [PATCH 1/7] [#106] Added choice field options resolved from the
collected answers.
Choice fields take an 'optionsFor()' resolver called with the run context and re-run whenever the answers change. The set resolves inside the engine's settling, so conditions, headless collection, the schema and the validator all see the same narrowed list, and a value the set no longer holds is reconciled away - a ranking completed, a toggle returned to its first state - unless it was supplied, in which case it is reported.
---
src/Builder/FieldBuilder.php | 43 +-
src/Builder/Form.php | 11 +
src/Engine/Engine.php | 133 +++++-
src/Model/Field.php | 96 +++-
src/Model/FieldType.php | 20 +
src/Render/PanelController.php | 22 +-
src/Schema/AgentHelp.php | 8 +-
src/Schema/OptionsResolver.php | 47 ++
src/Schema/SchemaGenerator.php | 8 +-
src/Schema/SchemaValidator.php | 11 +-
src/Tui.php | 6 +-
tests/phpunit/Unit/DynamicOptionsTest.php | 421 ++++++++++++++++++
.../Unit/Engine/EngineConditionalTest.php | 4 +-
.../Unit/Schema/SchemaGeneratorTest.php | 6 +
14 files changed, 808 insertions(+), 28 deletions(-)
create mode 100644 src/Schema/OptionsResolver.php
create mode 100644 tests/phpunit/Unit/DynamicOptionsTest.php
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index 0be2e537..f236de01 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 $optionsFor = NULL;
+
/**
* The query length below which the query source is not called.
*/
@@ -1096,7 +1101,8 @@ public function heading(string $label): self {
* 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()`.
+ * `->default()`. For options that follow the collected answers rather than
+ * resolving once, see `->optionsFor()`.
*
* @return $this
* The builder.
@@ -1115,6 +1121,40 @@ public function options(array|\Closure $options): self {
return $this;
}
+ /**
+ * Resolve the options from the answers collected so far.
+ *
+ * Where `->options()` resolves one fixed list, a resolver is called again
+ * whenever the answers 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 conditions evaluate and
+ * 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 resolved 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 - so an answer never names an option that is not on offer.
+ * A value supplied headlessly is reported instead of dropped.
+ *
+ * Keep it cheap: it runs for the whole form on every settle, not once per
+ * panel. Load an expensive list with `->options()`, which resolves once, or
+ * source a remote one with `->optionsFrom()`, which follows the query.
+ *
+ * @param \Closure $resolver
+ * An `fn (Context $context): array` returning the options
+ * for the run context, keyed by value with a label - the shape
+ * `->options()` takes. The context carries the answers collected so far
+ * alongside the target directory, the update flag and the version.
+ *
+ * @return $this
+ * The builder.
+ */
+ public function optionsFor(\Closure $resolver): self {
+ $this->optionsFor = $resolver;
+
+ return $this;
+ }
+
/**
* Search and suggest only: source the options from the live query.
*
@@ -1255,6 +1295,7 @@ public function build(): Field {
envAliases: $this->envAliases,
ghost: $this->ghost,
ratingCaptions: $this->captions,
+ optionsFor: $this->optionsFor,
);
}
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..aebba698 100644
--- a/src/Engine/Engine.php
+++ b/src/Engine/Engine.php
@@ -38,6 +38,20 @@ class Engine {
*/
protected Deriver $deriver;
+ /**
+ * What each field's dynamic option set was last resolved from, and to.
+ *
+ * A settling pass that leaves the answers as they were leaves the options
+ * they produced valid, so the resolver is called again only when what it
+ * reads has actually changed. The rows are remembered alongside them 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,rows:list<\DrevOps\Tui\Model\Option>}>
+ */
+ protected array $optionMemo = [];
+
/**
* Construct an engine.
*
@@ -74,7 +88,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 +161,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 +173,97 @@ 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);
+
+ foreach ($fields as $field) {
+ if (!$field->optionsFor instanceof \Closure) {
+ continue;
+ }
+
+ $memo = $this->optionMemo[$field->id] ?? NULL;
+ if ($memo !== NULL && $memo['answers'] === $answers && $memo['rows'] === $field->options) {
+ continue;
+ }
+
+ try {
+ $field->options = Option::resolved(($field->optionsFor)(new Context($context->directory, $answers, $context->update, $context->version)));
+ }
+ catch (\Throwable $throwable) {
+ throw $this->optionsError($field, $throwable);
+ }
+
+ $this->optionMemo[$field->id] = ['answers' => $answers, '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 {
+ return new EngineException(Translator::t('Could not load options for field "@id": @error', [
+ '@id' => $field->id,
+ '@error' => $throwable->getMessage(),
+ ]), $throwable->getCode(), $throwable);
+ }
+
/**
* The queries that look up a field's supplied value, one per item.
*
@@ -206,7 +308,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 +327,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 +649,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 +668,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..a6220386 100644
--- a/src/Model/Field.php
+++ b/src/Model/Field.php
@@ -12,9 +12,10 @@
/**
* 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
+ * The definition is immutable except for three resolved concerns written back:
+ * options a field declares indirectly - through a loader resolved once
+ * (`$optionsLoader`) or a resolver re-run as the answers change (`$optionsFor`)
+ * - 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.
@@ -203,6 +204,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 $optionsFor
+ * 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 +255,7 @@ public function __construct(
public readonly array $envAliases = [],
public readonly bool $ghost = FALSE,
public readonly array $ratingCaptions = [],
+ public readonly ?\Closure $optionsFor = NULL,
) {
$this->assertEnvNames();
$this->assertRatingCaptions();
@@ -271,6 +278,16 @@ 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 ($this->optionsFor instanceof \Closure) {
+ if (!$this->type->supportsOptions()) {
+ throw new FormException(sprintf('Field "%s" of type "%s" shows no options to resolve; only select, search, suggest, toggle and reorder fields have a list.', $this->id, $this->type->value));
+ }
+
+ if ($options !== [] || $optionsLoader instanceof \Closure || $this->optionsSource instanceof \Closure) {
+ throw new FormException(sprintf('Field "%s" resolves its options from the answers and declares its own options as well; a resolver 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 +449,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->optionsFor 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->optionsFor 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 +631,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..44c490f0
--- /dev/null
+++ b/src/Schema/OptionsResolver.php
@@ -0,0 +1,47 @@
+optionsFor instanceof \Closure) {
+ return;
+ }
+
+ try {
+ $field->options = Option::resolved(($field->optionsFor)($context));
+ }
+ catch (\Throwable) {
+ // Nothing this context can be told; the empty list stands.
+ }
+ }
+
+}
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..1cafaf8b 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
*/
@@ -62,6 +65,10 @@ 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 before membership is checked.
+ OptionsResolver::resolve($field, new Context('', $answers));
+
$error = $this->validateValue($field, $answers[$field->id]);
if ($error !== NULL) {
$errors[] = $error;
diff --git a/src/Tui.php b/src/Tui.php
index 040b4cb3..1960cb14 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,
);
}
diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php
new file mode 100644
index 00000000..cc38821c
--- /dev/null
+++ b/tests/phpunit/Unit/DynamicOptionsTest.php
@@ -0,0 +1,421 @@
+>
+ */
+ 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')->optionsFor($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'])->optionsFor($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')->optionsFor($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')->optionsFor($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 testSuggestKeepsAValueTheResolvedHintsDoNotHold(): 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')->optionsFor($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 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 testSchemaResolvesTheOptionsOfTheGivenContext(): void {
+ $schema = (new Tui($this->form()))->schema(new Context(answers: ['category' => 'vegetable']));
+
+ $item = $schema['prompts'][1];
+ $this->assertSame(['carrot', 'potato', 'tomato'], array_column($item['options'], 'value'));
+ $this->assertTrue($item['options_dynamic']);
+ $this->assertFalse($schema['prompts'][0]['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((new Tui($form))->schema()['prompts'][0]['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 testReconcilingLeavesAValueThatIsNotAChoiceAlone(): 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 type with no option list' => [
+ static function (PanelBuilder $p) use ($resolver): void {
+ $p->text('item', 'Item')->optionsFor($resolver);
+ },
+ '/shows no options to resolve/',
+ ];
+
+ yield 'alongside static options' => [
+ static function (PanelBuilder $p) use ($resolver): void {
+ $p->select('item', 'Item')->options(['apple' => 'Apple'])->optionsFor($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'])->optionsFor($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 => [])->optionsFor($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')->optionsFor($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 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,
From efc636f49d5ab337d8b3d3c44924de9ca3707af7 Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 20:13:31 +1000
Subject: [PATCH 2/7] [#106] Folded answer-driven options into 'options()' and
documented both lifecycles.
A callback handed to 'options()' now says by its own signature when it runs: one asking for the run context follows the collected answers, one asking for nothing loads once on panel entry as before. Options declared on a type with no list are rejected when the form is built, whichever form they take. Adds the playground demo, the documentation and the updated data-flow diagrams.
---
README.md | 1 +
docs/architecture/dataflow-collect-dark.svg | 2 +-
docs/architecture/dataflow-collect.puml | 6 ++
docs/architecture/dataflow-collect.svg | 2 +-
docs/architecture/dataflow-tui-dark.svg | 2 +-
docs/architecture/dataflow-tui.puml | 7 +-
docs/architecture/dataflow-tui.svg | 2 +-
docs/content/field-behaviour.mdx | 36 +++++++++-
docs/content/progress.mdx | 5 +-
docs/content/widgets/select.mdx | 8 +--
playground/19-dynamic-options.php | 53 ++++++++++++++
playground/README.md | 1 +
src/Builder/FieldBuilder.php | 79 +++++++++------------
src/Engine/Engine.php | 4 +-
src/Model/Field.php | 26 +++----
src/Schema/OptionsResolver.php | 4 +-
tests/phpunit/Unit/DynamicOptionsTest.php | 59 +++++++++++----
17 files changed, 207 insertions(+), 90 deletions(-)
create mode 100644 playground/19-dynamic-options.php
diff --git a/README.md b/README.md
index 998c76c7..33d0529b 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 | [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..0ec4476d 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,38 @@ 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(fn(Context $c): array => $catalog[$c->answers['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. 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..572e7ef6 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..7e531018 100644
--- a/docs/content/widgets/select.mdx
+++ b/docs/content/widgets/select.mdx
@@ -32,11 +32,11 @@ Runnable scripts: [`playground/02-widgets-select.php`](https://github.com/drevop
| 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` |
+| `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..2eaa6df0
--- /dev/null
+++ b/playground/19-dynamic-options.php
@@ -0,0 +1,53 @@
+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.
+ *
+ * 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');
+
+ // 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(static fn(Context $context): array => $catalog[$context->answers['category']] ?? []);
+
+ // The same narrowing over several picks - the basket keeps only what the
+ // chosen category still offers.
+ $p->select('basket', 'Basket')->multiple()->options(static fn(Context $context): array => $catalog[$context->answers['category']] ?? []);
+ });
+
+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..8d35062a 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. | [`19-dynamic-options.php`](19-dynamic-options.php) |
## Running the examples
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index f236de01..f9e06054 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -94,7 +94,7 @@ final class FieldBuilder {
/**
* A resolver for the options, or NULL when they do not follow the answers.
*/
- protected ?\Closure $optionsFor = NULL;
+ protected ?\Closure $optionsResolver = NULL;
/**
* The query length below which the query source is not called.
@@ -1092,24 +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 conditions evaluate and 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()`. For options that follow the collected answers rather than
- * resolving once, see `->optionsFor()`.
+ * 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;
}
@@ -1121,40 +1142,6 @@ public function options(array|\Closure $options): self {
return $this;
}
- /**
- * Resolve the options from the answers collected so far.
- *
- * Where `->options()` resolves one fixed list, a resolver is called again
- * whenever the answers 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 conditions evaluate and
- * 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 resolved 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 - so an answer never names an option that is not on offer.
- * A value supplied headlessly is reported instead of dropped.
- *
- * Keep it cheap: it runs for the whole form on every settle, not once per
- * panel. Load an expensive list with `->options()`, which resolves once, or
- * source a remote one with `->optionsFrom()`, which follows the query.
- *
- * @param \Closure $resolver
- * An `fn (Context $context): array` returning the options
- * for the run context, keyed by value with a label - the shape
- * `->options()` takes. The context carries the answers collected so far
- * alongside the target directory, the update flag and the version.
- *
- * @return $this
- * The builder.
- */
- public function optionsFor(\Closure $resolver): self {
- $this->optionsFor = $resolver;
-
- return $this;
- }
-
/**
* Search and suggest only: source the options from the live query.
*
@@ -1295,7 +1282,7 @@ public function build(): Field {
envAliases: $this->envAliases,
ghost: $this->ghost,
ratingCaptions: $this->captions,
- optionsFor: $this->optionsFor,
+ optionsResolver: $this->optionsResolver,
);
}
diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php
index aebba698..d0ef44a8 100644
--- a/src/Engine/Engine.php
+++ b/src/Engine/Engine.php
@@ -205,7 +205,7 @@ protected function resolveDynamicOptions(array $fields, array $values, array $ac
$answers = $this->activeAnswers($fields, $values, $active);
foreach ($fields as $field) {
- if (!$field->optionsFor instanceof \Closure) {
+ if (!$field->optionsResolver instanceof \Closure) {
continue;
}
@@ -215,7 +215,7 @@ protected function resolveDynamicOptions(array $fields, array $values, array $ac
}
try {
- $field->options = Option::resolved(($field->optionsFor)(new Context($context->directory, $answers, $context->update, $context->version)));
+ $field->options = Option::resolved(($field->optionsResolver)(new Context($context->directory, $answers, $context->update, $context->version)));
}
catch (\Throwable $throwable) {
throw $this->optionsError($field, $throwable);
diff --git a/src/Model/Field.php b/src/Model/Field.php
index a6220386..05779924 100644
--- a/src/Model/Field.php
+++ b/src/Model/Field.php
@@ -14,10 +14,10 @@
*
* The definition is immutable except for three resolved concerns written back:
* options a field declares indirectly - through a loader resolved once
- * (`$optionsLoader`) or a resolver re-run as the answers change (`$optionsFor`)
- * - 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
+ * (`$optionsLoader`) or a resolver re-run as the answers change
+ * (`$optionsResolver`) - 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
@@ -204,7 +204,7 @@ 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 $optionsFor
+ * @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
@@ -255,7 +255,7 @@ public function __construct(
public readonly array $envAliases = [],
public readonly bool $ghost = FALSE,
public readonly array $ratingCaptions = [],
- public readonly ?\Closure $optionsFor = NULL,
+ public readonly ?\Closure $optionsResolver = NULL,
) {
$this->assertEnvNames();
$this->assertRatingCaptions();
@@ -278,13 +278,13 @@ 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 ($this->optionsFor instanceof \Closure) {
- if (!$this->type->supportsOptions()) {
- throw new FormException(sprintf('Field "%s" of type "%s" shows no options to resolve; only select, search, suggest, toggle and reorder fields have a list.', $this->id, $this->type->value));
- }
+ 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) {
if ($options !== [] || $optionsLoader instanceof \Closure || $this->optionsSource instanceof \Closure) {
- throw new FormException(sprintf('Field "%s" resolves its options from the answers and declares its own options as well; a resolver replaces them, so declare only one.', $this->id));
+ 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));
}
}
@@ -457,7 +457,7 @@ public function selectableValues(): array {
* 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->optionsFor instanceof \Closure && !$this->optionsSource instanceof \Closure;
+ return !$this->optionsLoader instanceof \Closure && !$this->optionsResolver instanceof \Closure && !$this->optionsSource instanceof \Closure;
}
/**
@@ -468,7 +468,7 @@ public function hasSettledOptions(): bool {
* live query, so no one list describes the field.
*/
public function hasDynamicOptions(): bool {
- return $this->optionsFor instanceof \Closure || $this->optionsSource instanceof \Closure;
+ return $this->optionsResolver instanceof \Closure || $this->optionsSource instanceof \Closure;
}
/**
diff --git a/src/Schema/OptionsResolver.php b/src/Schema/OptionsResolver.php
index 44c490f0..bcf2defd 100644
--- a/src/Schema/OptionsResolver.php
+++ b/src/Schema/OptionsResolver.php
@@ -32,12 +32,12 @@ final class OptionsResolver {
* The context the resolver is called with.
*/
public static function resolve(Field $field, Context $context): void {
- if (!$field->optionsFor instanceof \Closure) {
+ if (!$field->optionsResolver instanceof \Closure) {
return;
}
try {
- $field->options = Option::resolved(($field->optionsFor)($context));
+ $field->options = Option::resolved(($field->optionsResolver)($context));
}
catch (\Throwable) {
// Nothing this context can be told; the empty list stands.
diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php
index cc38821c..22896b03 100644
--- a/tests/phpunit/Unit/DynamicOptionsTest.php
+++ b/tests/phpunit/Unit/DynamicOptionsTest.php
@@ -99,7 +99,7 @@ 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')->optionsFor($this->resolver());
+ $p->select('item', 'Item')->options($this->resolver());
});
// The derived category settles before the options resolve, so the resolver
@@ -128,7 +128,7 @@ public function testDefaultInsideTheResolvedSetStands(): void {
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'])->optionsFor($this->resolver());
+ $p->select('basket', 'Basket')->multiple()->default(['apple', 'carrot', 'cherry'])->options($this->resolver());
});
$answers = (new Tui($form))->collect('{"category":"fruit"}');
@@ -139,7 +139,7 @@ public function testMultipleKeepsOnlyTheValuesTheSetStillHolds(): void {
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')->optionsFor($this->resolver());
+ $p->reorder('ranking', 'Ranking')->options($this->resolver());
});
// A reorder is always a full permutation, so the resolved set becomes the
@@ -152,7 +152,7 @@ public function testRankingIsCompletedToTheResolvedOptions(): void {
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')->optionsFor($this->resolver());
+ $p->toggle('item', 'Item')->options($this->resolver());
});
// A toggle is always in one of its states, and it has none until the
@@ -165,7 +165,7 @@ public function testToggleFallsBackToTheFirstResolvedOption(): void {
public function testSuggestKeepsAValueTheResolvedHintsDoNotHold(): 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')->optionsFor($this->resolver());
+ $p->suggest('item', 'Item')->options($this->resolver());
});
// A suggest field's options are hints, never a closed set, so a value
@@ -224,6 +224,27 @@ public function testInteractiveSelectionIsDroppedOnceTheSetNarrows(): void {
$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
@@ -287,30 +308,44 @@ public function testRejectedDeclarationFailsWhenTheFormIsBuilt(\Closure $declare
public static function dataProviderRejectedDeclarationFailsWhenTheFormIsBuilt(): \Iterator {
$resolver = static fn(Context $context): array => [];
- yield 'a type with no option list' => [
+ yield 'a resolver on a type with no option list' => [
static function (PanelBuilder $p) use ($resolver): void {
- $p->text('item', 'Item')->optionsFor($resolver);
+ $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']);
},
- '/shows no options to resolve/',
+ '/of type "text" shows no options/',
];
yield 'alongside static options' => [
static function (PanelBuilder $p) use ($resolver): void {
- $p->select('item', 'Item')->options(['apple' => 'Apple'])->optionsFor($resolver);
+ $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'])->optionsFor($resolver);
+ $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 => [])->optionsFor($resolver);
+ $p->search('item', 'Item')->optionsFrom(static fn(string $query): array => [])->options($resolver);
},
'/declare only one/',
];
@@ -331,7 +366,7 @@ static function (PanelBuilder $p) use ($resolver): void {
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')->optionsFor($this->resolver($answer));
+ $field = $p->select('item', 'Item')->options($this->resolver($answer));
if ($declare instanceof \Closure) {
$declare($field);
From b270c2b69b0330376ba7e1707ac2dece02cfcd7f Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 20:24:42 +1000
Subject: [PATCH 3/7] [#106] Carried the run context through option resolution
and validation.
The engine memo now keys on the whole resolver input, so a second run against another directory or in update mode is not answered from the first one's cached options. 'Tui::validate()' takes the run context its sibling schema calls already take and passes it down, and a schema-side resolver that cannot answer empties the list rather than advertising the set some earlier context produced. An exception code that is not an integer coerces instead of failing the report. Reads an answer defensively in the demo and the documented example, since a supplied value is whatever arrived until it is validated.
---
docs/content/field-behaviour.mdx | 9 +-
playground/19-dynamic-options.php | 12 ++-
src/Builder/FieldBuilder.php | 14 +--
src/Engine/Engine.php | 33 +++---
src/Model/Field.php | 5 +-
src/Schema/OptionsResolver.php | 8 +-
src/Schema/SchemaValidator.php | 11 +-
src/Tui.php | 7 +-
tests/phpunit/Unit/DynamicOptionsTest.php | 122 +++++++++++++++++++++-
9 files changed, 185 insertions(+), 36 deletions(-)
diff --git a/docs/content/field-behaviour.mdx b/docs/content/field-behaviour.mdx
index 0ec4476d..1db09235 100644
--- a/docs/content/field-behaviour.mdx
+++ b/docs/content/field-behaviour.mdx
@@ -92,7 +92,14 @@ $catalog = [
];
$p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']);
-$p->select('item', 'Item')->options(fn(Context $c): array => $catalog[$c->answers['category']] ?? []);
+
+$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. 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.
diff --git a/playground/19-dynamic-options.php b/playground/19-dynamic-options.php
index 2eaa6df0..ecc36956 100644
--- a/playground/19-dynamic-options.php
+++ b/playground/19-dynamic-options.php
@@ -36,13 +36,21 @@
$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(static fn(Context $context): array => $catalog[$context->answers['category']] ?? []);
+ $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(static fn(Context $context): array => $catalog[$context->answers['category']] ?? []);
+ $p->select('basket', 'Basket')->multiple()->options($stock);
});
try {
diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php
index f9e06054..2094dc6e 100644
--- a/src/Builder/FieldBuilder.php
+++ b/src/Builder/FieldBuilder.php
@@ -1098,13 +1098,13 @@ public function heading(string $label): self {
* 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 conditions evaluate and 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.
+ * 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
diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php
index d0ef44a8..b205e965 100644
--- a/src/Engine/Engine.php
+++ b/src/Engine/Engine.php
@@ -41,14 +41,15 @@ class Engine {
/**
* What each field's dynamic option set was last resolved from, and to.
*
- * A settling pass that leaves the answers as they were leaves the options
- * they produced valid, so the resolver is called again only when what it
- * reads has actually changed. The rows are remembered alongside them 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,rows:list<\DrevOps\Tui\Model\Option>}>
+ * 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 = [];
@@ -203,6 +204,11 @@ protected function loadQueryOptions(array $fields, array $values, array $active)
*/
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 first one's memo.
+ $run = [$context->directory, $context->update, $context->version];
foreach ($fields as $field) {
if (!$field->optionsResolver instanceof \Closure) {
@@ -210,18 +216,18 @@ protected function resolveDynamicOptions(array $fields, array $values, array $ac
}
$memo = $this->optionMemo[$field->id] ?? NULL;
- if ($memo !== NULL && $memo['answers'] === $answers && $memo['rows'] === $field->options) {
+ if ($memo !== NULL && $memo['answers'] === $answers && $memo['run'] === $run && $memo['rows'] === $field->options) {
continue;
}
try {
- $field->options = Option::resolved(($field->optionsResolver)(new Context($context->directory, $answers, $context->update, $context->version)));
+ $field->options = Option::resolved(($field->optionsResolver)($resolved));
}
catch (\Throwable $throwable) {
throw $this->optionsError($field, $throwable);
}
- $this->optionMemo[$field->id] = ['answers' => $answers, 'rows' => $field->options];
+ $this->optionMemo[$field->id] = ['answers' => $answers, 'run' => $run, 'rows' => $field->options];
if ($supplied[$field->id] ?? FALSE) {
continue;
@@ -258,10 +264,13 @@ protected function suppliedInputs(array $sources): array {
* 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(),
- ]), $throwable->getCode(), $throwable);
+ ]), (int) $throwable->getCode(), $throwable);
}
/**
diff --git a/src/Model/Field.php b/src/Model/Field.php
index 05779924..041ac4e7 100644
--- a/src/Model/Field.php
+++ b/src/Model/Field.php
@@ -14,8 +14,9 @@
*
* The definition is immutable except for three resolved concerns written back:
* options a field declares indirectly - through a loader resolved once
- * (`$optionsLoader`) or a resolver re-run as the answers change
- * (`$optionsResolver`) - land in `$options`, a progress row tracks its live
+ * (`$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.
diff --git a/src/Schema/OptionsResolver.php b/src/Schema/OptionsResolver.php
index bcf2defd..f9f9813b 100644
--- a/src/Schema/OptionsResolver.php
+++ b/src/Schema/OptionsResolver.php
@@ -16,7 +16,7 @@
* real one exists. This settles them against a caller-provided context - the
* answers known so far, none for a plain schema - so the description carries
* what those answers actually allow. A resolver that cannot answer this
- * context leaves the list empty rather than failing the description, the way a
+ * context empties the list rather than failing the description, the way a
* closure default that cannot resolve stands down to NULL.
*
* @package DrevOps\Tui\Schema
@@ -40,7 +40,11 @@ public static function resolve(Field $field, Context $context): void {
$field->options = Option::resolved(($field->optionsResolver)($context));
}
catch (\Throwable) {
- // Nothing this context can be told; the empty list stands.
+ // 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/SchemaValidator.php b/src/Schema/SchemaValidator.php
index 1cafaf8b..e4e9b240 100644
--- a/src/Schema/SchemaValidator.php
+++ b/src/Schema/SchemaValidator.php
@@ -27,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()) {
}
/**
@@ -66,8 +69,10 @@ public function validate(array $answers): array {
}
// Options that follow the answers describe what this very answer set
- // allows, so they are settled against it before membership is checked.
- OptionsResolver::resolve($field, new Context('', $answers));
+ // 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) {
diff --git a/src/Tui.php b/src/Tui.php
index 1960cb14..692a78e5 100644
--- a/src/Tui.php
+++ b/src/Tui.php
@@ -600,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
index 22896b03..f4b077f1 100644
--- a/tests/phpunit/Unit/DynamicOptionsTest.php
+++ b/tests/phpunit/Unit/DynamicOptionsTest.php
@@ -261,13 +261,52 @@ public function testValidatorChecksMembershipAgainstTheResolvedSet(): void {
$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']));
- $item = $schema['prompts'][1];
- $this->assertSame(['carrot', 'potato', 'tomato'], array_column($item['options'], 'value'));
- $this->assertTrue($item['options_dynamic']);
- $this->assertFalse($schema['prompts'][0]['options_dynamic']);
+ $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 {
@@ -275,7 +314,7 @@ public function testSchemaFlagsOptionsThatFollowTheQuery(): void {
$p->search('veg', 'Vegetable')->optionsFrom(static fn(string $query): array => []);
});
- $this->assertTrue((new Tui($form))->schema()['prompts'][0]['options_dynamic']);
+ $this->assertTrue($this->schemaFlag((new Tui($form))->schema(), 'veg', 'options_dynamic'));
}
public function testAgentHelpEnumeratesTheResolvedValues(): void {
@@ -398,6 +437,79 @@ protected function resolver(?\Closure $answer = NULL): \Closure {
};
}
+ /**
+ * 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;
+ $this->assertIsArray($options);
+
+ $values = [];
+
+ foreach ($options as $option) {
+ $this->assertIsArray($option);
+ $value = $option['value'] ?? NULL;
+ $this->assertIsString($value);
+ $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;
+ $this->assertIsBool($flag);
+
+ 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;
+ $this->assertIsArray($prompts);
+
+ foreach ($prompts as $prompt) {
+ $this->assertIsArray($prompt);
+
+ if (($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.
*
From 0b1ad9d6f7ecc4aabc0fa3219f9153b3a4557aa7 Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 20:25:39 +1000
Subject: [PATCH 4/7] [#106] Narrowed the schema test helpers with type guards
rather than assertions.
The static analyser reads a generated schema as nested 'mixed', and 'assertIsArray()' does not narrow it without the PHPUnit extension, so the helpers guard with 'is_array()' and 'is_string()' and fail with a message naming the prompt.
---
tests/phpunit/Unit/DynamicOptionsTest.php | 28 +++++++++++++++--------
1 file changed, 19 insertions(+), 9 deletions(-)
diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php
index f4b077f1..eb4c7639 100644
--- a/tests/phpunit/Unit/DynamicOptionsTest.php
+++ b/tests/phpunit/Unit/DynamicOptionsTest.php
@@ -450,14 +450,20 @@ protected function resolver(?\Closure $answer = NULL): \Closure {
*/
protected function schemaOptions(array $schema, string $id): array {
$options = $this->schemaFor($schema, $id)['options'] ?? NULL;
- $this->assertIsArray($options);
+
+ if (!is_array($options)) {
+ $this->fail(sprintf('The prompt "%s" carries no options.', $id));
+ }
$values = [];
foreach ($options as $option) {
- $this->assertIsArray($option);
- $value = $option['value'] ?? NULL;
- $this->assertIsString($value);
+ $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;
}
@@ -479,7 +485,10 @@ protected function schemaOptions(array $schema, string $id): array {
*/
protected function schemaFlag(array $schema, string $id, string $key): bool {
$flag = $this->schemaFor($schema, $id)[$key] ?? NULL;
- $this->assertIsBool($flag);
+
+ if (!is_bool($flag)) {
+ $this->fail(sprintf('The prompt "%s" carries no "%s" flag.', $id, $key));
+ }
return $flag;
}
@@ -497,12 +506,13 @@ protected function schemaFlag(array $schema, string $id, string $key): bool {
*/
protected function schemaFor(array $schema, string $id): array {
$prompts = $schema['prompts'] ?? NULL;
- $this->assertIsArray($prompts);
- foreach ($prompts as $prompt) {
- $this->assertIsArray($prompt);
+ if (!is_array($prompts)) {
+ $this->fail('The schema carries no prompts.');
+ }
- if (($prompt['id'] ?? NULL) === $id) {
+ foreach ($prompts as $prompt) {
+ if (is_array($prompt) && ($prompt['id'] ?? NULL) === $id) {
return $prompt;
}
}
From cb6cdad8dd787bdcc0a2fbcbc155e8eea9390141 Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 20:35:18 +1000
Subject: [PATCH 5/7] [#106] Satisfied the coding standards for the option
guard and test names.
Renames the two test methods whose single-letter word ran into the next capital, collapses the option guard into one condition, and rewraps a comment inside the eighty-column limit.
---
src/Engine/Engine.php | 3 ++-
src/Model/Field.php | 6 ++----
tests/phpunit/Unit/DynamicOptionsTest.php | 4 ++--
3 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php
index b205e965..c85b3f93 100644
--- a/src/Engine/Engine.php
+++ b/src/Engine/Engine.php
@@ -207,7 +207,8 @@ protected function resolveDynamicOptions(array $fields, array $values, array $ac
$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 first one's memo.
+ // 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) {
diff --git a/src/Model/Field.php b/src/Model/Field.php
index 041ac4e7..70fc5677 100644
--- a/src/Model/Field.php
+++ b/src/Model/Field.php
@@ -283,10 +283,8 @@ public function __construct(
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) {
- if ($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->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()) {
diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php
index eb4c7639..de943907 100644
--- a/tests/phpunit/Unit/DynamicOptionsTest.php
+++ b/tests/phpunit/Unit/DynamicOptionsTest.php
@@ -162,7 +162,7 @@ public function testToggleFallsBackToTheFirstResolvedOption(): void {
$this->assertSame('carrot', $answers->value('item'));
}
- public function testSuggestKeepsAValueTheResolvedHintsDoNotHold(): void {
+ 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());
@@ -324,7 +324,7 @@ public function testAgentHelpEnumeratesTheResolvedValues(): void {
$this->assertStringNotContainsString('apple', $help);
}
- public function testReconcilingLeavesAValueThatIsNotAChoiceAlone(): void {
+ public function testReconcilingLeavesNonChoiceValueAlone(): void {
$field = new Field('name', 'Order name', '', FieldType::Text, 'Pear');
$this->assertSame('Pear', $field->reconcileValue('Pear'));
From 3185c385e6ae2f0fa8144537ee60d625d7e87c87 Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 20:41:10 +1000
Subject: [PATCH 6/7] [#106] Formatted the documentation pages to the project
style.
Emphasis uses underscores and the option table columns are padded to the widest cell, as the formatter writes them.
---
docs/content/progress.mdx | 2 +-
docs/content/widgets/select.mdx | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/docs/content/progress.mdx b/docs/content/progress.mdx
index 572e7ef6..30ee2eae 100644
--- a/docs/content/progress.mdx
+++ b/docs/content/progress.mdx
@@ -131,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()`. (For a list that follows the *answers* rather than the query, see [options from the answers](/field-behaviour#options-from-the-answers).)
+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 7e531018..9b1e72f2 100644
--- a/docs/content/widgets/select.mdx
+++ b/docs/content/widgets/select.mdx
@@ -30,11 +30,11 @@ Runnable scripts: [`playground/02-widgets-select.php`](https://github.com/drevop
## Options
-| Name | Description | Required | Default |
-| ------------ | -------------------------------------------------------------------------------- | -------- | ------------ |
+| 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` |
+| `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). To narrow the choices by an earlier answer, see [options from the answers](/field-behaviour#options-from-the-answers).
From 460dc87b5139c5f3c3b38bc9fdd3a34e2935ccd1 Mon Sep 17 00:00:00 2001
From: Alex Skrypnyk
Date: Tue, 28 Jul 2026 21:00:06 +1000
Subject: [PATCH 7/7] Addressed code review: stated the headless loader timing
and the supplied-value exception.
The loader paragraph now covers headless collection resolving it up front, and the summaries in the README, the playground index and the demo docblock say that a value supplied headlessly is reported rather than dropped.
---
README.md | 2 +-
docs/content/field-behaviour.mdx | 2 +-
playground/19-dynamic-options.php | 3 ++-
playground/README.md | 2 +-
4 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 33d0529b..5008e43f 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +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 | [options from the answers](https://phptui.dev/field-behaviour#options-from-the-answers) | [`19-dynamic-options`](playground/19-dynamic-options.php) |
+| 🎯 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/content/field-behaviour.mdx b/docs/content/field-behaviour.mdx
index 1db09235..4935578a 100644
--- a/docs/content/field-behaviour.mdx
+++ b/docs/content/field-behaviour.mdx
@@ -102,7 +102,7 @@ $p->select('item', 'Item')->options(function (Context $c) use ($catalog): array
});
```
-**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. 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.
+**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.
diff --git a/playground/19-dynamic-options.php b/playground/19-dynamic-options.php
index ecc36956..7664ca84 100644
--- a/playground/19-dynamic-options.php
+++ b/playground/19-dynamic-options.php
@@ -9,7 +9,8 @@
* 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.
+ * 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
diff --git a/playground/README.md b/playground/README.md
index 8d35062a..2ce3dab1 100644
--- a/playground/README.md
+++ b/playground/README.md
@@ -33,7 +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. | [`19-dynamic-options.php`](19-dynamic-options.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