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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ It powers the [Vortex](https://www.vortextemplate.com) project installer, but kn
## Features

- 🧭 [**Panel TUI**](#panels-and-navigation) - a full-screen, scrollable, keyboard-driven form: panels hold fields, sub-panels drill in to any depth
- 🧩 [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `confirm`, `pause`
- 🧩 [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `confirm`, `toggle`, `pause`
- 🏗️ [**Builder-driven**](#configuration) - panels and fields are declared in PHP with a fluent builder; the common cases need no code
- 🤖 [**Interactive or headless**](#headless-collection) - drive the panel TUI by keyboard, or collect answers non-interactively from a JSON payload and environment variables (and emit a JSON schema for agents and forms)
- 🔗 [**Derived values**](#derived-values) - compute one field from others with [str2name](https://github.com/AlexSkrypnyk/str2name) transforms; chains settle to a fixpoint
Expand Down Expand Up @@ -72,7 +72,7 @@ It also exposes `schema()`, `agentHelp()` and `validate()`, and - when you want

## Widgets

Eleven widget types cover text entry, choices and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.
Twelve widget types cover text entry, choices and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see [`playground/3-widgets/`](playground/3-widgets)). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.

<p align="center">
<img src="docs/assets/widgets.svg" width="100%" alt="All widgets, one after another">
Expand Down Expand Up @@ -357,6 +357,32 @@ $p->confirm('cdn', 'Serve via CDN?')->default(TRUE);
</tr>
</table>

### Toggle

An inline switch between two labeled values. Arrows or Space flip, the first letter of each label sets the choice directly, Enter accepts. Pass `default` (an option value) to start on the other value.

```php
$p->toggle('telemetry', 'Telemetry')->options(['enabled' => 'Enabled', 'disabled' => 'Disabled'])->default('enabled');
```

<table>
<tr>
<td></td>
<td align="center"><strong>ANSI</strong></td>
<td align="center"><strong>No ANSI</strong></td>
</tr>
<tr>
<td align="right"><strong>Unicode</strong></td>
<td><img src="docs/assets/widget-toggle.svg" alt="Toggle: Unicode + ANSI"></td>
<td><img src="docs/assets/widget-toggle-no-ansi.svg" alt="Toggle: Unicode + No ANSI"></td>
</tr>
<tr>
<td align="right"><strong>ASCII</strong></td>
<td><img src="docs/assets/widget-toggle-ascii.svg" alt="Toggle: ASCII + ANSI"></td>
<td><img src="docs/assets/widget-toggle-ascii-no-ansi.svg" alt="Toggle: ASCII + No ANSI"></td>
</tr>
</table>

### Pause

An acknowledgement gate: Enter (or Space) accepts `TRUE`. Headless runs auto-acknowledge it, so it never blocks automation.
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"require": {
"php": ">=8.3",
"ext-mbstring": "*",
"alexskrypnyk/str2name": "^1.5.0",
"symfony/console": "^7.4.14"
},
Expand Down
1 change: 1 addition & 0 deletions docs/assets/widget-toggle-ascii-no-ansi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/assets/widget-toggle-ascii.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/assets/widget-toggle-no-ansi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/assets/widget-toggle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/assets/widgets-ascii-no-ansi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/assets/widgets-ascii.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/assets/widgets-no-ansi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/assets/widgets.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions docs/util/update-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ function widgetInteractions(): array {
type_text "n"
wait_and_enter
}
EXPECT,
'toggle' => <<<'EXPECT'
# Toggle: select the second value by its first letter, accept.
expect "Toggle widget" {
pause 1000
type_text "d"
wait_and_enter
}
EXPECT,
'pause' => <<<'EXPECT'
# Pause: acknowledge.
Expand Down Expand Up @@ -489,6 +497,7 @@ function getJobs(string $project_dir): array {
'search' => 10,
'multisearch' => 10,
'confirm' => 6,
'toggle' => 6,
'pause' => 6,
];

Expand Down
5 changes: 5 additions & 0 deletions playground/3-widgets/show.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use DrevOps\Tui\Widget\SuggestWidget;
use DrevOps\Tui\Widget\TextareaWidget;
use DrevOps\Tui\Widget\TextWidget;
use DrevOps\Tui\Widget\ToggleWidget;
use DrevOps\Tui\Widget\WidgetInterface;

require __DIR__ . '/../../vendor/autoload.php';
Expand Down Expand Up @@ -75,6 +76,10 @@
'memcached' => 'Memcached',
], ['redis']),
'Confirm' => static fn(): WidgetInterface => new ConfirmWidget(TRUE),
'Toggle' => static fn(): WidgetInterface => new ToggleWidget([
'enabled' => 'Enabled',
'disabled' => 'Disabled',
], 'enabled'),
'Pause' => static fn(): WidgetInterface => new PauseWidget(),
];

Expand Down
51 changes: 51 additions & 0 deletions playground/3-widgets/widget-toggle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/**
* @file
* Interactive toggle widget: Left/Right or Space flip, first letter selects.
*
* Usage:
* php 3-widgets/widget-toggle.php
* php 3-widgets/widget-toggle.php --no-unicode # textual glyphs
* php 3-widgets/widget-toggle.php --no-ansi # no colour.
*/

declare(strict_types=1);

use DrevOps\Tui\Input\KeyParser;
use DrevOps\Tui\Render\Terminal;
use DrevOps\Tui\Theme\DefaultTheme;
use DrevOps\Tui\Widget\ToggleWidget;

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

// Forcing the mode with a flag shows the textual (ASCII) or no-colour
// rendering without changing the terminal locale.
$opts = getopt('', ['no-unicode', 'no-ansi']);
$theme = new DefaultTheme(76, ['color' => !isset($opts['no-ansi']), 'unicode' => !isset($opts['no-unicode'])]);

$widget = new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled');

$terminal = new Terminal();
$parser = new KeyParser();
$terminal->setup();

try {
while (!$widget->isComplete() && !$widget->isCancelled()) {
$terminal->render(implode("\n", [
Comment thread
coderabbitai[bot] marked this conversation as resolved.
$theme->renderEditorHeader('Toggle widget'),
$theme->renderHintLine('edit', 'Enter accept', 'Esc cancel'),
'',
$widget->view($theme),
]));

foreach ($parser->parse($terminal->read()) as $key) {
$widget->handle($key);
}
}
}
finally {
$terminal->restore();
}

echo 'Toggle: ' . ($widget->isCancelled() ? '(cancelled)' : (string) json_encode($widget->value())) . PHP_EOL;
2 changes: 2 additions & 0 deletions playground/3-widgets/widgets.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use DrevOps\Tui\Widget\SuggestWidget;
use DrevOps\Tui\Widget\TextareaWidget;
use DrevOps\Tui\Widget\TextWidget;
use DrevOps\Tui\Widget\ToggleWidget;
use DrevOps\Tui\Widget\WidgetInterface;

require __DIR__ . '/../../vendor/autoload.php';
Expand Down Expand Up @@ -79,4 +80,5 @@
], 'london'), 'Search');
$interact(new MultiSearchWidget(['redis' => 'Redis', 'solr' => 'Solr', 'clamav' => 'ClamAV', 'memcached' => 'Memcached'], ['redis']), 'MultiSearch');
$interact(new ConfirmWidget(TRUE), 'Confirm');
$interact(new ToggleWidget(['enabled' => 'Enabled', 'disabled' => 'Disabled'], 'enabled'), 'Toggle');
$interact(new PauseWidget(), 'Pause', ['Enter continue', 'Esc cancel']);
24 changes: 23 additions & 1 deletion src/Builder/FieldBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ public function build(): Field {
$this->label,
$this->description,
$this->fieldType,
$this->hasDefault ? $this->default : $this->defaultFor($this->fieldType),
$this->resolveDefault(),
$this->options,
$this->required,
$this->when,
Expand All @@ -354,6 +354,28 @@ public function build(): Field {
);
}

