From 8312680d4445689f917f6c8b4ecf5ea144db1327 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:10:39 +1000 Subject: [PATCH 1/5] [#12] Added a contextual key-binding help footer and '?' overlay. Each widget contributes its own hint fragments through a typed 'Hint' list, replacing the 'rendersHint()' split and the hand-rolled per-widget hint strings. A single footer renders from the active context - the panel hub or the focused widget - and 'NumberWidget' now steps through remappable 'Increment'/'Decrement' actions so its hints are binding-driven too. A '?' key opens a form-scoped help overlay, and '->footer(FALSE)' turns the footer off. --- README.md | 4 +- src/Builder/Form.php | 21 +++++ src/Config/Config.php | 3 + src/Input/Action.php | 5 ++ src/Input/DefaultKeyMap.php | 5 ++ src/Input/Hint.php | 43 +++++++++ src/Render/HelpSection.php | 47 ++++++++++ src/Render/PanelController.php | 90 ++++++++++++++++++- src/Theme/DefaultTheme.php | 85 +++++++++++++----- src/Widget/AbstractWidget.php | 5 +- src/Widget/ConfirmWidget.php | 9 ++ src/Widget/MultiSelectWidget.php | 40 +++------ src/Widget/NumberWidget.php | 77 ++++++---------- src/Widget/PasswordWidget.php | 31 ++++--- src/Widget/PauseWidget.php | 9 ++ src/Widget/SearchWidget.php | 9 ++ src/Widget/SelectWidget.php | 9 ++ src/Widget/SuggestWidget.php | 9 ++ src/Widget/TextareaWidget.php | 27 +++--- src/Widget/ToggleWidget.php | 9 ++ src/Widget/WidgetInterface.php | 13 ++- tests/phpunit/Unit/Builder/FormTest.php | 3 + tests/phpunit/Unit/Input/KeyMapTest.php | 15 ++++ .../Unit/Render/PanelControllerTest.php | 52 +++++++++++ tests/phpunit/Unit/Theme/ThemeOptionsTest.php | 18 ++-- tests/phpunit/Unit/Theme/ThemeRenderTest.php | 51 ++++++++++- .../phpunit/Unit/Widget/ConfirmWidgetTest.php | 7 ++ .../Unit/Widget/MultiSearchWidgetTest.php | 9 +- .../Unit/Widget/MultiSelectWidgetTest.php | 25 +++--- .../phpunit/Unit/Widget/NumberWidgetTest.php | 20 +++-- .../Unit/Widget/PasswordWidgetTest.php | 14 ++- tests/phpunit/Unit/Widget/PauseWidgetTest.php | 7 ++ .../phpunit/Unit/Widget/SearchWidgetTest.php | 7 ++ .../phpunit/Unit/Widget/SelectWidgetTest.php | 7 ++ .../phpunit/Unit/Widget/SuggestWidgetTest.php | 7 ++ tests/phpunit/Unit/Widget/TextWidgetTest.php | 9 +- .../Unit/Widget/TextareaWidgetTest.php | 23 ++--- .../phpunit/Unit/Widget/ToggleWidgetTest.php | 7 ++ .../phpunit/Unit/Widget/WidgetFactoryTest.php | 11 ++- 39 files changed, 637 insertions(+), 205 deletions(-) create mode 100644 src/Input/Hint.php create mode 100644 src/Render/HelpSection.php diff --git a/README.md b/README.md index 784387f..8a10b19 100644 --- a/README.md +++ b/README.md @@ -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`, `date`, `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) @@ -574,6 +574,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 diff --git a/src/Builder/Form.php b/src/Builder/Form.php index 53b99de..5e5f69e 100644 --- a/src/Builder/Form.php +++ b/src/Builder/Form.php @@ -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. */ @@ -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. * @@ -325,6 +345,7 @@ public function build(): Config { $this->envPrefix, $this->themeOptions, KeyMapManager::create($this->keymap, $this->keymapOverrides), + $this->footer, ); $this->assertUniqueFieldIds($config); diff --git a/src/Config/Config.php b/src/Config/Config.php index 14c0b2f..97d432d 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -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, @@ -67,6 +69,7 @@ public function __construct( public string $envPrefix = '', public array $themeOptions = [], public ?KeyMap $keymap = NULL, + public bool $footer = TRUE, ) { } diff --git a/src/Input/Action.php b/src/Input/Action.php index f5d3f2e..7ba2105 100644 --- a/src/Input/Action.php +++ b/src/Input/Action.php @@ -34,6 +34,7 @@ enum Action { case Quit; case ScrollUp; case ScrollDown; + case Help; // Text editing. case DeleteBack; @@ -42,6 +43,10 @@ enum Action { case ExternalEdit; case Complete; + // Number entry. + case Increment; + case Decrement; + // List and multiselect. case Toggle; case SelectAll; diff --git a/src/Input/DefaultKeyMap.php b/src/Input/DefaultKeyMap.php index dbe6343..9fdbc46 100644 --- a/src/Input/DefaultKeyMap.php +++ b/src/Input/DefaultKeyMap.php @@ -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), // The text field accepts its inline ghost-text completion on Tab. new Binding(Scope::field(FieldType::Text), Action::Complete, KeyName::Tab), diff --git a/src/Input/Hint.php b/src/Input/Hint.php new file mode 100644 index 0000000..698907e --- /dev/null +++ b/src/Input/Hint.php @@ -0,0 +1,43 @@ + + */ + 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); + } + +} diff --git a/src/Render/HelpSection.php b/src/Render/HelpSection.php new file mode 100644 index 0000000..757580e --- /dev/null +++ b/src/Render/HelpSection.php @@ -0,0 +1,47 @@ + + */ + 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); + } + +} diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php index 3ea3e88..1228e56 100644 --- a/src/Render/PanelController.php +++ b/src/Render/PanelController.php @@ -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; @@ -103,6 +104,11 @@ class PanelController { */ protected bool $cancelled = FALSE; + /** + * Whether the help overlay is showing. + */ + protected bool $help = FALSE; + /** * Construct a controller. * @@ -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); @@ -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. * @@ -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(); @@ -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); } @@ -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; @@ -417,6 +451,58 @@ protected function activate(): void { } } + /** + * The panel-hub footer: the contextual hint line, unless turned off. + * + * @return list + * 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 (isset($seen[$field->type->value])) { + continue; + } + + $seen[$field->type->value] = TRUE; + $widget = $this->widgets->create($field, $this->values[$field->id] ?? $field->default); + $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. * diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php index 62fcce7..38e65e9 100644 --- a/src/Theme/DefaultTheme.php +++ b/src/Theme/DefaultTheme.php @@ -10,11 +10,13 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Panel; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Render\Box; +use DrevOps\Tui\Render\HelpSection; use DrevOps\Tui\Render\Navigator; use DrevOps\Tui\Render\Scroller; use DrevOps\Tui\Render\Viewport; @@ -908,20 +910,32 @@ public function keysHint(ScopedKeyMap $keys, string $label, Action ...$actions): } /** - * Render the status line shown at the foot of a panel. + * Render a context's hint fragments as one dot-joined footer line. * - * @param \DrevOps\Tui\Input\ScopedKeyMap $nav - * The navigation-scope bindings, so the hint reflects the active keys. + * Each {@see Hint} becomes a labelled fragment drawn from the live bindings, + * so the line never contradicts a remapped key. Fragments whose actions are + * all unbound drop out, and an entirely unbound context yields an empty line. + * + * @param \DrevOps\Tui\Input\ScopedKeyMap $keys + * The active scope's bindings. + * @param \DrevOps\Tui\Input\Hint ...$hints + * The hint fragments, in display order. * * @return string - * The themed status line (hint text and arrow glyphs). + * The themed hint line, or an empty string when nothing is bound. */ - public function renderStatusLine(ScopedKeyMap $nav): string { - return $this->renderHintLine( - $this->keysHint($nav, 'move', Action::MoveUp, Action::MoveDown), - $this->keysHint($nav, 'select', Action::Activate), - $this->keysHint($nav, 'back', Action::Back), - ); + public function renderHints(ScopedKeyMap $keys, Hint ...$hints): string { + $fragments = []; + + foreach ($hints as $hint) { + $fragment = $this->keysHint($keys, $hint->label, ...$hint->actions); + + if ($fragment !== '') { + $fragments[] = $fragment; + } + } + + return $fragments === [] ? '' : $this->renderHintLine(...$fragments); } /** @@ -954,27 +968,24 @@ public function renderEditorHeader(string $label): string { } /** - * Compose a field's editor screen: the label, the widget view and hints. + * Compose a field's editor screen: the label, the widget view and its hints. * * @param string $label * The field label. * @param string $view * The widget's rendered view. - * @param bool $renders_hint - * Whether the view already carries its own key-hint line, in which case the - * generic accept/cancel hint is omitted so the two cannot contradict. + * @param list<\DrevOps\Tui\Input\Hint> $hints + * The widget's hint fragments; an empty list draws no hint line, so the + * footer can be turned off form-wide. * @param \DrevOps\Tui\Input\ScopedKeyMap|null $keys - * The editor's scope bindings, so the accept/cancel hint reflects the - * active keys; NULL uses the default accept/cancel glyphs. + * The editor's scope bindings, so the hint glyphs reflect the active keys. * * @return string * The editor screen - boxed when the theme has a border, else plain. */ - public function renderEditor(string $label, string $view, bool $renders_hint = FALSE, ?ScopedKeyMap $keys = NULL): string { - $hint = $keys instanceof ScopedKeyMap - ? $this->renderHintLine($this->keysHint($keys, 'accept', Action::Accept), $this->keysHint($keys, 'cancel', Action::Cancel)) - : $this->renderHintLine($this->enter() . ' accept', 'esc cancel'); - $footer = $renders_hint ? [] : [$hint]; + public function renderEditor(string $label, string $view, array $hints = [], ?ScopedKeyMap $keys = NULL): string { + $hint = $keys instanceof ScopedKeyMap ? $this->renderHints($keys, ...$hints) : ''; + $footer = $hint === '' ? [] : [$hint]; if ($this->borderStyle() !== self::BORDER_NONE) { $body = explode("\n", $view); @@ -984,7 +995,37 @@ public function renderEditor(string $label, string $view, bool $renders_hint = F $screen = $this->renderEditorHeader($label) . "\n" . $view; - return $renders_hint ? $screen : $screen . "\n\n" . $hint; + return $hint === '' ? $screen : $screen . "\n\n" . $hint; + } + + /** + * Compose the full-screen key-binding help overlay. + * + * @param \DrevOps\Tui\Input\ScopedKeyMap $nav + * The navigation bindings, for the close hint. + * @param \DrevOps\Tui\Render\HelpSection ...$sections + * The contexts to list, each a heading with its bindings and hints. + * + * @return string + * The rendered overlay. + */ + public function renderHelp(ScopedKeyMap $nav, HelpSection ...$sections): string { + $lines = [$this->title('Keyboard help'), '']; + + foreach ($sections as $section) { + $lines[] = $this->label($section->title); + $hint = $this->renderHints($section->keys, ...$section->hints); + + if ($hint !== '') { + $lines[] = $hint; + } + + $lines[] = ''; + } + + $lines[] = $this->renderHints($nav, new Hint('close', Action::Help)); + + return implode("\n", $lines); } /** diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php index bcda540..cda631d 100644 --- a/src/Widget/AbstractWidget.php +++ b/src/Widget/AbstractWidget.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Widget; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\Scope; @@ -114,8 +115,8 @@ public function value(): mixed { /** * {@inheritdoc} */ - public function rendersHint(): bool { - return FALSE; + public function hints(): array { + return [new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; } /** diff --git a/src/Widget/ConfirmWidget.php b/src/Widget/ConfirmWidget.php index e7dd64f..fc8aeda 100644 --- a/src/Widget/ConfirmWidget.php +++ b/src/Widget/ConfirmWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; @@ -91,4 +92,12 @@ public function view(ThemeInterface $theme): string { return $this->current ? $marker_on . ' ' . $yes_label . ' ' . $marker_off . ' ' . $no_label : $marker_off . ' ' . $yes_label . ' ' . $marker_on . ' ' . $no_label; } + /** + * {@inheritdoc} + */ + #[\Override] + public function hints(): array { + return [new Hint('yes/no', Action::Yes, Action::No), new Hint('toggle', Action::Toggle), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + } + } diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php index cb823e4..8fada30 100644 --- a/src/Widget/MultiSelectWidget.php +++ b/src/Widget/MultiSelectWidget.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\Option; use DrevOps\Tui\Config\OptionKind; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; @@ -313,43 +314,24 @@ public function view(ThemeInterface $theme): string { $lines[] = $theme->indicator(' ' . $theme->indicatorDown()); } - $lines[] = $this->hint($theme); - return implode("\n", $lines); } /** * {@inheritdoc} - */ - #[\Override] - public function rendersHint(): bool { - return TRUE; - } - - /** - * Build the key-hint line shown beneath the option list. * * Toggle is the non-obvious action here - nothing else signals that a key - * toggles the highlighted option - so it leads, followed by the remaining - * bindings. Every glyph is drawn from the live bindings, so the line stays - * truthful when the keys are remapped. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * - * @return string - * The themed, dot-joined hint line. + * toggles the highlighted option - so it leads, followed by the rest. */ - protected function hint(ThemeInterface $theme): string { - $fragments = array_filter([ - $theme->keysHint($this->keys(), 'select', Action::Toggle), - $theme->keysHint($this->keys(), 'move', Action::MoveUp, Action::MoveDown), - $theme->keysHint($this->keys(), 'none/all', Action::SelectNone, Action::SelectAll), - $theme->keysHint($this->keys(), 'accept', Action::Accept), - $theme->keysHint($this->keys(), 'cancel', Action::Cancel), - ]); - - return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); + #[\Override] + public function hints(): array { + return [ + new Hint('select', Action::Toggle), + new Hint('move', Action::MoveUp, Action::MoveDown), + new Hint('none/all', Action::SelectNone, Action::SelectAll), + new Hint('accept', Action::Accept), + new Hint('cancel', Action::Cancel), + ]; } } diff --git a/src/Widget/NumberWidget.php b/src/Widget/NumberWidget.php index d2dcc12..cd10c47 100644 --- a/src/Widget/NumberWidget.php +++ b/src/Widget/NumberWidget.php @@ -4,10 +4,12 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\NumberBounds; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; -use DrevOps\Tui\Theme\ThemeInterface; +use DrevOps\Tui\Input\Scope; /** * Integer input: digits with an optional leading minus, accepted as an int. @@ -36,6 +38,14 @@ public function __construct(string $buffer = '', ?\Closure $validate = NULL, ?\C parent::__construct($buffer, $validate, $transform); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Number); + } + /** * {@inheritdoc} */ @@ -43,16 +53,20 @@ public function __construct(string $buffer = '', ?\Closure $validate = NULL, ?\C public function handle(Key $key): void { $bounds = $this->bounds; - if ($bounds instanceof NumberBounds && $key->is(KeyName::Up)) { - $this->adjust($bounds, 1); + if ($bounds instanceof NumberBounds) { + $keys = $this->keys(); - return; - } + if ($keys->matches($key, Action::Increment)) { + $this->adjust($bounds, 1); - if ($bounds instanceof NumberBounds && $key->is(KeyName::Down)) { - $this->adjust($bounds, -1); + return; + } - return; + if ($keys->matches($key, Action::Decrement)) { + $this->adjust($bounds, -1); + + return; + } } parent::handle($key); @@ -114,50 +128,17 @@ protected function adjust(NumberBounds $bounds, int $direction): void { /** * {@inheritdoc} + * + * The step keys are the non-obvious binding here - nothing else signals that + * they adjust the value - so they lead when bounds are set. */ #[\Override] - public function view(ThemeInterface $theme): string { + public function hints(): array { if (!$this->bounds instanceof NumberBounds) { - return parent::view($theme); + return parent::hints(); } - $rows = [$this->caretLine($theme), $this->hint($theme)]; - - if ($this->error !== NULL) { - $rows[] = $theme->error($this->error); - } - - return implode("\n", $rows); - } - - /** - * {@inheritdoc} - */ - #[\Override] - public function rendersHint(): bool { - return $this->bounds instanceof NumberBounds; - } - - /** - * Build the key-hint line shown beneath a bounded number entry. - * - * The arrow keys are the non-obvious binding here - nothing else signals that - * they adjust the value - so they lead, followed by the remaining bindings. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * - * @return string - * The themed, dot-joined hint line. - */ - protected function hint(ThemeInterface $theme): string { - $fragments = [ - $theme->arrowUp() . '/' . $theme->arrowDown() . ' adjust', - $theme->enter() . ' accept', - 'esc cancel', - ]; - - return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); + return [new Hint('adjust', Action::Increment, Action::Decrement), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; } } diff --git a/src/Widget/PasswordWidget.php b/src/Widget/PasswordWidget.php index 0f10c4c..7e0bfa4 100644 --- a/src/Widget/PasswordWidget.php +++ b/src/Widget/PasswordWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; @@ -129,14 +130,6 @@ public function view(ThemeInterface $theme): string { $rows[] = $theme->footer('re-enter to confirm'); } - if ($this->revealable) { - $rows[] = $theme->footer(implode(' ' . $theme->dot() . ' ', array_filter([ - $theme->keysHint($this->keys(), 'accept', Action::Accept), - $theme->keysHint($this->keys(), 'reveal', Action::Reveal), - $theme->keysHint($this->keys(), 'cancel', Action::Cancel), - ]))); - } - if ($this->error !== NULL) { $rows[] = $theme->error($this->error); } @@ -144,6 +137,20 @@ public function view(ThemeInterface $theme): string { return implode("\n", $rows); } + /** + * {@inheritdoc} + * + * The reveal toggle is only offered when the widget is revealable. + */ + #[\Override] + public function hints(): array { + if (!$this->revealable) { + return parent::hints(); + } + + return [new Hint('accept', Action::Accept), new Hint('reveal', Action::Reveal), new Hint('cancel', Action::Cancel)]; + } + /** * Render the input line for the current display mode. * @@ -161,12 +168,4 @@ protected function renderLine(ThemeInterface $theme): string { }; } - /** - * {@inheritdoc} - */ - #[\Override] - public function rendersHint(): bool { - return $this->revealable; - } - } diff --git a/src/Widget/PauseWidget.php b/src/Widget/PauseWidget.php index 2354a51..1dc2c09 100644 --- a/src/Widget/PauseWidget.php +++ b/src/Widget/PauseWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; @@ -57,4 +58,12 @@ public function view(ThemeInterface $theme): string { return 'Press ' . $theme->highlight($glyph) . ' to continue'; } + /** + * {@inheritdoc} + */ + #[\Override] + public function hints(): array { + return [new Hint('continue', Action::Accept), new Hint('cancel', Action::Cancel)]; + } + } diff --git a/src/Widget/SearchWidget.php b/src/Widget/SearchWidget.php index 4b7be1b..6511ffc 100644 --- a/src/Widget/SearchWidget.php +++ b/src/Widget/SearchWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\OptionKind; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Theme\ThemeInterface; @@ -202,4 +203,12 @@ protected function positionsFor(string $label): array { return $this->filter === '' ? [] : $this->matcher()->positions($label, $this->filter); } + /** + * {@inheritdoc} + */ + #[\Override] + public function hints(): array { + return [new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + } + } diff --git a/src/Widget/SelectWidget.php b/src/Widget/SelectWidget.php index ff5b655..e11bfa1 100644 --- a/src/Widget/SelectWidget.php +++ b/src/Widget/SelectWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\OptionKind; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Theme\ThemeInterface; @@ -133,4 +134,12 @@ public function view(ThemeInterface $theme): string { return implode("\n", $lines); } + /** + * {@inheritdoc} + */ + #[\Override] + public function hints(): array { + return [new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + } + } diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php index 7cb0fa0..3e9e002 100644 --- a/src/Widget/SuggestWidget.php +++ b/src/Widget/SuggestWidget.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Widget; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Theme\ThemeInterface; @@ -162,4 +163,12 @@ protected function positionsFor(string $value): array { return $this->buffer === '' ? [] : $this->matcher()->positions($value, $this->buffer); } + /** + * {@inheritdoc} + */ + #[\Override] + public function hints(): array { + return [new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + } + } diff --git a/src/Widget/TextareaWidget.php b/src/Widget/TextareaWidget.php index 9ecaea7..2606b28 100644 --- a/src/Widget/TextareaWidget.php +++ b/src/Widget/TextareaWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; @@ -165,29 +166,21 @@ public function applyExternalEdit(?string $content): void { public function view(ThemeInterface $theme): string { $text = substr($this->buffer, 0, $this->cursor) . $theme->caret() . substr($this->buffer, $this->cursor); - $fragments = [ - $theme->keysHint($this->keys(), 'newline', Action::NewLine), - $theme->keysHint($this->keys(), 'accept', Action::Accept), - $theme->keysHint($this->keys(), 'cancel', Action::Cancel), - ]; - - if ($this->externalEdit) { - $fragments[] = $theme->keysHint($this->keys(), 'editor', Action::ExternalEdit); - } - - $hint = $theme->footer(implode(' ' . $theme->dot() . ' ', array_filter($fragments))); - - $out = $text . "\n" . $hint; - - return $this->error === NULL ? $out : $out . "\n" . $theme->error($this->error); + return $this->error === NULL ? $text : $text . "\n" . $theme->error($this->error); } /** * {@inheritdoc} */ #[\Override] - public function rendersHint(): bool { - return TRUE; + public function hints(): array { + $hints = [new Hint('newline', Action::NewLine), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + + if ($this->externalEdit) { + $hints[] = new Hint('editor', Action::ExternalEdit); + } + + return $hints; } } diff --git a/src/Widget/ToggleWidget.php b/src/Widget/ToggleWidget.php index 2f8e4db..2cd32e4 100644 --- a/src/Widget/ToggleWidget.php +++ b/src/Widget/ToggleWidget.php @@ -6,6 +6,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; @@ -137,4 +138,12 @@ public function view(ThemeInterface $theme): string { return implode(' ', $parts); } + /** + * {@inheritdoc} + */ + #[\Override] + public function hints(): array { + return [new Hint('toggle', Action::Toggle), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + } + } diff --git a/src/Widget/WidgetInterface.php b/src/Widget/WidgetInterface.php index 455e9ce..0ec154b 100644 --- a/src/Widget/WidgetInterface.php +++ b/src/Widget/WidgetInterface.php @@ -4,6 +4,7 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Theme\ThemeInterface; @@ -72,11 +73,15 @@ public function error(): ?string; public function view(ThemeInterface $theme): string; /** - * Whether view() renders its own key-hint line. + * The key-hint fragments for this widget, in display order. * - * @return bool - * TRUE when the view already includes a hint line of its own. + * Each fragment pairs a label with the actions whose live keys illustrate it; + * the footer renders them against the widget's bindings. This is the widget's + * own contribution to the contextual help footer. + * + * @return list<\DrevOps\Tui\Input\Hint> + * The ordered hint fragments. */ - public function rendersHint(): bool; + public function hints(): array; } diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index de62e29..b1a0e8d 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -47,6 +47,7 @@ public function testBuildsExpectedConfig(): void { ->banner('LOGO') ->buttons(TRUE, 'Install', 'Quit') ->clearOnExit(FALSE) + ->footer(FALSE) ->color(TRUE) ->unicode(FALSE) ->envPrefix('APP_') @@ -75,6 +76,7 @@ public function testBuildsExpectedConfig(): void { $this->assertSame('Install', $config->submitLabel); $this->assertSame('Quit', $config->cancelLabel); $this->assertFalse($config->clearOnExit); + $this->assertFalse($config->footer); $this->assertTrue($config->color); $this->assertFalse($config->unicode); $this->assertSame('APP_', $config->envPrefix); @@ -192,6 +194,7 @@ public function testDefaultsAndFallbacks(): void { // Config-level defaults. $this->assertSame('', $config->subject); $this->assertTrue($config->buttons); + $this->assertTrue($config->footer); $this->assertSame('Submit', $config->submitLabel); $this->assertSame('', $config->theme); $this->assertNull($config->color); diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php index b66d518..cd05d5b 100644 --- a/tests/phpunit/Unit/Input/KeyMapTest.php +++ b/tests/phpunit/Unit/Input/KeyMapTest.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Binding; use DrevOps\Tui\Input\DefaultKeyMap; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyMap; use DrevOps\Tui\Input\KeyMapManager; @@ -25,6 +26,7 @@ */ #[CoversClass(Action::class)] #[CoversClass(Binding::class)] +#[CoversClass(Hint::class)] #[CoversClass(Scope::class)] #[CoversClass(ScopedKeyMap::class)] #[CoversClass(KeyMap::class)] @@ -49,6 +51,12 @@ public static function dataProviderDefaultBindings(): \Iterator { yield 'nav q quits' => [Scope::navigation(), Key::char('q'), Action::Quit, TRUE]; yield 'nav escape backs out' => [Scope::navigation(), Key::named(KeyName::Escape), Action::Back, TRUE]; yield 'nav wheel scrolls' => [Scope::navigation(), Key::named(KeyName::MouseWheelDown), Action::ScrollDown, TRUE]; + yield 'nav ? opens help' => [Scope::navigation(), Key::char('?'), Action::Help, TRUE]; + // A bounded number steps with the arrows, which override base movement. + yield 'number up increments' => [Scope::field(FieldType::Number), Key::named(KeyName::Up), Action::Increment, TRUE]; + yield 'number down decrements' => [Scope::field(FieldType::Number), Key::named(KeyName::Down), Action::Decrement, TRUE]; + yield 'number up is no longer move up' => [Scope::field(FieldType::Number), Key::named(KeyName::Up), Action::MoveUp, FALSE]; + yield 'number left still moves the caret' => [Scope::field(FieldType::Number), Key::named(KeyName::Left), Action::MoveLeft, TRUE]; // Text. yield 'text enter accepts' => [Scope::field(FieldType::Text), Key::named(KeyName::Enter), Action::Accept, TRUE]; yield 'text space inserts' => [Scope::field(FieldType::Text), Key::named(KeyName::Space), Action::InsertSpace, TRUE]; @@ -237,4 +245,11 @@ public function testBindingHoldsItsDeclaration(): void { $this->assertSame([KeyName::Escape, 'x'], $binding->keys); } + public function testHintHoldsItsDeclaration(): void { + $hint = new Hint('none/all', Action::SelectNone, Action::SelectAll); + + $this->assertSame('none/all', $hint->label); + $this->assertSame([Action::SelectNone, Action::SelectAll], $hint->actions); + } + } diff --git a/tests/phpunit/Unit/Render/PanelControllerTest.php b/tests/phpunit/Unit/Render/PanelControllerTest.php index e92f125..3953eac 100644 --- a/tests/phpunit/Unit/Render/PanelControllerTest.php +++ b/tests/phpunit/Unit/Render/PanelControllerTest.php @@ -264,6 +264,58 @@ public function testQuit(): void { $this->assertTrue($controller->isDone()); } + public function testHubFooterShowsQuitAndHelp(): void { + $controller = $this->controller(); + + // The hub footer is complete: it surfaces quit and the help toggle, not + // just the move/select/back subset. + $footer = Ansi::strip($controller->frame(12)); + $this->assertStringContainsString('q quit', $footer); + $this->assertStringContainsString('? help', $footer); + } + + public function testHelpOverlayTogglesAndCloses(): void { + $controller = $this->controller(); + + // '?' opens the overlay; the frame becomes the help screen listing the hub + // and each widget type the form uses (here Text and Confirm). + $controller->handle(Key::char('?')); + $this->assertTrue($controller->isShowingHelp()); + + $help = Ansi::strip($controller->frame(12)); + $this->assertStringContainsString('Keyboard help', $help); + $this->assertStringContainsString('Navigation', $help); + $this->assertStringContainsString('Text', $help); + $this->assertStringContainsString('Confirm', $help); + $this->assertStringContainsString('? close', $help); + + // Any key dismisses it, and that key does nothing else (the cursor stays). + $controller->handle(Key::named(KeyName::Down)); + $this->assertFalse($controller->isShowingHelp()); + $this->assertSame(0, $controller->cursor()); + $this->assertStringContainsString('Demo', Ansi::strip($controller->frame(12))); + } + + public function testFooterHiddenWhenTurnedOff(): void { + $config = Form::create('Demo') + ->footer(FALSE) + ->buttons(FALSE) + ->panel('p', 'p', function (PanelBuilder $p): void { + $p->text('a', 'A'); + }) + ->build(); + $controller = new PanelController($config, new DefaultTheme(40, ['color' => FALSE]), ['a' => 'x'], []); + + // The hub footer is gone. + $this->assertStringNotContainsString('quit', Ansi::strip($controller->frame(12))); + + // And so is the editor's hint line (drill into the panel, then the field). + $controller->handle(Key::named(KeyName::Enter)); + $controller->handle(Key::named(KeyName::Enter)); + $this->assertTrue($controller->isEditing()); + $this->assertStringNotContainsString('accept', Ansi::strip($controller->frame(12))); + } + public function testTextareaExternalEditCommitsCapturedValue(): void { $controller = $this->textareaController($this->fixedEditor('FROM EDITOR')); diff --git a/tests/phpunit/Unit/Theme/ThemeOptionsTest.php b/tests/phpunit/Unit/Theme/ThemeOptionsTest.php index 0d1b871..412fba3 100644 --- a/tests/phpunit/Unit/Theme/ThemeOptionsTest.php +++ b/tests/phpunit/Unit/Theme/ThemeOptionsTest.php @@ -8,6 +8,9 @@ use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Panel; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; +use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Render\Viewport; use DrevOps\Tui\Tests\Fixtures\Theme\AccentOptionTheme; @@ -125,17 +128,18 @@ public function testEditorAdoptsBorder(): void { $this->assertStringContainsString('Acme', Ansi::strip($boxed)); } - public function testEditorOmitsGenericHintWhenViewRendersOwn(): void { + public function testEditorDrawsHintsOnlyWhenGiven(): void { $plain = new DefaultTheme(30, ['color' => FALSE]); + $keys = KeyMapManager::create()->forField(FieldType::Text); - // With the flag set, the generic accept/cancel hint is dropped so it cannot - // contradict a hint the widget's own view already renders. - $this->assertStringNotContainsString('accept', $plain->renderEditor('Name', 'body', TRUE)); - $this->assertStringContainsString('accept', $plain->renderEditor('Name', 'body', FALSE)); + // An empty hint list draws no footer (the footer can be turned off); a + // non-empty list draws it. + $this->assertStringNotContainsString('accept', $plain->renderEditor('Name', 'body', [], $keys)); + $this->assertStringContainsString('accept', $plain->renderEditor('Name', 'body', [new Hint('accept', Action::Accept)], $keys)); - // Bordered: the dropped footer still closes the box with a single rule. + // Bordered: an empty hint list still closes the box with a single rule. $boxed = new DefaultTheme(30, ['color' => FALSE, 'border' => ThemeInterface::BORDER_LINE]); - $frame = $boxed->renderEditor('Name', 'body', TRUE); + $frame = $boxed->renderEditor('Name', 'body', [], $keys); $this->assertStringNotContainsString('accept', $frame); $this->assertStringContainsString('body', Ansi::strip($frame)); } diff --git a/tests/phpunit/Unit/Theme/ThemeRenderTest.php b/tests/phpunit/Unit/Theme/ThemeRenderTest.php index 02e9ff0..fcf3ea1 100644 --- a/tests/phpunit/Unit/Theme/ThemeRenderTest.php +++ b/tests/phpunit/Unit/Theme/ThemeRenderTest.php @@ -10,10 +10,12 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Panel; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; +use DrevOps\Tui\Render\HelpSection; use DrevOps\Tui\Render\Navigator; use DrevOps\Tui\Render\Viewport; use DrevOps\Tui\Theme\DefaultTheme; @@ -26,6 +28,7 @@ * Tests the theme's rendering via headless frame probes. */ #[CoversClass(DefaultTheme::class)] +#[CoversClass(HelpSection::class)] #[Group('tui')] final class ThemeRenderTest extends TestCase { @@ -185,14 +188,53 @@ public function testItemCount(): void { $this->assertSame(2, $this->theme()->itemCount($panel)); } - public function testStatusLineIsThemed(): void { - $line = (new DefaultTheme())->renderStatusLine(KeyMapManager::create()->navigation()); + public function testHintsLineIsThemed(): void { + $line = (new DefaultTheme())->renderHints(KeyMapManager::create()->navigation(), new Hint('move', Action::MoveUp, Action::MoveDown)); // Themed with the footer role (dim gray) and composed from arrow glyphs. $this->assertStringContainsString("\033[90m", $line); $this->assertStringContainsString('↑/↓ move', Ansi::strip($line)); } + public function testRenderHintsJoinsFragmentsInBothModes(): void { + $keys = KeyMapManager::create()->forField(FieldType::MultiSelect); + $hints = [new Hint('select', Action::Toggle), new Hint('none/all', Action::SelectNone, Action::SelectAll)]; + + $unicode = Ansi::strip((new DefaultTheme())->renderHints($keys, ...$hints)); + $this->assertSame('space select · ←/→ none/all', $unicode); + + // The glyphs degrade with the theme's Unicode mode. + $ascii = Ansi::strip((new DefaultTheme(76, ['unicode' => FALSE]))->renderHints($keys, ...$hints)); + $this->assertStringContainsString(' none/all', $ascii); + } + + public function testRenderHintsEmptyWhenNothingBound(): void { + $nav = KeyMapManager::create()->navigation(); + + // Newline is not a navigation action, so the whole line collapses to empty. + $this->assertSame('', (new DefaultTheme())->renderHints($nav, new Hint('newline', Action::NewLine))); + } + + public function testRenderHelpListsSectionsAndCloseHint(): void { + $nav = KeyMapManager::create()->navigation(); + $text = KeyMapManager::create()->forField(FieldType::Text); + + $help = Ansi::strip((new DefaultTheme())->renderHelp( + $nav, + new HelpSection('Navigation', $nav, new Hint('move', Action::MoveUp, Action::MoveDown)), + new HelpSection('Text', $text, new Hint('accept', Action::Accept)), + // A section whose hints resolve to nothing lists its heading only. + new HelpSection('Empty', $nav, new Hint('newline', Action::NewLine)), + )); + + $this->assertStringContainsString('Keyboard help', $help); + $this->assertStringContainsString('Navigation', $help); + $this->assertStringContainsString('↑/↓ move', $help); + $this->assertStringContainsString('Text', $help); + $this->assertStringContainsString('Empty', $help); + $this->assertStringContainsString('? close', $help); + } + #[DataProvider('dataProviderKeyHint')] public function testKeyHint(Key $key, string $unicode, string $ascii): void { $this->assertSame($unicode, (new DefaultTheme())->keyHint($key)); @@ -231,9 +273,10 @@ public function testKeysHintDropsUnboundActions(): void { public function testRenderEditorDerivesHintFromKeys(): void { $keys = KeyMapManager::create()->forField(FieldType::Text); - $editor = Ansi::strip((new DefaultTheme())->renderEditor('Name', 'value', FALSE, $keys)); + $hints = [new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + $editor = Ansi::strip((new DefaultTheme())->renderEditor('Name', 'value', $hints, $keys)); - // The generic accept/cancel hint reflects the active bindings. + // The hint reflects the active bindings. $this->assertStringContainsString('↵ accept', $editor); $this->assertStringContainsString('esc cancel', $editor); } diff --git a/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php b/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php index 3996bd6..2bdf0f9 100644 --- a/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php +++ b/tests/phpunit/Unit/Widget/ConfirmWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; @@ -64,4 +65,10 @@ public function testCancel(): void { $this->assertTrue($widget->isCancelled()); } + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new ConfirmWidget(FALSE))->hints()); + + $this->assertSame(['yes/no', 'toggle', 'accept', 'cancel'], $labels); + } + } diff --git a/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php index 30d0d3d..9589152 100644 --- a/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php +++ b/tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; @@ -61,12 +62,10 @@ public function testViewShowsQueryLineAboveOptions(): void { $this->assertStringNotContainsString('ClamAV', $view); } - public function testViewShowsInheritedKeyHint(): void { - $widget = new MultiSearchWidget($this->labels); - - $view = Ansi::strip($widget->view(new DefaultTheme())); + public function testInheritsMultiselectHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new MultiSearchWidget($this->labels))->hints()); - $this->assertStringContainsString('space select · ↑/↓ move · ←/→ none/all · ↵ accept · esc cancel', $view); + $this->assertSame(['select', 'move', 'none/all', 'accept', 'cancel'], $labels); } public function testSkipsNonSelectableWhenToggling(): void { diff --git a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php index a14a9f6..94fab2d 100644 --- a/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php @@ -4,7 +4,9 @@ namespace DrevOps\Tui\Tests\Unit\Widget; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; @@ -130,21 +132,16 @@ public function testToggleWithNoMatchesIsNoop(): void { $this->assertSame([], $value); } - public function testViewShowsKeyHint(): void { - $widget = new MultiSelectWidget(['a' => 'Apple', 'b' => 'Banana']); + public function testHints(): void { + $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], (new MultiSelectWidget(['a' => 'A']))->hints()); - $unicode = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('space select · ↑/↓ move · ←/→ none/all · ↵ accept · esc cancel', $unicode); - - // The glyphs degrade with the theme's Unicode mode. - $ascii = Ansi::strip($widget->view(new DefaultTheme(76, ['unicode' => FALSE]))); - $this->assertStringContainsString(' none/all', $ascii); - } - - public function testRendersOwnHint(): void { - // The view carries its own hint line, so the editor chrome must not add the - // generic "enter accept" hint on top. - $this->assertTrue((new MultiSelectWidget(['a' => 'A']))->rendersHint()); + $this->assertSame([ + ['select', [Action::Toggle]], + ['move', [Action::MoveUp, Action::MoveDown]], + ['none/all', [Action::SelectNone, Action::SelectAll]], + ['accept', [Action::Accept]], + ['cancel', [Action::Cancel]], + ], $hints); } public function testSpaceSkipsDisabledAndTogglesSelectable(): void { diff --git a/tests/phpunit/Unit/Widget/NumberWidgetTest.php b/tests/phpunit/Unit/Widget/NumberWidgetTest.php index 311fc2d..53c6344 100644 --- a/tests/phpunit/Unit/Widget/NumberWidgetTest.php +++ b/tests/phpunit/Unit/Widget/NumberWidgetTest.php @@ -5,7 +5,9 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Config\NumberBounds; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; @@ -86,8 +88,10 @@ public function testArrowsInertAndUnhintedWithoutBounds(): void { $widget->handle(Key::named(KeyName::Down)); $this->assertSame(5, $widget->value()); - $this->assertFalse($widget->rendersHint()); - $this->assertStringNotContainsString('adjust', $widget->view(new DefaultTheme())); + + // Without bounds it contributes only the shared accept/cancel hints. + $labels = array_map(static fn(Hint $hint): string => $hint->label, $widget->hints()); + $this->assertSame(['accept', 'cancel'], $labels); } public function testUpDownStepByOneWithinBounds(): void { @@ -151,14 +155,16 @@ public function testSteppingClearsStaleError(): void { $this->assertStringNotContainsString('Enter a number', $widget->view(new DefaultTheme())); } - public function testRendersOwnHintWhenBounded(): void { + public function testHintsWhenBounded(): void { $widget = new NumberWidget('5', bounds: new NumberBounds(0, 10)); - $view = $widget->view(new DefaultTheme()); + $hints = array_map(static fn(Hint $hint): array => [$hint->label, $hint->actions], $widget->hints()); - $this->assertTrue($widget->rendersHint()); - $this->assertStringContainsString('adjust', $view); - $this->assertStringContainsString('accept', $view); + $this->assertSame([ + ['adjust', [Action::Increment, Action::Decrement]], + ['accept', [Action::Accept]], + ['cancel', [Action::Cancel]], + ], $hints); } } diff --git a/tests/phpunit/Unit/Widget/PasswordWidgetTest.php b/tests/phpunit/Unit/Widget/PasswordWidgetTest.php index 1ac64c0..50d9bc9 100644 --- a/tests/phpunit/Unit/Widget/PasswordWidgetTest.php +++ b/tests/phpunit/Unit/Widget/PasswordWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; @@ -81,7 +82,6 @@ public function testToggleIgnoredWhenNotRevealable(): void { // Tab neither revealed the value nor was inserted as a character. $this->assertSame(3, substr_count($widget->view($theme), '•')); $this->assertStringNotContainsString('abc', $widget->view($theme)); - $this->assertFalse($widget->rendersHint()); } public function testRevealDoesNotChangeAcceptedValue(): void { @@ -93,15 +93,11 @@ public function testRevealDoesNotChangeAcceptedValue(): void { } public function testHintShownOnlyWhenRevealable(): void { - $theme = new DefaultTheme(); - - $revealable = new PasswordWidget('x', revealable: TRUE); - $this->assertTrue($revealable->rendersHint()); - $this->assertStringContainsString('tab reveal', $revealable->view($theme)); + $revealable = array_map(static fn(Hint $hint): string => $hint->label, (new PasswordWidget('x', revealable: TRUE))->hints()); + $this->assertContains('reveal', $revealable); - $plain = new PasswordWidget('x'); - $this->assertFalse($plain->rendersHint()); - $this->assertStringNotContainsString('tab reveal', $plain->view($theme)); + $plain = array_map(static fn(Hint $hint): string => $hint->label, (new PasswordWidget('x'))->hints()); + $this->assertNotContains('reveal', $plain); } public function testConfirmAcceptsMatchingEntries(): void { diff --git a/tests/phpunit/Unit/Widget/PauseWidgetTest.php b/tests/phpunit/Unit/Widget/PauseWidgetTest.php index c4f29ad..ea23fbb 100644 --- a/tests/phpunit/Unit/Widget/PauseWidgetTest.php +++ b/tests/phpunit/Unit/Widget/PauseWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; @@ -61,4 +62,10 @@ public function testCancelAndView(): void { $this->assertTrue($widget->isCancelled()); } + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new PauseWidget())->hints()); + + $this->assertSame(['continue', 'cancel'], $labels); + } + } diff --git a/tests/phpunit/Unit/Widget/SearchWidgetTest.php b/tests/phpunit/Unit/Widget/SearchWidgetTest.php index 7ce2e05..765c04e 100644 --- a/tests/phpunit/Unit/Widget/SearchWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SearchWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; @@ -231,4 +232,10 @@ public function testPagingFollowsCursorDownTheList(): void { $this->assertStringNotContainsString('Apple', $view); } + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SearchWidget($this->labels))->hints()); + + $this->assertSame(['move', 'accept', 'cancel'], $labels); + } + } diff --git a/tests/phpunit/Unit/Widget/SelectWidgetTest.php b/tests/phpunit/Unit/Widget/SelectWidgetTest.php index 5f05895..f409426 100644 --- a/tests/phpunit/Unit/Widget/SelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SelectWidgetTest.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\Option; use DrevOps\Tui\Config\OptionKind; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; @@ -163,4 +164,10 @@ public function testPagesLongOptionList(): void { $this->assertStringNotContainsString('Apple', $scrolled); } + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SelectWidget(['a' => 'A']))->hints()); + + $this->assertSame(['move', 'accept', 'cancel'], $labels); + } + } diff --git a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php index 2a7c55a..ac8a847 100644 --- a/tests/phpunit/Unit/Widget/SuggestWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SuggestWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; @@ -154,4 +155,10 @@ public function testPagingFollowsHighlightDownTheList(): void { $this->assertStringNotContainsString('one', $view); } + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new SuggestWidget(['UTC', 'GMT']))->hints()); + + $this->assertSame(['move', 'accept', 'cancel'], $labels); + } + } diff --git a/tests/phpunit/Unit/Widget/TextWidgetTest.php b/tests/phpunit/Unit/Widget/TextWidgetTest.php index 3b6b907..b60587f 100644 --- a/tests/phpunit/Unit/Widget/TextWidgetTest.php +++ b/tests/phpunit/Unit/Widget/TextWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; @@ -90,9 +91,11 @@ public function testSpaceInsertsSpace(): void { $this->assertSame('a b', $value); } - public function testDoesNotRenderOwnHint(): void { - // A plain widget leaves the editor chrome to supply the accept/cancel hint. - $this->assertFalse((new TextWidget())->rendersHint()); + public function testHints(): void { + // A plain widget contributes the shared accept/cancel hints. + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new TextWidget())->hints()); + + $this->assertSame(['accept', 'cancel'], $labels); } public function testGhostTextRendersDimmedSuffix(): void { diff --git a/tests/phpunit/Unit/Widget/TextareaWidgetTest.php b/tests/phpunit/Unit/Widget/TextareaWidgetTest.php index 94e2a1a..4246ff0 100644 --- a/tests/phpunit/Unit/Widget/TextareaWidgetTest.php +++ b/tests/phpunit/Unit/Widget/TextareaWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; @@ -64,12 +65,9 @@ public function testUpFromLongerLineClampsColumn(): void { $this->assertSame("aZ\nlonger", $widget->value()); } - public function testViewShowsHintAndError(): void { + public function testViewShowsError(): void { $widget = new TextareaWidget('x', fn(mixed $value): string => 'Nope.'); - $view = $widget->view(new DefaultTheme()); - $this->assertStringContainsString('tab accept', $view); - $widget->handle(Key::named(KeyName::Tab)); $this->assertStringContainsString('Nope.', $widget->view(new DefaultTheme())); } @@ -83,10 +81,10 @@ public function testCancel(): void { $this->assertNull($value); } - public function testRendersOwnHint(): void { - // Its view already shows "enter newline / tab accept", so the editor chrome - // must not add the generic "enter accept" hint on top. - $this->assertTrue((new TextareaWidget('x'))->rendersHint()); + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new TextareaWidget('x'))->hints()); + + $this->assertSame(['newline', 'accept', 'cancel'], $labels); } public function testEditorKeyRequestsHandoffWhenEnabled(): void { @@ -141,9 +139,12 @@ public function testApplyExternalEditRunsValidator(): void { $this->assertStringContainsString('Nope.', $widget->view(new DefaultTheme())); } - public function testViewShowsEditorHintOnlyWhenEnabled(): void { - $this->assertStringContainsString('ctrl-e editor', (new TextareaWidget('x', externalEdit: TRUE))->view(new DefaultTheme())); - $this->assertStringNotContainsString('ctrl-e editor', (new TextareaWidget('x'))->view(new DefaultTheme())); + public function testEditorHintOnlyWhenEnabled(): void { + $enabled = array_map(static fn(Hint $hint): string => $hint->label, (new TextareaWidget('x', externalEdit: TRUE))->hints()); + $this->assertContains('editor', $enabled); + + $disabled = array_map(static fn(Hint $hint): string => $hint->label, (new TextareaWidget('x'))->hints()); + $this->assertNotContains('editor', $disabled); } } diff --git a/tests/phpunit/Unit/Widget/ToggleWidgetTest.php b/tests/phpunit/Unit/Widget/ToggleWidgetTest.php index 1c6549b..7fef043 100644 --- a/tests/phpunit/Unit/Widget/ToggleWidgetTest.php +++ b/tests/phpunit/Unit/Widget/ToggleWidgetTest.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Tests\Unit\Widget; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; @@ -136,4 +137,10 @@ public function testFlipWithoutOptionsIsSafe(): void { $this->assertSame('', $widget->value()); } + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new ToggleWidget(['on' => 'On', 'off' => 'Off']))->hints()); + + $this->assertSame(['toggle', 'accept', 'cancel'], $labels); + } + } diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index 23789af..24941ea 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -10,6 +10,7 @@ use DrevOps\Tui\Config\NumberBounds; use DrevOps\Tui\Config\Option; use DrevOps\Tui\Config\OptionKind; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; @@ -79,8 +80,9 @@ public function testPasswordFlagsPassedThrough(): void { $widget = (new WidgetFactory())->create($field, 'secret'); - // Revealable shows through the widget owning its own hint line. - $this->assertTrue($widget->rendersHint()); + // Revealable shows through the reveal hint the widget contributes. + $labels = array_map(static fn(Hint $hint): string => $hint->label, $widget->hints()); + $this->assertContains('reveal', $labels); // Confirm shows through the two-step flow: the first Enter does not accept. $widget->handle(Key::named(KeyName::Enter)); @@ -104,8 +106,9 @@ public function testNumberBoundsPassedThrough(): void { $widget = (new WidgetFactory())->create($field, 5); - // Bounds show through the widget owning its own hint line and stepping. - $this->assertTrue($widget->rendersHint()); + // Bounds show through the adjust hint the widget contributes and stepping. + $labels = array_map(static fn(Hint $hint): string => $hint->label, $widget->hints()); + $this->assertContains('adjust', $labels); $widget->handle(Key::named(KeyName::Up)); $this->assertSame(6, $widget->value()); } From e412c0faa720b476dbf19510cddb540996a4eb58 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:21:00 +1000 Subject: [PATCH 2/5] [#12] Reused the shared accept/cancel hints via 'parent::hints()'. Each widget's hints() spreads the base accept/cancel fragments from 'AbstractWidget::hints()' rather than repeating them, so the shared tail lives in one place. The rendered footers are unchanged. --- src/Widget/ConfirmWidget.php | 2 +- src/Widget/MultiSelectWidget.php | 3 +-- src/Widget/NumberWidget.php | 2 +- src/Widget/SearchWidget.php | 2 +- src/Widget/SelectWidget.php | 2 +- src/Widget/SuggestWidget.php | 2 +- src/Widget/TextareaWidget.php | 2 +- src/Widget/ToggleWidget.php | 2 +- 8 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Widget/ConfirmWidget.php b/src/Widget/ConfirmWidget.php index fc8aeda..7d008ce 100644 --- a/src/Widget/ConfirmWidget.php +++ b/src/Widget/ConfirmWidget.php @@ -97,7 +97,7 @@ public function view(ThemeInterface $theme): string { */ #[\Override] public function hints(): array { - return [new Hint('yes/no', Action::Yes, Action::No), new Hint('toggle', Action::Toggle), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + return [new Hint('yes/no', Action::Yes, Action::No), new Hint('toggle', Action::Toggle), ...parent::hints()]; } } diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php index 8fada30..d7e959b 100644 --- a/src/Widget/MultiSelectWidget.php +++ b/src/Widget/MultiSelectWidget.php @@ -329,8 +329,7 @@ public function hints(): array { new Hint('select', Action::Toggle), new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('none/all', Action::SelectNone, Action::SelectAll), - new Hint('accept', Action::Accept), - new Hint('cancel', Action::Cancel), + ...parent::hints(), ]; } diff --git a/src/Widget/NumberWidget.php b/src/Widget/NumberWidget.php index cd10c47..3def2c7 100644 --- a/src/Widget/NumberWidget.php +++ b/src/Widget/NumberWidget.php @@ -138,7 +138,7 @@ public function hints(): array { return parent::hints(); } - return [new Hint('adjust', Action::Increment, Action::Decrement), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + return [new Hint('adjust', Action::Increment, Action::Decrement), ...parent::hints()]; } } diff --git a/src/Widget/SearchWidget.php b/src/Widget/SearchWidget.php index 6511ffc..41b0aba 100644 --- a/src/Widget/SearchWidget.php +++ b/src/Widget/SearchWidget.php @@ -208,7 +208,7 @@ protected function positionsFor(string $label): array { */ #[\Override] public function hints(): array { - return [new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + return [new Hint('move', Action::MoveUp, Action::MoveDown), ...parent::hints()]; } } diff --git a/src/Widget/SelectWidget.php b/src/Widget/SelectWidget.php index e11bfa1..fe7132b 100644 --- a/src/Widget/SelectWidget.php +++ b/src/Widget/SelectWidget.php @@ -139,7 +139,7 @@ public function view(ThemeInterface $theme): string { */ #[\Override] public function hints(): array { - return [new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + return [new Hint('move', Action::MoveUp, Action::MoveDown), ...parent::hints()]; } } diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php index 3e9e002..73e0fea 100644 --- a/src/Widget/SuggestWidget.php +++ b/src/Widget/SuggestWidget.php @@ -168,7 +168,7 @@ protected function positionsFor(string $value): array { */ #[\Override] public function hints(): array { - return [new Hint('move', Action::MoveUp, Action::MoveDown), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + return [new Hint('move', Action::MoveUp, Action::MoveDown), ...parent::hints()]; } } diff --git a/src/Widget/TextareaWidget.php b/src/Widget/TextareaWidget.php index 2606b28..024aeee 100644 --- a/src/Widget/TextareaWidget.php +++ b/src/Widget/TextareaWidget.php @@ -174,7 +174,7 @@ public function view(ThemeInterface $theme): string { */ #[\Override] public function hints(): array { - $hints = [new Hint('newline', Action::NewLine), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + $hints = [new Hint('newline', Action::NewLine), ...parent::hints()]; if ($this->externalEdit) { $hints[] = new Hint('editor', Action::ExternalEdit); diff --git a/src/Widget/ToggleWidget.php b/src/Widget/ToggleWidget.php index 2cd32e4..7ac18fa 100644 --- a/src/Widget/ToggleWidget.php +++ b/src/Widget/ToggleWidget.php @@ -143,7 +143,7 @@ public function view(ThemeInterface $theme): string { */ #[\Override] public function hints(): array { - return [new Hint('toggle', Action::Toggle), new Hint('accept', Action::Accept), new Hint('cancel', Action::Cancel)]; + return [new Hint('toggle', Action::Toggle), ...parent::hints()]; } } From 140a3ee5e0f067d912b157285fc717f975da815b Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 23:36:35 +1000 Subject: [PATCH 3/5] [#12] Adopted the hints() contract in the file picker and custom theme. 'FilePickerWidget' now contributes a 'hints()' list (single vs multiple mode) instead of the removed 'rendersHint()', and the custom-theme example overrides 'renderHints()' in place of the folded-in 'renderStatusLine()'. --- playground/2-custom-theme/OceanTheme.php | 10 ++-- src/Input/Hint.php | 6 +-- src/Widget/FilePickerWidget.php | 48 ++++++------------- src/Widget/WidgetInterface.php | 1 - .../Unit/Widget/FilePickerWidgetTest.php | 24 +++++++--- 5 files changed, 38 insertions(+), 51 deletions(-) diff --git a/playground/2-custom-theme/OceanTheme.php b/playground/2-custom-theme/OceanTheme.php index 3146407..0e190e0 100644 --- a/playground/2-custom-theme/OceanTheme.php +++ b/playground/2-custom-theme/OceanTheme.php @@ -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; @@ -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)); } diff --git a/src/Input/Hint.php b/src/Input/Hint.php index 698907e..6e1b0ce 100644 --- a/src/Input/Hint.php +++ b/src/Input/Hint.php @@ -10,9 +10,9 @@ * 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"). + * 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 */ diff --git a/src/Widget/FilePickerWidget.php b/src/Widget/FilePickerWidget.php index bc7edeb..2466632 100644 --- a/src/Widget/FilePickerWidget.php +++ b/src/Widget/FilePickerWidget.php @@ -7,6 +7,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\FilePickerMode; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Render\Ansi; @@ -244,17 +245,26 @@ public function view(ThemeInterface $theme): string { $lines[] = $theme->indicator(' ' . $theme->indicatorDown()); } - $lines[] = $this->hint($theme); - return implode("\n", $lines); } /** * {@inheritdoc} + * + * The Toggle fragment resolves only in multiple mode, where Space is bound to + * it; Accept reads "select" for a single pick and "accept" for multiple. */ #[\Override] - public function rendersHint(): bool { - return TRUE; + public function hints(): array { + return [ + new Hint('select', Action::Toggle), + new Hint('move', Action::MoveUp, Action::MoveDown), + new Hint('open', Action::MoveRight), + new Hint('up', Action::MoveLeft), + new Hint($this->multiple ? 'accept' : 'select', Action::Accept), + new Hint('hidden', Action::Reveal), + new Hint('cancel', Action::Cancel), + ]; } /** @@ -595,36 +605,6 @@ protected function crumb(): string { return $base . substr($this->cwd, strlen($this->root)); } - /** - * Build the key-hint line shown beneath the entry list. - * - * Every glyph is drawn from the live bindings, so the line stays truthful - * when the keys are remapped. The Toggle fragment only appears in multiple - * mode, where Space is bound to it; Accept reads "select" for a single pick - * and "accept" for a multiple one. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * - * @return string - * The themed, dot-joined hint line. - */ - protected function hint(ThemeInterface $theme): string { - $keys = $this->keys(); - - $fragments = array_filter([ - $theme->keysHint($keys, 'select', Action::Toggle), - $theme->keysHint($keys, 'move', Action::MoveUp, Action::MoveDown), - $theme->keysHint($keys, 'open', Action::MoveRight), - $theme->keysHint($keys, 'up', Action::MoveLeft), - $theme->keysHint($keys, $this->multiple ? 'accept' : 'select', Action::Accept), - $theme->keysHint($keys, 'hidden', Action::Reveal), - $theme->keysHint($keys, 'cancel', Action::Cancel), - ]); - - return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); - } - /** * Join an entry name onto the browsed directory. * diff --git a/src/Widget/WidgetInterface.php b/src/Widget/WidgetInterface.php index 0ec154b..0642259 100644 --- a/src/Widget/WidgetInterface.php +++ b/src/Widget/WidgetInterface.php @@ -4,7 +4,6 @@ namespace DrevOps\Tui\Widget; -use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Theme\ThemeInterface; diff --git a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php index f5ddc6a..099750e 100644 --- a/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php +++ b/tests/phpunit/Unit/Widget/FilePickerWidgetTest.php @@ -4,9 +4,11 @@ namespace DrevOps\Tui\Tests\Unit\Widget; +use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\FilePickerMode; use DrevOps\Tui\Input\ArrayKeyStream; use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Theme\DefaultTheme; @@ -283,7 +285,6 @@ public function testRootBreadcrumb(): void { public function testNonexistentStartIsEmpty(): void { $widget = new FilePickerWidget($this->root . '/missing'); - $this->assertTrue($widget->rendersHint()); $this->assertSame('', $widget->value()); $this->assertStringContainsString('(empty)', $this->render($widget)); } @@ -349,9 +350,6 @@ public function testAsciiRendering(): void { // The cursor row carries the ASCII marker; directories carry a slash. $this->assertStringContainsString('> docs/', $view); $this->assertStringContainsString('src/', $view); - // The hint line advertises the navigation keys. - $this->assertStringContainsString('open', $view); - $this->assertStringContainsString('tab hidden', $view); } public function testMultipleAsciiCheckboxes(): void { @@ -362,8 +360,22 @@ public function testMultipleAsciiCheckboxes(): void { $widget->handle(Key::named(KeyName::Space)); $this->assertStringContainsString('[x] docs/', $widget->view($theme)); - // The multiple-mode hint leads with the toggle key. - $this->assertStringContainsString('space select', $widget->view($theme)); + } + + public function testHintsRenderPerMode(): void { + $theme = new DefaultTheme(76, ['unicode' => FALSE, 'color' => FALSE]); + + // A single picker binds no toggle key, so that fragment drops and Accept + // reads "select"; the browse and hidden fragments are always present. + $single = Ansi::strip($theme->renderHints(KeyMapManager::create()->forField(FieldType::FilePicker), ...(new FilePickerWidget($this->root))->hints())); + $this->assertStringNotContainsString('space select', $single); + $this->assertStringContainsString('open', $single); + $this->assertStringContainsString('tab hidden', $single); + + // Multiple mode leads with the toggle key and Accept reads "accept". + $multiple = Ansi::strip($theme->renderHints(KeyMapManager::create()->forField(FieldType::MultiFilePicker), ...(new FilePickerWidget($this->root, multiple: TRUE))->hints())); + $this->assertStringContainsString('space select', $multiple); + $this->assertStringContainsString('accept', $multiple); } public function testScrollsLargeDirectory(): void { From 1787e116a92e91e6c641128c459dd6b06ab9d5bd Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 11 Jul 2026 09:12:22 +1000 Subject: [PATCH 4/5] [#12] Deduplicated help sections by enum case, not its value. --- src/Render/PanelController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php index 1228e56..937b25e 100644 --- a/src/Render/PanelController.php +++ b/src/Render/PanelController.php @@ -491,11 +491,11 @@ protected function helpSections(): array { $seen = []; foreach ($this->config->fields() as $field) { - if (isset($seen[$field->type->value])) { + if (in_array($field->type, $seen, TRUE)) { continue; } - $seen[$field->type->value] = TRUE; + $seen[] = $field->type; $widget = $this->widgets->create($field, $this->values[$field->id] ?? $field->default); $sections[] = new HelpSection(ucfirst($field->type->value), $this->keymap->forField($field->type), ...$widget->hints()); } From 55e30be601939d8e1fde0554f3701dfccaea8e82 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Mon, 13 Jul 2026 10:27:59 +1000 Subject: [PATCH 5/5] [#12] Adopted the hints() contract in the date picker widget. 'DateWidget' contributes a 'hints()' list (day/week motion) instead of the removed 'rendersHint()', and the widgets playground draws its own generic hint line rather than gating on it. --- playground/3-widgets/widgets.php | 8 +---- src/Widget/DateWidget.php | 35 ++++++-------------- tests/phpunit/Unit/Widget/DateWidgetTest.php | 14 +++----- 3 files changed, 16 insertions(+), 41 deletions(-) diff --git a/playground/3-widgets/widgets.php b/playground/3-widgets/widgets.php index 858feb1..343a32d 100644 --- a/playground/3-widgets/widgets.php +++ b/playground/3-widgets/widgets.php @@ -48,13 +48,7 @@ try { while (!$widget->isComplete() && !$widget->isCancelled()) { $lines = [$theme->renderEditorHeader($label . ' widget')]; - - // A widget that draws its own key hints (like the date calendar) gets no - // generic hint line, so the two cannot contradict. - if (!$widget->rendersHint()) { - $lines[] = $theme->renderHintLine(...$hints); - } - + $lines[] = $theme->renderHintLine(...$hints); $lines[] = ''; $lines[] = $widget->view($theme); $terminal->render(implode("\n", $lines)); diff --git a/src/Widget/DateWidget.php b/src/Widget/DateWidget.php index aedabcb..65a5145 100644 --- a/src/Widget/DateWidget.php +++ b/src/Widget/DateWidget.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Weekday; use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Input\Scope; @@ -158,7 +159,7 @@ protected function liveValue(): mixed { * {@inheritdoc} */ public function view(ThemeInterface $theme): string { - $rows = array_merge([$this->heading($theme), $this->weekdayRow($theme)], $this->weekRows($theme), [$this->hint($theme)]); + $rows = array_merge([$this->heading($theme), $this->weekdayRow($theme)], $this->weekRows($theme)); if ($this->error !== NULL) { $rows[] = $theme->error($this->error); @@ -169,10 +170,17 @@ public function view(ThemeInterface $theme): string { /** * {@inheritdoc} + * + * Month (PgUp/PgDn) and month-edge (Home/End) jumps have no action of their + * own, so the footer advertises the binding-driven day/week motion. */ #[\Override] - public function rendersHint(): bool { - return TRUE; + public function hints(): array { + return [ + new Hint('day', Action::MoveLeft, Action::MoveRight), + new Hint('week', Action::MoveUp, Action::MoveDown), + ...parent::hints(), + ]; } /** @@ -259,25 +267,4 @@ protected function dayCell(ThemeInterface $theme, \DateTimeImmutable $date, int return $this->bounds->contains($date) ? $cell : $theme->description($cell); } - /** - * Build the key-hint line shown beneath the calendar. - * - * @param \DrevOps\Tui\Theme\ThemeInterface $theme - * The theme. - * - * @return string - * The themed, dot-joined hint line. - */ - protected function hint(ThemeInterface $theme): string { - $fragments = [ - $theme->arrowLeft() . '/' . $theme->arrowRight() . ' day', - $theme->arrowUp() . '/' . $theme->arrowDown() . ' week', - 'PgUp/PgDn month', - $theme->enter() . ' accept', - 'esc cancel', - ]; - - return $theme->footer(implode(' ' . $theme->dot() . ' ', $fragments)); - } - } diff --git a/tests/phpunit/Unit/Widget/DateWidgetTest.php b/tests/phpunit/Unit/Widget/DateWidgetTest.php index 7bba16d..a9cd2be 100644 --- a/tests/phpunit/Unit/Widget/DateWidgetTest.php +++ b/tests/phpunit/Unit/Widget/DateWidgetTest.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Weekday; use DrevOps\Tui\Input\ArrayKeyStream; +use DrevOps\Tui\Input\Hint; use DrevOps\Tui\Input\Key; use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; @@ -174,17 +175,10 @@ public function testRendersCalendar(): void { $this->assertMatchesRegularExpression('/Mo\s+Tu\s+We\s+Th\s+Fr\s+Sa\s+Su/', $view); } - public function testRendersOwnHint(): void { - $widget = new DateWidget('2026-07-15'); - - $this->assertTrue($widget->rendersHint()); + public function testHints(): void { + $labels = array_map(static fn(Hint $hint): string => $hint->label, (new DateWidget('2026-07-15'))->hints()); - $view = Ansi::strip($widget->view(new DefaultTheme())); - $this->assertStringContainsString('day', $view); - $this->assertStringContainsString('week', $view); - $this->assertStringContainsString('month', $view); - $this->assertStringContainsString('accept', $view); - $this->assertStringContainsString('cancel', $view); + $this->assertSame(['day', 'week', 'accept', 'cancel'], $labels); } public function testWeekStartRotatesHeaderAndLayout(): void {