Skip to content
Open
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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
- 🧭 [**Panel TUI**](#panels-and-navigation) - a full-screen, scrollable, keyboard-driven form: panels hold fields, sub-panels drill in to any depth, with a contextual key-hint footer and a `?` help overlay
- 🧩 [**Widgets**](#widgets) - `text`, `number`, `textarea`, `password`, `select`, `multiselect`, `suggest`, `search`, `multisearch`, `filepicker`, `multifilepicker`, `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)
Expand Down Expand Up @@ -530,6 +530,8 @@ $p->pause('ready', 'Review the summary above');

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 **contextual help footer** runs along the foot of every screen, listing exactly the keys valid right now and updating as focus moves between the hub and each editor. The hub shows move, select, back, quit and `?`; each widget contributes its own bindings on top of accept/cancel - a select adds move, a multiselect adds toggle and select-all/none, a bounded number adds the step keys, a textarea adds newline and the external-editor handoff, a revealable password adds the reveal toggle. Pressing `?` at the hub opens a fuller overlay that lists the hub and every widget type the form uses, so the form teaches its own less-common widgets. The footer follows the active theme and key map - it degrades to ASCII glyphs and always reflects remapped keys - and `->footer(FALSE)` turns it off form-wide.

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.

### Nested panels
Expand Down
10 changes: 3 additions & 7 deletions playground/2-custom-theme/OceanTheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use DrevOps\Tui\Answers\Answers;
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\Panel;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Hint;
use DrevOps\Tui\Input\ScopedKeyMap;
use DrevOps\Tui\Render\Navigator;
use DrevOps\Tui\Theme\DefaultTheme;
Expand Down Expand Up @@ -243,14 +243,10 @@ public function renderBreadcrumbLine(Navigator $navigator): string {
* {@inheritdoc}
*/
#[\Override]
public function renderStatusLine(ScopedKeyMap $nav): string {
public function renderHints(ScopedKeyMap $keys, Hint ...$hints): string {
$sep = ' ' . $this->dot() . ' ';

$fragments = array_filter([
$this->keysHint($nav, 'move', Action::MoveUp, Action::MoveDown),
$this->keysHint($nav, 'choose', Action::Activate),
$this->keysHint($nav, 'back', Action::Back),
]);
$fragments = array_filter(array_map(fn(Hint $hint): string => $this->keysHint($keys, $hint->label, ...$hint->actions), $hints));

return $this->footer(implode($sep, $fragments));
}
Expand Down
21 changes: 21 additions & 0 deletions src/Builder/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ final class Form {
*/
protected bool $clearOnExit = TRUE;

/**
* Whether the interactive TUI shows the contextual key-hint footer.
*/
protected bool $footer = TRUE;

/**
* Force ANSI colour on/off; NULL auto-detects.
*/
Expand Down Expand Up @@ -219,6 +224,21 @@ public function clearOnExit(bool $clear): self {
return $this;
}

/**
* Set whether the contextual key-hint footer is shown.
*
* @param bool $show
* Whether to show the footer.
*
* @return $this
* The builder.
*/
public function footer(bool $show): self {
$this->footer = $show;

return $this;
}

/**
* Force ANSI colour on or off.
*
Expand Down Expand Up @@ -325,6 +345,7 @@ public function build(): Config {
$this->envPrefix,
$this->themeOptions,
KeyMapManager::create($this->keymap, $this->keymapOverrides),
$this->footer,
);

$this->assertUniqueFieldIds($config);
Expand Down
3 changes: 3 additions & 0 deletions src/Config/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
* @param \DrevOps\Tui\Input\KeyMap|null $keymap
* The resolved key bindings for the interactive TUI; NULL uses the default
* preset. The Form builder resolves and validates this at build time.
* @param bool $footer
* Whether the interactive TUI shows the contextual key-hint footer.
*/
public function __construct(
public string $title,
Expand All @@ -67,6 +69,7 @@ public function __construct(
public string $envPrefix = '',
public array $themeOptions = [],
public ?KeyMap $keymap = NULL,
public bool $footer = TRUE,
) {
}

Expand Down
5 changes: 5 additions & 0 deletions src/Input/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@ enum Action {
case Quit;
case ScrollUp;
case ScrollDown;
case Help;

// Text editing.
case DeleteBack;
case InsertSpace;
case NewLine;
case ExternalEdit;

// Number entry.
case Increment;
case Decrement;

// List and multiselect.
case Toggle;
case SelectAll;
Expand Down
5 changes: 5 additions & 0 deletions src/Input/DefaultKeyMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public function bindings(): array {
new Binding(Scope::navigation(), Action::Quit, 'q'),
new Binding(Scope::navigation(), Action::ScrollUp, KeyName::MouseWheelUp),
new Binding(Scope::navigation(), Action::ScrollDown, KeyName::MouseWheelDown),
new Binding(Scope::navigation(), Action::Help, '?'),

// The arrow keys step a bounded number, overriding base movement.
new Binding(Scope::field(FieldType::Number), Action::Increment, KeyName::Up),
new Binding(Scope::field(FieldType::Number), Action::Decrement, KeyName::Down),

new Binding(Scope::field(FieldType::Textarea), Action::NewLine, KeyName::Enter),
new Binding(Scope::field(FieldType::Textarea), Action::Accept, KeyName::Tab),
Expand Down
43 changes: 43 additions & 0 deletions src/Input/Hint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace DrevOps\Tui\Input;

/**
* One labelled fragment of a context's key-hint footer.
*
* A hint pairs a human label ("move", "accept", "none/all") with the actions
* whose live keys illustrate it, so the glyphs are rendered from the active
* bindings - {@see \DrevOps\Tui\Theme\ThemeInterface::keysHint()} - and never
* drift from a remap. A context - a widget or the panel hub - declares an
* ordered list of these; the theme turns each into a fragment and joins them.
* Listing two actions under one label groups their keys ("←/→ none/all").
*
* @package DrevOps\Tui\Input
*/
final readonly class Hint {

/**
* The actions whose primary keys lead the fragment.
*
* @var list<\DrevOps\Tui\Input\Action>
*/
public array $actions;

/**
* Construct a hint.
*
* @param string $label
* The label describing what the keys do (e.g. "move", "accept").
* @param \DrevOps\Tui\Input\Action ...$actions
* The actions whose primary keys illustrate the label.
*/
public function __construct(
public string $label,
Action ...$actions,
) {
$this->actions = array_values($actions);
}

}
47 changes: 47 additions & 0 deletions src/Render/HelpSection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace DrevOps\Tui\Render;

use DrevOps\Tui\Input\Hint;
use DrevOps\Tui\Input\ScopedKeyMap;

/**
* One section of the help overlay: a titled context with its key hints.
*
* The controller assembles one per context it wants to teach - the panel hub
* and each widget type the form uses - pairing the section's bindings with the
* hint fragments to render against them, so the overlay reads from the same
* source of truth as the live footer.
*
* @package DrevOps\Tui\Render
*/
final readonly class HelpSection {

/**
* The hint fragments to render for this section.
*
* @var list<\DrevOps\Tui\Input\Hint>
*/
public array $hints;

/**
* Construct a help section.
*
* @param string $title
* The section heading (e.g. "Navigation", "Select").
* @param \DrevOps\Tui\Input\ScopedKeyMap $keys
* The section's bindings, so the glyphs reflect the live keys.
* @param \DrevOps\Tui\Input\Hint ...$hints
* The hint fragments in display order.
*/
public function __construct(
public string $title,
public ScopedKeyMap $keys,
Hint ...$hints,
) {
$this->hints = array_values($hints);
}

}
90 changes: 88 additions & 2 deletions src/Render/PanelController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use DrevOps\Tui\Config\Field;
use DrevOps\Tui\Config\Panel;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Hint;
use DrevOps\Tui\Input\Key;
use DrevOps\Tui\Input\KeyMap;
use DrevOps\Tui\Input\KeyMapManager;
Expand Down Expand Up @@ -103,6 +104,11 @@ class PanelController {
*/
protected bool $cancelled = FALSE;

/**
* Whether the help overlay is showing.
*/
protected bool $help = FALSE;

/**
* Construct a controller.
*
Expand Down Expand Up @@ -146,6 +152,13 @@ public function __construct(
* The key.
*/
public function handle(Key $key): void {
if ($this->help) {
// Any key dismisses the help overlay.
$this->help = FALSE;

return;
}

if ($this->editor instanceof WidgetInterface) {
$this->handleEditing($key);

Expand Down Expand Up @@ -185,6 +198,16 @@ public function isCancelled(): bool {
return $this->cancelled;
}

/**
* Whether the help overlay is showing.
*
* @return bool
* TRUE when the overlay is open.
*/
public function isShowingHelp(): bool {
return $this->help;
}

/**
* Run the interactive loop against a terminal until the user quits.
*
Expand Down Expand Up @@ -266,11 +289,16 @@ public function answers(): Answers {
* The frame.
*/
public function frame(int $height = 12): string {
if ($this->help) {
return $this->theme->renderHelp($this->nav, ...$this->helpSections());
}

if ($this->editor instanceof WidgetInterface) {
$label = $this->editing instanceof Field ? $this->editing->label : '';
$keys = $this->editing instanceof Field ? $this->keymap->forField($this->editing->type) : $this->nav;
$hints = $this->config->footer ? $this->editor->hints() : [];

return $this->theme->renderEditor($label, $this->editor->view($this->theme), $this->editor->rendersHint(), $keys);
return $this->theme->renderEditor($label, $this->editor->view($this->theme), $hints, $keys);
}

$panel = $this->navigator->current();
Expand Down Expand Up @@ -302,7 +330,7 @@ public function frame(int $height = 12): string {

$this->offset = $viewport->offset;
$header = [$this->theme->renderBreadcrumbLine($this->navigator)];
$footer = [$this->theme->renderStatusLine($this->nav)];
$footer = $this->hubFooter();

return $this->theme->renderFrame($header, $body, $footer, $viewport, $height);
}
Expand Down Expand Up @@ -345,6 +373,12 @@ protected function handleEditing(Key $key): void {
* The key.
*/
protected function handleNavigation(Key $key): void {
if ($this->nav->matches($key, Action::Help)) {
$this->help = TRUE;

return;
}

if ($this->nav->matches($key, Action::Quit)) {
$this->done = TRUE;

Expand Down Expand Up @@ -417,6 +451,58 @@ protected function activate(): void {
}
}

/**
* The panel-hub footer: the contextual hint line, unless turned off.
*
* @return list<string>
* The footer lines: one when the footer is on, none when it is off.
*/
protected function hubFooter(): array {
return $this->config->footer ? [$this->theme->renderHints($this->nav, ...$this->navigationHints())] : [];
}

/**
* The hint fragments for the panel hub, in display order.
*
* @return list<\DrevOps\Tui\Input\Hint>
* The hub hints.
*/
protected function navigationHints(): array {
return [
new Hint('move', Action::MoveUp, Action::MoveDown),
new Hint('select', Action::Activate),
new Hint('back', Action::Back),
new Hint('quit', Action::Quit),
new Hint('help', Action::Help),
];
}

/**
* The help-overlay sections: the hub, then each widget type the form uses.
*
* Field types are listed once, in first-seen order, so the overlay teaches
* every widget the form can show without repeating a type.
*
* @return list<\DrevOps\Tui\Render\HelpSection>
* The sections.
*/
protected function helpSections(): array {
$sections = [new HelpSection('Navigation', $this->nav, ...$this->navigationHints())];

$seen = [];
foreach ($this->config->fields() as $field) {
if (in_array($field->type, $seen, TRUE)) {
continue;
}

$seen[] = $field->type;
$widget = $this->widgets->create($field, $this->values[$field->id] ?? $field->default);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
$sections[] = new HelpSection(ucfirst($field->type->value), $this->keymap->forField($field->type), ...$widget->hints());
}

return $sections;
}

/**
* Whether the submit/cancel buttons are shown on the current panel.
*
Expand Down
Loading
Loading