/**
* The effective default: the declared one, or the type's implicit default.
*
* @return mixed
* The default value.
*/
protected function resolveDefault(): mixed {
if ($this->hasDefault) {
return $this->default;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A toggle is always in one of its two states, so it defaults to the first
// option's value rather than an empty value that would not match either.
// The value is read off the option, not its array key, so a numeric-string
// value like "0" is not coerced to an int.
if ($this->fieldType === FieldType::Toggle && $this->options !== []) {
return reset($this->options)->value;
}

return $this->defaultFor($this->fieldType);
}

/**
* The engine default for a field type when none is declared.
*
Expand Down
34 changes: 34 additions & 0 deletions src/Builder/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use DrevOps\Tui\Config\Config;
use DrevOps\Tui\Config\ConfigException;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\Fixup;
use DrevOps\Tui\Config\Option;
use DrevOps\Tui\Config\Panel;

/**
Expand Down Expand Up @@ -288,6 +290,7 @@ public function build(): Config {
);

$this->assertUniqueFieldIds($config);
$this->assertToggleOptions($config);

return $config;
}
Expand All @@ -310,4 +313,35 @@ protected function assertUniqueFieldIds(Config $config): void {
}
}

/**
* Assert that every toggle field declares exactly two options.
*
* @param \DrevOps\Tui\Config\Config $config
* The built config.
*/
protected function assertToggleOptions(Config $config): void {
foreach ($config->fields() as $field) {
if ($field->type !== FieldType::Toggle) {
continue;
}

if (count($field->options) !== 2) {
throw new ConfigException(sprintf('Toggle field "%s" must have exactly two options, %d given.', $field->id, count($field->options)));
}

// A dynamic default is a closure resolved at runtime; every literal
// default - whatever its type - must be one of the two option values,
// otherwise the widget would silently coerce it and select the first.
if ($field->default instanceof \Closure) {
continue;
}

$values = array_map(static fn(Option $option): string => $option->value, $field->options);

if (!is_string($field->default) || !in_array($field->default, $values, TRUE)) {
throw new ConfigException(sprintf('Toggle field "%s" default must be one of: %s.', $field->id, implode(', ', $values)));
}
}
}

}
15 changes: 15 additions & 0 deletions src/Builder/PanelBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ public function confirm(string $id, string $label = ''): FieldBuilder {
return $this->field($id, $label, FieldType::Confirm);
}

