Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ $p->number('port', 'HTTP port')->default(8080);
</tr>
</table>

Declare optional `min`, `max` and `step` to bound the value and enable keyboard adjustment. Up and Down then adjust the value by the step (defaulting to `1`), clamped to the range, and an entry outside the range is rejected inline with a clear message. The bounds are enforced headlessly too - a `--prompts` or environment value outside the range is rejected - and are reflected in the JSON schema as `min`, `max` and `step` on the prompt. With none declared the field stays a plain integer entry with the arrow keys inert.

```php
$p->number('port', 'HTTP port')->min(1)->max(65535)->step(1)->default(8080);
```

### Textarea

Multi-line text input: Enter inserts a newline, Up/Down move between lines keeping the column, Tab accepts.
Expand Down
88 changes: 88 additions & 0 deletions src/Builder/FieldBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace DrevOps\Tui\Builder;

use DrevOps\Tui\Condition\ConditionInterface;
use DrevOps\Tui\Config\ConfigException;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\NumberBounds;
use DrevOps\Tui\Config\Option;
use DrevOps\Tui\Derive\Derive;
use DrevOps\Tui\Discovery\DiscoverInterface;
Expand Down Expand Up @@ -90,6 +92,21 @@ final class FieldBuilder {
*/
protected bool $externalEditor = FALSE;

/**
* The number field's inclusive minimum, when declared.
*/
protected ?int $min = NULL;

/**
* The number field's inclusive maximum, when declared.
*/
protected ?int $max = NULL;

/**
* The number field's Up/Down increment, when declared.
*/
protected ?int $step = NULL;

/**
* Construct a field builder.
*
Expand Down Expand Up @@ -213,6 +230,51 @@ public function externalEditor(bool $enabled = TRUE): self {
return $this;
}

/**
* Number only: set the inclusive minimum accepted value.
*
* @param int $min
* The minimum.
*
* @return $this
* The builder.
*/
public function min(int $min): self {
$this->min = $min;

return $this;
}

/**
* Number only: set the inclusive maximum accepted value.
*
* @param int $max
* The maximum.
*
* @return $this
* The builder.
*/
public function max(int $max): self {
$this->max = $max;

return $this;
}

/**
* Number only: set the Up/Down increment.
*
* @param int $step
* The step; must be positive.
*
* @return $this
* The builder.
*/
public function step(int $step): self {
$this->step = $step;

return $this;
}

/**
* Set the conditional-visibility rule.
*
Expand Down Expand Up @@ -351,6 +413,7 @@ public function build(): Field {
$this->revealable,
$this->confirm,
$this->externalEditor,
$this->buildBounds(),
);
}

Expand All @@ -376,6 +439,31 @@ protected function resolveDefault(): mixed {
return $this->defaultFor($this->fieldType);
}

/**
* Assemble the number bounds from the declared min/max/step, if any.
*
* @return \DrevOps\Tui\Config\NumberBounds|null
* The bounds, or NULL when none were declared.
*
* @throws \DrevOps\Tui\Config\ConfigException
* When min exceeds max, or the step is not positive.
*/
protected function buildBounds(): ?NumberBounds {
if ($this->min === NULL && $this->max === NULL && $this->step === NULL) {
return NULL;
}

if ($this->min !== NULL && $this->max !== NULL && $this->min > $this->max) {
throw new ConfigException(sprintf('Field "%s" declares min %d greater than max %d.', $this->id, $this->min, $this->max));
}

if ($this->step !== NULL && $this->step < 1) {
throw new ConfigException(sprintf('Field "%s" declares a non-positive step %d.', $this->id, $this->step));
}

return new NumberBounds($this->min, $this->max, $this->step);
}

/**
* The engine default for a field type when none is declared.
*
Expand Down
4 changes: 4 additions & 0 deletions src/Config/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
* @param bool $externalEditor
* Whether the field may hand off to the user's $EDITOR for composing its
* value. Honoured by the textarea widget; ignored by other types.
* @param \DrevOps\Tui\Config\NumberBounds|null $bounds
* Number only: optional min/max/step bounds; NULL for a plain integer
* entry with no range or keyboard stepping.
*/
public function __construct(
public string $id,
Expand All @@ -76,6 +79,7 @@ public function __construct(
public bool $revealable = FALSE,
public bool $confirm = FALSE,
public bool $externalEditor = FALSE,
public ?NumberBounds $bounds = NULL,
) {
}

Expand Down
132 changes: 132 additions & 0 deletions src/Config/NumberBounds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare(strict_types=1);

namespace DrevOps\Tui\Config;

/**
* Optional integer bounds and step for a number field.
*
* Any of the three may be unset (NULL): an unset min or max leaves that side
* open, and an unset step increments by one. The range arithmetic and the
* human range phrase live here once, so the interactive widget, the headless
* engine and the answer-set validator all agree.
*
* @package DrevOps\Tui\Config
*/
final readonly class NumberBounds {

/**
* Construct number bounds.
*
* @param int|null $min
* The inclusive minimum, or NULL for an open lower bound.
* @param int|null $max
* The inclusive maximum, or NULL for an open upper bound.
* @param int|null $step
* The Up/Down increment, or NULL to step by one.
*/
public function __construct(
public ?int $min = NULL,
public ?int $max = NULL,
public ?int $step = NULL,
) {
}

/**
* Whether a value is within the bounds.
*
* @param int|float $value
* The value.
*
* @return bool
* TRUE when the value is within both declared bounds.
*/
public function contains(int|float $value): bool {
if ($this->min !== NULL && $value < $this->min) {
return FALSE;
}

return $this->max === NULL || $value <= $this->max;
}

/**
* The range phrase for a value that violates the bounds, else NULL.
*
* The shared primitive behind every enforcement surface: it narrows the value
* to a number (a non-numeric value is not this object's concern and passes),
* then returns the human range phrase when the value falls outside the range.
*
* @param mixed $value
* The value to test.
*
* @return string|null
* The range phrase (e.g. "between 1 and 10") when out of range, else NULL.
*/
public function violation(mixed $value): ?string {
if (!is_int($value) && !is_float($value)) {
return NULL;
}

return $this->contains($value) ? NULL : $this->describe();
}

/**
* The human range phrase, e.g. "between 1 and 10" or "at least 1".
*
* @return string
* The phrase, or an empty string when neither bound is declared.
*/
public function describe(): string {
if ($this->min !== NULL && $this->max !== NULL) {
return sprintf('between %d and %d', $this->min, $this->max);
}

if ($this->min !== NULL) {
return sprintf('at least %d', $this->min);
}

if ($this->max !== NULL) {
return sprintf('at most %d', $this->max);
}

return '';
}

/**
* Clamp a value into the declared bounds.
*
* @param int $value
* The value.
*
* @return int
* The value, moved onto the nearest bound it exceeds.
*/
public function clamp(int $value): int {
if ($this->min !== NULL && $value < $this->min) {
return $this->min;
}

if ($this->max !== NULL && $value > $this->max) {
return $this->max;
}

return $value;
}

/**
* Step a value by the step in a direction, clamped to the bounds.
*
* @param int $value
* The current value.
* @param int $direction
* Either 1 to increment or -1 to decrement.
*
* @return int
* The stepped, clamped value.
*/
public function step(int $value, int $direction): int {
return $this->clamp($value + $direction * ($this->step ?? 1));
}

}
5 changes: 5 additions & 0 deletions src/Engine/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ protected function resolveInitial(Field $field, array $inputs, Context $context)
* An error message, or NULL when the value is valid.
*/
protected function validateValue(Field $field, mixed $value): ?string {
$violation = $field->bounds?->violation($value);
if ($violation !== NULL) {
return sprintf('must be %s.', $violation);
}

$validator = $field->validate ?? $this->handlers->validator($field->id);
if (!$validator instanceof \Closure) {
return NULL;
Expand Down
32 changes: 31 additions & 1 deletion src/Schema/AgentHelp.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace DrevOps\Tui\Schema;

use DrevOps\Tui\Config\Config;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\NumberBounds;

/**
* Produces instructions for driving the form non-interactively.
Expand Down Expand Up @@ -48,10 +50,38 @@ public function generate(): string {

foreach ($this->config->fields() as $field) {
$required = $field->required ? ' (required)' : '';
$lines[] = sprintf(' %s [%s]%s - %s', $field->id, $field->type->value, $required, $field->label);
$lines[] = sprintf(' %s [%s]%s - %s%s', $field->id, $field->type->value, $required, $field->label, $this->rangeNote($field));
}

return implode("\n", $lines);
}

/**
* A compact range annotation for a bounded number field.
*
* @param \DrevOps\Tui\Config\Field $field
* The field.
*
* @return string
* The annotation (e.g. " (between 1 and 10, step 2)"), or an empty string.
*/
protected function rangeNote(Field $field): string {
if (!$field->bounds instanceof NumberBounds) {
return '';
}

$parts = [];

$described = $field->bounds->describe();
if ($described !== '') {
$parts[] = $described;
}

if ($field->bounds->step !== NULL) {
$parts[] = sprintf('step %d', $field->bounds->step);
}

return sprintf(' (%s)', implode(', ', $parts));
}

}
3 changes: 3 additions & 0 deletions src/Schema/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public function generate(): array {
'options' => $this->options($field),
'default' => $field->default instanceof \Closure ? NULL : $field->default,
'required' => $field->required,
'min' => $field->bounds?->min,
'max' => $field->bounds?->max,
'step' => $field->bounds?->step,
'when' => $field->when?->toArray(),
'derive' => $field->derive?->toArray(),
'discover' => $field->discover instanceof DiscoverInterface ? $field->discover->toArray() : NULL,
Expand Down
22 changes: 22 additions & 0 deletions src/Schema/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,31 @@ protected function validateValue(Field $field, mixed $value): ?string {
return sprintf('Question "%s" is required.', $field->id);
}

$bounds_error = $this->checkBounds($field, $value);
if ($bounds_error !== NULL) {
return $bounds_error;
}

return $this->checkOptions($field, $value);
}

/**
* Check a number value against its declared bounds.
*
* @param \DrevOps\Tui\Config\Field $field
* The field.
* @param mixed $value
* The value.
*
* @return string|null
* An error, or NULL when in range (or when the field declares no bounds).
*/
protected function checkBounds(Field $field, mixed $value): ?string {
$violation = $field->bounds?->violation($value);

return $violation === NULL ? NULL : sprintf('Question "%s" must be %s.', $field->id, $violation);
}

/**
* Whether the value matches the field type.
*
Expand Down
Loading
Loading