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
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ It powers the [Vortex](https://www.vortextemplate.com) project installer, but kn
- ⚙️ [**Declared behaviour**](#field-behaviour) - validation, transforms and dynamic defaults as closures on the field; per-field handler classes remain as a fallback
- 📦 [**Self-describing answers**](#self-describing-answers) - each answer carries a snapshot of its question and its provenance; summaries need no form config
- 🎨 [**Themes**](#themes) - the whole visual representation (colours, glyphs, layout) is a theme class; ships with dark and light
- ⌨️ [**Key bindings**](#key-bindings) - remap navigation, edit, accept and cancel keys per widget type; ships a vim-style preset, and a bad binding fails loudly at build time
- ✨ [**Unicode and ASCII**](#display-modes) - glyphs follow the terminal locale and colour honours `NO_COLOR`; both can be forced on the form

## Installation
Expand Down Expand Up @@ -411,7 +412,7 @@ $p->pause('ready', 'Review the summary above');

## Panels and navigation

The interactive TUI is a full-screen panel browser: the root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field (or drills into a sub-panel), Esc goes back, `q` quits, and the mouse wheel scrolls long panels without moving the cursor. **Submit** and **Cancel** buttons live on the root panel - `->buttons(FALSE)` hides them, `->buttons(TRUE, 'Save', 'Discard')` relabels them.
The interactive TUI is a full-screen panel browser: the root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field (or drills into a sub-panel), Esc goes back, `q` quits, and the mouse wheel scrolls long panels without moving the cursor - all of these keys are configurable (see [Key bindings](#key-bindings)). **Submit** and **Cancel** buttons live on the root panel - `->buttons(FALSE)` hides them, `->buttons(TRUE, 'Save', 'Discard')` relabels them.

A form-level `->banner()` shows a start screen (with an optional version) before the panels, and `->clearOnExit(FALSE)` keeps the final frame on screen after the TUI exits.

Expand Down Expand Up @@ -606,9 +607,52 @@ Or register a short alias with `ThemeManager::register('ocean', OceanTheme::clas
<img src="docs/assets/theme-ocean.svg" width="100%" alt="Custom ocean theme with a banner">
</p>

## Key bindings

Navigation, edit, accept and cancel keys are configurable. A widget asks for a semantic **action** - `MoveUp`, `Accept`, `Toggle`, `NewLine` and so on - rather than a fixed key, and a **key map** binds each action to one or more keys. Set it on the form with `->keys(...)`, mirroring `->theme(...)`:

```php
$form = Form::create('My form')->keys('vim'); // built-in vim navigation (h/j/k/l)
```

Two presets ship: `default` (the bindings described in [Panels and navigation](#panels-and-navigation)) and `vim`, which adds `h`/`j`/`k`/`l` alongside the arrow keys - only where a letter is not typed input, so text and filter fields keep the arrows.

### Per-widget-type overrides

Bindings are layered by **scope**: a base layer shared by every widget, a navigation layer for the panel browser, and one layer per widget type that overrides the base only where it differs (Enter inserts a newline in a textarea, Space toggles a checkbox option). Retune individual bindings by passing overrides on top of a preset - each names a scope, an action and its keys:

```php
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Binding;
use DrevOps\Tui\Input\KeyName;
use DrevOps\Tui\Input\Scope;

$form = Form::create('My form')->keys('default', [
// Quit with x as well as q.
new Binding(Scope::navigation(), Action::Quit, 'x'),
// In the single-choice list, Tab accepts too.
new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter),
]);
```

A binding's keys accept a `KeyName` for a named key or a single-character string for a printable one. The panel and editor hints are drawn from the live bindings, so they always reflect the active keys.

### Presets and validation

A preset is a class listing its bindings. Subclass `DefaultKeyMap` to ship your own, name it directly with `->keys('\App\MyKeyMap')`, or register a short alias with `KeyMapManager::register('mine', MyKeyMap::class)` and then `->keys('mine')`.

Bindings are validated when the form is built, so a bad key map is caught at declaration time, not mid-session:

- a key bound to two different actions in the same scope is a conflict;
- a printable character bound in the base scope, or in a scope whose widget consumes typed input (text, search, checkbox), would be un-typeable and is rejected;
- an unknown preset name, or a character binding that is not exactly one character, is rejected.

See [`playground/8-key-bindings`](playground/8-key-bindings) for the default map, the vim preset and a custom override side by side.

## Playground

Runnable, self-contained examples are in [`playground/`](playground): a minimal form, a full "package scaffolder", a custom-theme demo, per-widget demos, nested panels with fix-ups, update-mode discovery, and a theme auto-detection demo. Each is independent - copy one as a starting point.
Runnable, self-contained examples are in [`playground/`](playground): a minimal form, a full "package scaffolder", a custom-theme demo, per-widget demos, nested panels with fix-ups, update-mode discovery, theme auto-detection, theme options, and a configurable key-bindings demo. Each is independent - copy one as a starting point.

The SVG demos on this page are generated from the playground scripts with `php docs/util/update-assets.php` (requires `asciinema`, `expect`, `node` and `npm`), which records each demo through a scripted terminal session and renders the recordings to SVG.

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Read it in three bands:

- **Left - what you provide.** A **Config** (assembled by the fluent `Form` builder into `Config` -> `Panel` -> `Field`) and, optionally, **Handlers** (classes that carry behaviour). Together these declare the questions and how each one behaves.
- **Middle - the Engine and its helpers.** The Engine drives collection, leaning on `InputResolver` (read a payload), `Discovery` (detect from the directory), `Deriver` + `Transform` (compute values), and `ConditionEvaluator` (decide what is shown).
- **Right - what comes out, and how it is shown.** `Answers` (plus a `SchemaGenerator` / `SchemaValidator` for agents and forms), and the **interactive TUI** - `PanelController` composing a `Theme` (resolved by name through `ThemeManager`), widgets, a `Navigator` and a `Terminal`.
- **Right - what comes out, and how it is shown.** `Answers` (plus a `SchemaGenerator` / `SchemaValidator` for agents and forms), and the **interactive TUI** - `PanelController` composing a `Theme` (resolved by name through `ThemeManager`), a `KeyMap` (resolved by preset through `KeyMapManager`), widgets, a `Navigator` and a `Terminal`.

## Step 1 - describe the questions

Expand Down Expand Up @@ -43,7 +43,7 @@ For interactive use, `PanelController::run()` seeds itself with the engine's res

![Interactive panel TUI](dataflow-tui.svg)

The theme instance comes from `ThemeManager` - a registry keyed by name ("dark", "light", or a registered custom class) that also detects the terminal's colour and Unicode capabilities and, when no theme is named, picks light or dark from the terminal background (an OSC 11 query answered by the `Terminal`, then `COLORFGBG`, then a dark default). Each turn the controller asks the **Theme** to compose a frame (the theme owns colours, glyphs and layout), computes the visible window with the `Navigator` and `Scroller`, and renders it to the `Terminal`. A key press is parsed by `KeyParser`; the controller either moves the cursor / drills into a sub-panel, or opens a widget to edit a field - widgets also render themselves through the theme, under a theme-composed underlined label header. Editing writes the new value back and marks it "edited". When the user finishes, it returns the same `Answers` object the headless path produces.
The theme instance comes from `ThemeManager` - a registry keyed by name ("dark", "light", or a registered custom class) that also detects the terminal's colour and Unicode capabilities and, when no theme is named, picks light or dark from the terminal background (an OSC 11 query answered by the `Terminal`, then `COLORFGBG`, then a dark default). Each turn the controller asks the **Theme** to compose a frame (the theme owns colours, glyphs and layout), computes the visible window with the `Navigator` and `Scroller`, and renders it to the `Terminal`. A key press is parsed by `KeyParser` into a `Key`, which a **KeyMap** resolves to a semantic action (move, accept, toggle, quit...) rather than a fixed key - the bindings behind each action are configurable per widget type, ship a vim preset alongside the default, and are validated when the form is built. Armed with the action, the controller either moves the cursor / drills into a sub-panel, or opens a widget to edit a field - the widget consults the same key map, and both render themselves through the theme, under a theme-composed underlined label header. Editing writes the new value back and marks it "edited". When the user finishes, it returns the same `Answers` object the headless path produces.

## Step 5 - apply the answers (the consumer's job)

Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/architecture.puml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ package "Interactive TUI" #FFEBEE {
[PanelController]
[ThemeManager]
[Theme]
[KeyMapManager]
[KeyMap]
[WidgetFactory]
interface WidgetInterface
[Navigator]
Expand Down Expand Up @@ -74,9 +76,15 @@ package "Interactive TUI" #FFEBEE {
[PanelController] --> [Config]
[ThemeManager] --> [Theme]
[PanelController] --> [Theme]
[Form] --> [KeyMapManager]
[KeyMapManager] --> [KeyMap]
[Config] --> [KeyMap]
[PanelController] --> [KeyMap]
[PanelController] --> [WidgetFactory]
[WidgetFactory] --> [KeyMap]
[WidgetFactory] --> WidgetInterface
WidgetInterface --> [Theme]
WidgetInterface --> [KeyMap]
[PanelController] --> [Navigator]
[PanelController] --> [Terminal]
[PanelController] --> [KeyParser]
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/architecture.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion docs/architecture/dataflow-tui.puml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@startuml
' drevops/tui - data flow for the interactive panel TUI.
' Traced from src/Render/PanelController.php (run -> frame/handle) and src/Theme/.
' Traced from src/Render/PanelController.php (run -> frame/handle), src/Theme/
' and src/Input/ (KeyMap resolves a key press to a semantic action).
' Regenerate every SVG with: plantuml -tsvg docs/architecture/*.puml
!theme plain
skinparam backgroundColor white
Expand All @@ -14,6 +15,7 @@ participant "Terminal" as Term
participant "Theme" as Theme
participant "Navigator\n+ Scroller" as Nav
participant "KeyParser" as KP
participant "KeyMap\n(scoped)" as KM
participant "Widget\n(via factory)" as W

PC -> Term: setup() raw mode, alt screen
Expand All @@ -38,8 +40,12 @@ loop until done

alt editing a field
PC -> W: handle(key)
W -> KM: matches(key, action)?
KM --> W: bound action (accept, move, toggle...)
W --> PC: value, complete or cancel
else navigating
PC -> KM: matches(key, action)?
KM --> PC: bound action (move, activate, back, quit)
note right of PC: move cursor, drill panel, or open editor
end
end
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/dataflow-tui.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions playground/2-custom-theme/OceanTheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use DrevOps\Tui\Answers\Answers;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\Panel;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\ScopedKeyMap;
use DrevOps\Tui\Render\Navigator;
use DrevOps\Tui\Theme\DefaultTheme;

Expand Down Expand Up @@ -241,10 +243,16 @@ public function renderBreadcrumbLine(Navigator $navigator): string {
* {@inheritdoc}
*/
#[\Override]
public function renderStatusLine(): string {
public function renderStatusLine(ScopedKeyMap $nav): string {
$sep = ' ' . $this->dot() . ' ';

return $this->footer($this->arrowUp() . $this->arrowDown() . ' move' . $sep . $this->enter() . ' choose' . $sep . 'esc back');
$fragments = array_filter([
$this->keysHint($nav, 'move', Action::MoveUp, Action::MoveDown),
$this->keysHint($nav, 'choose', Action::Activate),
$this->keysHint($nav, 'back', Action::Back),
]);

return $this->footer(implode($sep, $fragments));
}

/**
Expand Down
84 changes: 84 additions & 0 deletions playground/8-key-bindings/run.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

/**
* @file
* Configurable key bindings: the default map, vim preset, or a custom override.
*
* Selected with --keys; run it interactively to feel the difference.
*
* Usage:
* php 8-key-bindings/run.php # default bindings (arrow keys)
* php 8-key-bindings/run.php --keys=vim # h/j/k/l navigation
* php 8-key-bindings/run.php --keys=custom # a per-widget-type override
* php 8-key-bindings/run.php --prompts='{"name":"Ada"}'
*/

declare(strict_types=1);

use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Engine\EngineException;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Binding;
use DrevOps\Tui\Input\KeyName;
use DrevOps\Tui\Input\Scope;
use DrevOps\Tui\Tui;

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

$options = getopt('', ['keys::', 'prompts::']);
$prompts = array_key_exists('prompts', $options) && is_string($options['prompts']) ? $options['prompts'] : '';
$which = array_key_exists('keys', $options) && is_string($options['keys']) ? $options['keys'] : 'default';

if (!in_array($which, ['default', 'vim', 'custom'], TRUE)) {
fwrite(STDERR, sprintf('Unknown --keys value "%s"; use default, vim or custom.%s', $which, PHP_EOL));
exit(1);
}

$form = Form::create('Key bindings demo')
->panel('profile', 'Profile', function (PanelBuilder $p): void {
$p->text('name', 'Name')->default('Ada');
$p->select('role', 'Role')->default('dev')->options([
'dev' => 'Developer',
'ops' => 'Operations',
'design' => 'Designer',
]);
$p->confirm('newsletter', 'Subscribe?')->default(TRUE);
$p->multiselect('langs', 'Languages')->options([
'php' => 'PHP',
'js' => 'JavaScript',
'go' => 'Go',
]);
});

// Three ways to set the bindings, selected with --keys. The hints at the foot
// of the panel and editor follow whatever is bound, so they always tell the
// truth about the active keys.
if ($which === 'vim') {
// The built-in vim preset: h/j/k/l navigate alongside the arrows. Letters are
// added only where they are not typed input, so text and filter fields keep
// the arrow keys.
$form->keys('vim');
}
elseif ($which === 'custom') {
// Start from the default preset and retune two bindings. Each override names
// a scope, an action and its keys; a conflicting or un-typeable binding
// throws when the form is built, not mid-session.
$form->keys('default', [
// Quit with x as well as q.
new Binding(Scope::navigation(), Action::Quit, 'x'),
// In the single-choice list, Tab accepts too (Enter still does).
new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter),
]);
}
// The default preset applies when --keys is default (no ->keys() call needed).
try {
$answers = (new Tui($form))->run($prompts, '1.0.0');
}
catch (EngineException $exception) {
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
exit(1);
}

echo $answers->toSummary() . PHP_EOL;
37 changes: 37 additions & 0 deletions playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ composer install
php playground/6-theme-detect/run.php --theme=light # force light
```

- **[`7-theme-options/`](7-theme-options)** - the display options (`spacing`,
`border`) and a custom `accent` option declared by a registered theme, all set
as plain strings in one array. Each value is validated, so a typo throws.

```bash
php playground/7-theme-options/run.php
```

- **[`8-key-bindings/`](8-key-bindings)** - the same form driven with three key
maps, selected with `--keys`: the default bindings, the built-in `vim` preset
(h/j/k/l), and a custom per-widget-type override. The panel and editor hints
follow whatever is bound.

```bash
php playground/8-key-bindings/run.php # default (arrow keys)
php playground/8-key-bindings/run.php --keys=vim # h/j/k/l navigation
php playground/8-key-bindings/run.php --keys=custom
```

## How a form picks a theme

Set it on the builder with `->theme(...)`, lowest friction first:
Expand All @@ -100,3 +119,21 @@ Set it on the builder with `->theme(...)`, lowest friction first:
picks `dark` or `light` from the terminal background (an OSC 11 query, then
`COLORFGBG`, then a dark default). Forcing a built-in opts out. This is what
`6-theme-detect` demonstrates.

## How a form sets key bindings

Set them on the builder with `->keys(...)`, mirroring `->theme(...)`:

1. **A preset name** - `->keys('vim')` for the built-in vim navigation, or a name
registered with `KeyMapManager::register('name', MyKeyMap::class)`.
2. **A preset class** - `->keys('\Your\KeyMapClass')`, instantiated directly with
no registration.
3. **Overrides** - `->keys('default', [new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab)])`
retunes individual bindings on top of a preset. A binding names a scope (the
base, navigation, or a widget type), an action and its keys.
4. **Defaults** - leave it unset for the built-in bindings. This is what most
examples do.

Conflicting or un-typeable bindings throw when the form is built, so a bad key map
is caught at declaration time, not mid-session. This is what `8-key-bindings`
demonstrates.
38 changes: 38 additions & 0 deletions src/Builder/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use DrevOps\Tui\Config\Fixup;
use DrevOps\Tui\Config\Option;
use DrevOps\Tui\Config\Panel;
use DrevOps\Tui\Input\KeyMapManager;

/**
* A fluent builder declaring a form: its panels, fields and TUI options.
Expand All @@ -30,6 +31,18 @@ final class Form {
*/
protected array $themeOptions = [];

/**
* The key-map preset name or class (empty for the default).
*/
protected string $keymap = '';

/**
* Bindings overriding the preset, applied on top of it.
*
* @var list<\DrevOps\Tui\Input\Binding>
*/
protected array $keymapOverrides = [];

/**
* The start banner (logo).
*/
Expand Down Expand Up @@ -131,6 +144,30 @@ public function theme(string $theme, array $options = []): self {
return $this;
}

/**
* Set the key-binding preset and optional overrides.
*
* The preset names the base bindings ("default", "vim", a registered name, or
* a preset class); the overrides retune individual bindings on top of it,
* each a {@see \DrevOps\Tui\Input\Binding} naming a scope, an action and its
* keys. Conflicting, un-typeable or malformed bindings throw when the form is
* built, not mid-session.
*
* @param string $preset
* The preset name or class. Empty selects the default preset.
* @param list<\DrevOps\Tui\Input\Binding> $overrides
* Bindings applied on top of the preset.
*
* @return $this
* The builder.
*/
public function keys(string $preset = '', array $overrides = []): self {
$this->keymap = $preset;
$this->keymapOverrides = $overrides;

return $this;
}

/**
* Set the start banner.
*
Expand Down Expand Up @@ -287,6 +324,7 @@ public function build(): Config {
$this->unicode,
$this->envPrefix,
$this->themeOptions,
KeyMapManager::create($this->keymap, $this->keymapOverrides),
);

$this->assertUniqueFieldIds($config);
Expand Down
Loading
Loading