/**
* Add a toggle field (an inline switch between two labeled values).
*
* @param string $id
* The field id.
* @param string $label
* The label (defaults to the id).
*
* @return \DrevOps\Tui\Builder\FieldBuilder
* The field builder.
*/
public function toggle(string $id, string $label = ''): FieldBuilder {
return $this->field($id, $label, FieldType::Toggle);
}

/**
* Add a suggest field.
*
Expand Down
1 change: 1 addition & 0 deletions src/Config/FieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum FieldType: string {
case Select = 'select';
case MultiSelect = 'multiselect';
case Confirm = 'confirm';
case Toggle = 'toggle';
case Suggest = 'suggest';
case Number = 'number';
case Textarea = 'textarea';
Expand Down
8 changes: 6 additions & 2 deletions src/Schema/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use DrevOps\Tui\Config\Config;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Config\Option;

/**
* Validates an answer set against the configuration.
Expand Down Expand Up @@ -158,9 +159,12 @@ protected function checkOptions(Field $field, mixed $value): ?string {
return NULL;
}

$valid = array_keys($field->options);
// Option values are read off each option, not from the array keys, so a
// numeric-string value like "0" is compared as a string rather than an
// int coerced by the array key.
$valid = array_map(static fn(Option $option): string => $option->value, $field->options);

if (in_array($field->type, [FieldType::Select, FieldType::Search], TRUE) && is_string($value) && !in_array($value, $valid, TRUE)) {
if (in_array($field->type, [FieldType::Select, FieldType::Search, FieldType::Toggle], TRUE) && is_string($value) && !in_array($value, $valid, TRUE)) {
return sprintf('Question "%s" must be one of: %s.', $field->id, implode(', ', $valid));
}

Expand Down
Loading
Loading