From a72f89bd91e5255a4c23809b8306096c9e3a74d1 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 21:39:16 +1000 Subject: [PATCH 1/2] [#14] Added configurable key bindings with a vim preset. --- README.md | 48 ++- docs/architecture/README.md | 4 +- docs/architecture/architecture.puml | 8 + docs/architecture/architecture.svg | 2 +- docs/architecture/dataflow-tui.puml | 8 +- docs/architecture/dataflow-tui.svg | 2 +- playground/2-custom-theme/OceanTheme.php | 12 +- playground/8-key-bindings/run.php | 84 +++++ playground/README.md | 37 ++ src/Builder/Form.php | 38 +++ src/Config/Config.php | 6 + src/Input/Action.php | 56 ++++ src/Input/Binding.php | 46 +++ src/Input/DefaultKeyMap.php | 74 ++++ src/Input/Key.php | 23 ++ src/Input/KeyMap.php | 315 ++++++++++++++++++ src/Input/KeyMapManager.php | 85 +++++ src/Input/Scope.php | 146 ++++++++ src/Input/ScopedKeyMap.php | 77 +++++ src/Input/VimKeyMap.php | 39 +++ src/Render/PanelController.php | 42 ++- src/Theme/DefaultTheme.php | 77 ++++- src/Theme/ThemeInterface.php | 34 ++ src/Widget/AbstractWidget.php | 49 ++- src/Widget/ConfirmWidget.php | 52 ++- src/Widget/MultiSelectWidget.php | 48 ++- src/Widget/PauseWidget.php | 21 +- src/Widget/SearchWidget.php | 14 +- src/Widget/SelectWidget.php | 10 +- src/Widget/SuggestWidget.php | 14 +- src/Widget/TextWidget.php | 14 +- src/Widget/TextareaWidget.php | 52 +-- src/Widget/WidgetFactory.php | 18 +- src/Widget/WidgetInterface.php | 12 + tests/phpunit/Unit/Builder/FormTest.php | 36 ++ tests/phpunit/Unit/Input/KeyMapTest.php | 229 +++++++++++++ tests/phpunit/Unit/Input/KeyStreamTest.php | 14 + tests/phpunit/Unit/Theme/ThemeRenderTest.php | 52 ++- tests/phpunit/Unit/Widget/PauseWidgetTest.php | 6 +- .../phpunit/Unit/Widget/SelectWidgetTest.php | 13 + .../phpunit/Unit/Widget/WidgetFactoryTest.php | 17 +- 41 files changed, 1800 insertions(+), 134 deletions(-) create mode 100644 playground/8-key-bindings/run.php create mode 100644 src/Input/Action.php create mode 100644 src/Input/Binding.php create mode 100644 src/Input/DefaultKeyMap.php create mode 100644 src/Input/KeyMap.php create mode 100644 src/Input/KeyMapManager.php create mode 100644 src/Input/Scope.php create mode 100644 src/Input/ScopedKeyMap.php create mode 100644 src/Input/VimKeyMap.php create mode 100644 tests/phpunit/Unit/Input/KeyMapTest.php diff --git a/README.md b/README.md index d0d3e53..95c81ec 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ It powers the [Vortex](https://www.vortextemplate.com) project installer, but kn - ⚙️ [**Declared behaviour**](#field-behaviour) - validation, transforms and dynamic defaults as closures on the field; per-field handler classes remain as a fallback - 📦 [**Self-describing answers**](#self-describing-answers) - each answer carries a snapshot of its question and its provenance; summaries need no form config - 🎨 [**Themes**](#themes) - the whole visual representation (colours, glyphs, layout) is a theme class; ships with dark and light +- ⌨️ [**Key bindings**](#key-bindings) - remap navigation, edit, accept and cancel keys per widget type; ships a vim-style preset, and a bad binding fails loudly at build time - ✨ [**Unicode and ASCII**](#display-modes) - glyphs follow the terminal locale and colour honours `NO_COLOR`; both can be forced on the form ## Installation @@ -411,7 +412,7 @@ $p->pause('ready', 'Review the summary above'); ## Panels and navigation -The interactive TUI is a full-screen panel browser: the root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field (or drills into a sub-panel), Esc goes back, `q` quits, and the mouse wheel scrolls long panels without moving the cursor. **Submit** and **Cancel** buttons live on the root panel - `->buttons(FALSE)` hides them, `->buttons(TRUE, 'Save', 'Discard')` relabels them. +The interactive TUI is a full-screen panel browser: the root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field (or drills into a sub-panel), Esc goes back, `q` quits, and the mouse wheel scrolls long panels without moving the cursor - all of these keys are configurable (see [Key bindings](#key-bindings)). **Submit** and **Cancel** buttons live on the root panel - `->buttons(FALSE)` hides them, `->buttons(TRUE, 'Save', 'Discard')` relabels them. A form-level `->banner()` shows a start screen (with an optional version) before the panels, and `->clearOnExit(FALSE)` keeps the final frame on screen after the TUI exits. @@ -606,9 +607,52 @@ Or register a short alias with `ThemeManager::register('ocean', OceanTheme::clas Custom ocean theme with a banner

+## Key bindings + +Navigation, edit, accept and cancel keys are configurable. A widget asks for a semantic **action** - `MoveUp`, `Accept`, `Toggle`, `NewLine` and so on - rather than a fixed key, and a **key map** binds each action to one or more keys. Set it on the form with `->keys(...)`, mirroring `->theme(...)`: + +```php +$form = Form::create('My form')->keys('vim'); // built-in vim navigation (h/j/k/l) +``` + +Two presets ship: `default` (the bindings described in [Panels and navigation](#panels-and-navigation)) and `vim`, which adds `h`/`j`/`k`/`l` alongside the arrow keys - only where a letter is not typed input, so text and filter fields keep the arrows. + +### Per-widget-type overrides + +Bindings are layered by **scope**: a base layer shared by every widget, a navigation layer for the panel browser, and one layer per widget type that overrides the base only where it differs (Enter inserts a newline in a textarea, Space toggles a checkbox option). Retune individual bindings by passing overrides on top of a preset - each names a scope, an action and its keys: + +```php +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Binding; +use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; + +$form = Form::create('My form')->keys('default', [ + // Quit with x as well as q. + new Binding(Scope::navigation(), Action::Quit, 'x'), + // In the single-choice list, Tab accepts too. + new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter), +]); +``` + +A binding's keys accept a `KeyName` for a named key or a single-character string for a printable one. The panel and editor hints are drawn from the live bindings, so they always reflect the active keys. + +### Presets and validation + +A preset is a class listing its bindings. Subclass `DefaultKeyMap` to ship your own, name it directly with `->keys('\App\MyKeyMap')`, or register a short alias with `KeyMapManager::register('mine', MyKeyMap::class)` and then `->keys('mine')`. + +Bindings are validated when the form is built, so a bad key map is caught at declaration time, not mid-session: + +- a key bound to two different actions in the same scope is a conflict; +- a printable character bound in the base scope, or in a scope whose widget consumes typed input (text, search, checkbox), would be un-typeable and is rejected; +- an unknown preset name, or a character binding that is not exactly one character, is rejected. + +See [`playground/8-key-bindings`](playground/8-key-bindings) for the default map, the vim preset and a custom override side by side. + ## Playground -Runnable, self-contained examples are in [`playground/`](playground): a minimal form, a full "package scaffolder", a custom-theme demo, per-widget demos, nested panels with fix-ups, update-mode discovery, and a theme auto-detection demo. Each is independent - copy one as a starting point. +Runnable, self-contained examples are in [`playground/`](playground): a minimal form, a full "package scaffolder", a custom-theme demo, per-widget demos, nested panels with fix-ups, update-mode discovery, theme auto-detection, theme options, and a configurable key-bindings demo. Each is independent - copy one as a starting point. The SVG demos on this page are generated from the playground scripts with `php docs/util/update-assets.php` (requires `asciinema`, `expect`, `node` and `npm`), which records each demo through a scripted terminal session and renders the recordings to SVG. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b3baf2f..2f23896 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,7 +12,7 @@ Read it in three bands: - **Left - what you provide.** A **Config** (assembled by the fluent `Form` builder into `Config` -> `Panel` -> `Field`) and, optionally, **Handlers** (classes that carry behaviour). Together these declare the questions and how each one behaves. - **Middle - the Engine and its helpers.** The Engine drives collection, leaning on `InputResolver` (read a payload), `Discovery` (detect from the directory), `Deriver` + `Transform` (compute values), and `ConditionEvaluator` (decide what is shown). -- **Right - what comes out, and how it is shown.** `Answers` (plus a `SchemaGenerator` / `SchemaValidator` for agents and forms), and the **interactive TUI** - `PanelController` composing a `Theme` (resolved by name through `ThemeManager`), widgets, a `Navigator` and a `Terminal`. +- **Right - what comes out, and how it is shown.** `Answers` (plus a `SchemaGenerator` / `SchemaValidator` for agents and forms), and the **interactive TUI** - `PanelController` composing a `Theme` (resolved by name through `ThemeManager`), a `KeyMap` (resolved by preset through `KeyMapManager`), widgets, a `Navigator` and a `Terminal`. ## Step 1 - describe the questions @@ -43,7 +43,7 @@ For interactive use, `PanelController::run()` seeds itself with the engine's res ![Interactive panel TUI](dataflow-tui.svg) -The theme instance comes from `ThemeManager` - a registry keyed by name ("dark", "light", or a registered custom class) that also detects the terminal's colour and Unicode capabilities and, when no theme is named, picks light or dark from the terminal background (an OSC 11 query answered by the `Terminal`, then `COLORFGBG`, then a dark default). Each turn the controller asks the **Theme** to compose a frame (the theme owns colours, glyphs and layout), computes the visible window with the `Navigator` and `Scroller`, and renders it to the `Terminal`. A key press is parsed by `KeyParser`; the controller either moves the cursor / drills into a sub-panel, or opens a widget to edit a field - widgets also render themselves through the theme, under a theme-composed underlined label header. Editing writes the new value back and marks it "edited". When the user finishes, it returns the same `Answers` object the headless path produces. +The theme instance comes from `ThemeManager` - a registry keyed by name ("dark", "light", or a registered custom class) that also detects the terminal's colour and Unicode capabilities and, when no theme is named, picks light or dark from the terminal background (an OSC 11 query answered by the `Terminal`, then `COLORFGBG`, then a dark default). Each turn the controller asks the **Theme** to compose a frame (the theme owns colours, glyphs and layout), computes the visible window with the `Navigator` and `Scroller`, and renders it to the `Terminal`. A key press is parsed by `KeyParser` into a `Key`, which a **KeyMap** resolves to a semantic action (move, accept, toggle, quit...) rather than a fixed key - the bindings behind each action are configurable per widget type, ship a vim preset alongside the default, and are validated when the form is built. Armed with the action, the controller either moves the cursor / drills into a sub-panel, or opens a widget to edit a field - the widget consults the same key map, and both render themselves through the theme, under a theme-composed underlined label header. Editing writes the new value back and marks it "edited". When the user finishes, it returns the same `Answers` object the headless path produces. ## Step 5 - apply the answers (the consumer's job) diff --git a/docs/architecture/architecture.puml b/docs/architecture/architecture.puml index 17ebfe1..f9f41b5 100644 --- a/docs/architecture/architecture.puml +++ b/docs/architecture/architecture.puml @@ -47,6 +47,8 @@ package "Interactive TUI" #FFEBEE { [PanelController] [ThemeManager] [Theme] + [KeyMapManager] + [KeyMap] [WidgetFactory] interface WidgetInterface [Navigator] @@ -74,9 +76,15 @@ package "Interactive TUI" #FFEBEE { [PanelController] --> [Config] [ThemeManager] --> [Theme] [PanelController] --> [Theme] +[Form] --> [KeyMapManager] +[KeyMapManager] --> [KeyMap] +[Config] --> [KeyMap] +[PanelController] --> [KeyMap] [PanelController] --> [WidgetFactory] +[WidgetFactory] --> [KeyMap] [WidgetFactory] --> WidgetInterface WidgetInterface --> [Theme] +WidgetInterface --> [KeyMap] [PanelController] --> [Navigator] [PanelController] --> [Terminal] [PanelController] --> [KeyParser] diff --git a/docs/architecture/architecture.svg b/docs/architecture/architecture.svg index 54c74d7..c08d1b0 100644 --- a/docs/architecture/architecture.svg +++ b/docs/architecture/architecture.svg @@ -1 +1 @@ -drevops/tui - component architectureBuilderConfigurationCoreResolutionHandlersOutputInteractive TUIFormConfigPanelFieldEngineInputResolverDiscover specsDeriverDeriveTransformConditionHandlerRegistryContextAnswersSchemaGeneratorSchemaValidatorPanelControllerThemeManagerThemeWidgetFactoryWidgetInterfaceNavigatorTerminalKeyParser \ No newline at end of file +drevops/tui - component architectureBuilderConfigurationCoreResolutionHandlersOutputInteractive TUIFormConfigPanelFieldEngineInputResolverDiscover specsDeriverDeriveTransformConditionHandlerRegistryContextAnswersSchemaGeneratorSchemaValidatorPanelControllerThemeManagerThemeKeyMapManagerKeyMapWidgetFactoryWidgetInterfaceNavigatorTerminalKeyParser \ No newline at end of file diff --git a/docs/architecture/dataflow-tui.puml b/docs/architecture/dataflow-tui.puml index 9c4be64..1755cee 100644 --- a/docs/architecture/dataflow-tui.puml +++ b/docs/architecture/dataflow-tui.puml @@ -1,6 +1,7 @@ @startuml ' drevops/tui - data flow for the interactive panel TUI. -' Traced from src/Render/PanelController.php (run -> frame/handle) and src/Theme/. +' Traced from src/Render/PanelController.php (run -> frame/handle), src/Theme/ +' and src/Input/ (KeyMap resolves a key press to a semantic action). ' Regenerate every SVG with: plantuml -tsvg docs/architecture/*.puml !theme plain skinparam backgroundColor white @@ -14,6 +15,7 @@ participant "Terminal" as Term participant "Theme" as Theme participant "Navigator\n+ Scroller" as Nav participant "KeyParser" as KP +participant "KeyMap\n(scoped)" as KM participant "Widget\n(via factory)" as W PC -> Term: setup() raw mode, alt screen @@ -38,8 +40,12 @@ loop until done alt editing a field PC -> W: handle(key) + W -> KM: matches(key, action)? + KM --> W: bound action (accept, move, toggle...) W --> PC: value, complete or cancel else navigating + PC -> KM: matches(key, action)? + KM --> PC: bound action (move, activate, back, quit) note right of PC: move cursor, drill panel, or open editor end end diff --git a/docs/architecture/dataflow-tui.svg b/docs/architecture/dataflow-tui.svg index d7dedee..0c5c669 100644 --- a/docs/architecture/dataflow-tui.svg +++ b/docs/architecture/dataflow-tui.svg @@ -1 +1 @@ -Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserWidgetUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserWidget(via factory)Widget(via factory)PanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]body(panel, answers, cursor)rows + cursor linecompute viewportvisible windowframe(header, body, footer, viewport)frame textrender(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)value, complete or cancel[navigating]move cursor, drill panel, or open editorrestore()Answers \ No newline at end of file +Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)PanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]body(panel, answers, cursor)rows + cursor linecompute viewportvisible windowframe(header, body, footer, viewport)frame textrender(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancel[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, or open editorrestore()Answers \ No newline at end of file diff --git a/playground/2-custom-theme/OceanTheme.php b/playground/2-custom-theme/OceanTheme.php index 0425614..3146407 100644 --- a/playground/2-custom-theme/OceanTheme.php +++ b/playground/2-custom-theme/OceanTheme.php @@ -7,6 +7,8 @@ use DrevOps\Tui\Answers\Answers; use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\Panel; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Render\Navigator; use DrevOps\Tui\Theme\DefaultTheme; @@ -241,10 +243,16 @@ public function renderBreadcrumbLine(Navigator $navigator): string { * {@inheritdoc} */ #[\Override] - public function renderStatusLine(): string { + public function renderStatusLine(ScopedKeyMap $nav): string { $sep = ' ' . $this->dot() . ' '; - return $this->footer($this->arrowUp() . $this->arrowDown() . ' move' . $sep . $this->enter() . ' choose' . $sep . 'esc back'); + $fragments = array_filter([ + $this->keysHint($nav, 'move', Action::MoveUp, Action::MoveDown), + $this->keysHint($nav, 'choose', Action::Activate), + $this->keysHint($nav, 'back', Action::Back), + ]); + + return $this->footer(implode($sep, $fragments)); } /** diff --git a/playground/8-key-bindings/run.php b/playground/8-key-bindings/run.php new file mode 100644 index 0000000..ffeb919 --- /dev/null +++ b/playground/8-key-bindings/run.php @@ -0,0 +1,84 @@ +panel('profile', 'Profile', function (PanelBuilder $p): void { + $p->text('name', 'Name')->default('Ada'); + $p->select('role', 'Role')->default('dev')->options([ + 'dev' => 'Developer', + 'ops' => 'Operations', + 'design' => 'Designer', + ]); + $p->confirm('newsletter', 'Subscribe?')->default(TRUE); + $p->multiselect('langs', 'Languages')->options([ + 'php' => 'PHP', + 'js' => 'JavaScript', + 'go' => 'Go', + ]); + }); + +// Three ways to set the bindings, selected with --keys. The hints at the foot +// of the panel and editor follow whatever is bound, so they always tell the +// truth about the active keys. +if ($which === 'vim') { + // The built-in vim preset: h/j/k/l navigate alongside the arrows. Letters are + // added only where they are not typed input, so text and filter fields keep + // the arrow keys. + $form->keys('vim'); +} +elseif ($which === 'custom') { + // Start from the default preset and retune two bindings. Each override names + // a scope, an action and its keys; a conflicting or un-typeable binding + // throws when the form is built, not mid-session. + $form->keys('default', [ + // Quit with x as well as q. + new Binding(Scope::navigation(), Action::Quit, 'x'), + // In the single-choice list, Tab accepts too (Enter still does). + new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter), + ]); +} +// The default preset applies when --keys is default (no ->keys() call needed). +try { + $answers = (new Tui($form))->run($prompts, '1.0.0'); +} +catch (EngineException $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); +} + +echo $answers->toSummary() . PHP_EOL; diff --git a/playground/README.md b/playground/README.md index b697acd..125e094 100644 --- a/playground/README.md +++ b/playground/README.md @@ -87,6 +87,25 @@ composer install php playground/6-theme-detect/run.php --theme=light # force light ``` +- **[`7-theme-options/`](7-theme-options)** - the display options (`spacing`, + `border`) and a custom `accent` option declared by a registered theme, all set + as plain strings in one array. Each value is validated, so a typo throws. + + ```bash + php playground/7-theme-options/run.php + ``` + +- **[`8-key-bindings/`](8-key-bindings)** - the same form driven with three key + maps, selected with `--keys`: the default bindings, the built-in `vim` preset + (h/j/k/l), and a custom per-widget-type override. The panel and editor hints + follow whatever is bound. + + ```bash + php playground/8-key-bindings/run.php # default (arrow keys) + php playground/8-key-bindings/run.php --keys=vim # h/j/k/l navigation + php playground/8-key-bindings/run.php --keys=custom + ``` + ## How a form picks a theme Set it on the builder with `->theme(...)`, lowest friction first: @@ -100,3 +119,21 @@ Set it on the builder with `->theme(...)`, lowest friction first: picks `dark` or `light` from the terminal background (an OSC 11 query, then `COLORFGBG`, then a dark default). Forcing a built-in opts out. This is what `6-theme-detect` demonstrates. + +## How a form sets key bindings + +Set them on the builder with `->keys(...)`, mirroring `->theme(...)`: + +1. **A preset name** - `->keys('vim')` for the built-in vim navigation, or a name + registered with `KeyMapManager::register('name', MyKeyMap::class)`. +2. **A preset class** - `->keys('\Your\KeyMapClass')`, instantiated directly with + no registration. +3. **Overrides** - `->keys('default', [new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab)])` + retunes individual bindings on top of a preset. A binding names a scope (the + base, navigation, or a widget type), an action and its keys. +4. **Defaults** - leave it unset for the built-in bindings. This is what most + examples do. + +Conflicting or un-typeable bindings throw when the form is built, so a bad key map +is caught at declaration time, not mid-session. This is what `8-key-bindings` +demonstrates. diff --git a/src/Builder/Form.php b/src/Builder/Form.php index 3fc6ec6..53b99de 100644 --- a/src/Builder/Form.php +++ b/src/Builder/Form.php @@ -10,6 +10,7 @@ use DrevOps\Tui\Config\Fixup; use DrevOps\Tui\Config\Option; use DrevOps\Tui\Config\Panel; +use DrevOps\Tui\Input\KeyMapManager; /** * A fluent builder declaring a form: its panels, fields and TUI options. @@ -30,6 +31,18 @@ final class Form { */ protected array $themeOptions = []; + /** + * The key-map preset name or class (empty for the default). + */ + protected string $keymap = ''; + + /** + * Bindings overriding the preset, applied on top of it. + * + * @var list<\DrevOps\Tui\Input\Binding> + */ + protected array $keymapOverrides = []; + /** * The start banner (logo). */ @@ -131,6 +144,30 @@ public function theme(string $theme, array $options = []): self { return $this; } + /** + * Set the key-binding preset and optional overrides. + * + * The preset names the base bindings ("default", "vim", a registered name, or + * a preset class); the overrides retune individual bindings on top of it, + * each a {@see \DrevOps\Tui\Input\Binding} naming a scope, an action and its + * keys. Conflicting, un-typeable or malformed bindings throw when the form is + * built, not mid-session. + * + * @param string $preset + * The preset name or class. Empty selects the default preset. + * @param list<\DrevOps\Tui\Input\Binding> $overrides + * Bindings applied on top of the preset. + * + * @return $this + * The builder. + */ + public function keys(string $preset = '', array $overrides = []): self { + $this->keymap = $preset; + $this->keymapOverrides = $overrides; + + return $this; + } + /** * Set the start banner. * @@ -287,6 +324,7 @@ public function build(): Config { $this->unicode, $this->envPrefix, $this->themeOptions, + KeyMapManager::create($this->keymap, $this->keymapOverrides), ); $this->assertUniqueFieldIds($config); diff --git a/src/Config/Config.php b/src/Config/Config.php index 24efb0c..14c0b2f 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -4,6 +4,8 @@ namespace DrevOps\Tui\Config; +use DrevOps\Tui\Input\KeyMap; + /** * The root configuration model: title, subject and a tree of panels. * @@ -45,6 +47,9 @@ * @param array $themeOptions * Display options passed to the interactive theme, keyed by name (e.g. * "spacing" and "border"; see ThemeInterface constants). + * @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. */ public function __construct( public string $title, @@ -61,6 +66,7 @@ public function __construct( public ?bool $unicode = NULL, public string $envPrefix = '', public array $themeOptions = [], + public ?KeyMap $keymap = NULL, ) { } diff --git a/src/Input/Action.php b/src/Input/Action.php new file mode 100644 index 0000000..e92c05d --- /dev/null +++ b/src/Input/Action.php @@ -0,0 +1,56 @@ + + */ + public array $keys; + + /** + * Construct a binding. + * + * @param \DrevOps\Tui\Input\Scope $scope + * The scope the binding applies in. + * @param \DrevOps\Tui\Input\Action $action + * The action the keys trigger. + * @param \DrevOps\Tui\Input\Key|\DrevOps\Tui\Input\KeyName|string ...$keys + * The keys bound to the action. + */ + public function __construct( + public Scope $scope, + public Action $action, + Key|KeyName|string ...$keys, + ) { + $this->keys = array_values($keys); + } + +} diff --git a/src/Input/DefaultKeyMap.php b/src/Input/DefaultKeyMap.php new file mode 100644 index 0000000..7064966 --- /dev/null +++ b/src/Input/DefaultKeyMap.php @@ -0,0 +1,74 @@ + + * The bindings, base first, then per-scope overrides. + */ + public function bindings(): array { + $base = Scope::base(); + + $bindings = [ + new Binding($base, Action::MoveUp, KeyName::Up), + new Binding($base, Action::MoveDown, KeyName::Down), + new Binding($base, Action::MoveLeft, KeyName::Left), + new Binding($base, Action::MoveRight, KeyName::Right), + new Binding($base, Action::Accept, KeyName::Enter), + new Binding($base, Action::Cancel, KeyName::Escape), + new Binding($base, Action::DeleteBack, KeyName::Backspace), + new Binding($base, Action::InsertSpace, KeyName::Space), + + new Binding(Scope::navigation(), Action::Activate, KeyName::Enter), + new Binding(Scope::navigation(), Action::Back, KeyName::Escape), + 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::field(FieldType::Textarea), Action::NewLine, KeyName::Enter), + new Binding(Scope::field(FieldType::Textarea), Action::Accept, KeyName::Tab), + // The textarea hands off to the user's $EDITOR on Ctrl-E. + new Binding(Scope::field(FieldType::Textarea), Action::ExternalEdit, Key::char("\x05")), + + new Binding(Scope::field(FieldType::Confirm), Action::Toggle, KeyName::Left, KeyName::Right, KeyName::Space, KeyName::Up, KeyName::Down), + new Binding(Scope::field(FieldType::Confirm), Action::Yes, 'y', 'Y'), + new Binding(Scope::field(FieldType::Confirm), Action::No, 'n', 'N'), + + // The password reveal toggle cycles the display mode on Tab. + new Binding(Scope::field(FieldType::Password), Action::Reveal, KeyName::Tab), + + new Binding(Scope::field(FieldType::Pause), Action::Accept, KeyName::Enter, KeyName::Space), + ]; + + // The checkbox and the searchable checkbox share one set of list bindings. + foreach ([FieldType::MultiSelect, FieldType::MultiSearch] as $type) { + $bindings[] = new Binding(Scope::field($type), Action::Toggle, KeyName::Space); + $bindings[] = new Binding(Scope::field($type), Action::SelectAll, KeyName::Right); + $bindings[] = new Binding(Scope::field($type), Action::SelectNone, KeyName::Left); + } + + return $bindings; + } + +} diff --git a/src/Input/Key.php b/src/Input/Key.php index bf4b9c6..d2e8919 100644 --- a/src/Input/Key.php +++ b/src/Input/Key.php @@ -62,4 +62,27 @@ public function is(KeyName $name): bool { return $this->name === $name; } + /** + * Whether this key is the same key as another. + * + * @param \DrevOps\Tui\Input\Key $other + * The key to compare. + * + * @return bool + * TRUE when both are the same named key or the same character. + */ + public function equals(Key $other): bool { + return $this->token() === $other->token(); + } + + /** + * A stable token identifying this key, usable as an array key. + * + * @return string + * The token. + */ + public function token(): string { + return $this->name instanceof KeyName ? 'name:' . $this->name->name : 'char:' . $this->char; + } + } diff --git a/src/Input/KeyMap.php b/src/Input/KeyMap.php new file mode 100644 index 0000000..ea34ac3 --- /dev/null +++ b/src/Input/KeyMap.php @@ -0,0 +1,315 @@ + + */ + protected array $fields = []; + + /** + * Resolve and validate a list of bindings into scoped maps. + * + * @param list<\DrevOps\Tui\Input\Binding> $bindings + * The bindings, a preset's defaults followed by any overrides. + * + * @throws \InvalidArgumentException + * When a binding conflicts, is un-typeable, or is malformed. + */ + public function __construct(array $bindings) { + $layers = $this->layer($bindings); + + $base_inverted = $this->invert($layers[Scope::base()->token()] ?? [], Scope::base()); + $this->assertTypeable($base_inverted, Scope::base()); + $this->base = $this->toScoped($base_inverted); + + $this->navigation = $this->buildScope($base_inverted, $layers, Scope::navigation()); + + foreach ($bindings as $binding) { + $token = $binding->scope->token(); + + if ($binding->scope->fieldType instanceof FieldType && !isset($this->fields[$token])) { + $this->fields[$token] = $this->buildScope($base_inverted, $layers, $binding->scope); + } + } + } + + /** + * The panel-navigation scope. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The navigation bindings. + */ + public function navigation(): ScopedKeyMap { + return $this->navigation; + } + + /** + * The scope for a widget type, or the base when the type has no overrides. + * + * @param \DrevOps\Tui\Config\FieldType $type + * The field type. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The bindings for that widget type. + */ + public function forField(FieldType $type): ScopedKeyMap { + return $this->fields[Scope::field($type)->token()] ?? $this->base; + } + + /** + * The bindings for a scope. + * + * @param \DrevOps\Tui\Input\Scope $scope + * The scope. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The bindings for that scope. + */ + public function scope(Scope $scope): ScopedKeyMap { + if ($scope->navigation) { + return $this->navigation; + } + + return $scope->fieldType instanceof FieldType ? $this->forField($scope->fieldType) : $this->base; + } + + /** + * Group bindings into per-scope, per-action layers, later bindings winning. + * + * @param list<\DrevOps\Tui\Input\Binding> $bindings + * The bindings. + * + * @return array}>> + * The layers, keyed by scope token then action name. + */ + protected function layer(array $bindings): array { + $layers = []; + + foreach ($bindings as $binding) { + $layers[$binding->scope->token()][$binding->action->name] = [ + 'action' => $binding->action, + 'keys' => $this->normalise($binding->keys, $binding->scope), + ]; + } + + return $layers; + } + + /** + * Build a scope by overlaying its own layer onto the base bindings. + * + * @param array $base_inverted + * The base bindings, inverted to key token => key/action. + * @param array}>> $layers + * All layers, keyed by scope token. + * @param \DrevOps\Tui\Input\Scope $scope + * The scope to build. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The resolved scope. + */ + protected function buildScope(array $base_inverted, array $layers, Scope $scope): ScopedKeyMap { + $scope_inverted = $this->invert($layers[$scope->token()] ?? [], $scope); + + $effective = $base_inverted; + foreach ($scope_inverted as $token => $entry) { + $effective[$token] = $entry; + } + + if ($scope->consumesText()) { + $this->assertTypeable($effective, $scope); + } + + return $this->toScoped($effective); + } + + /** + * Invert an action => keys layer to a key token => key/action map. + * + * @param array}> $layer + * The layer. + * @param \DrevOps\Tui\Input\Scope $scope + * The scope, for the conflict message. + * + * @return array + * The inverted map. + * + * @throws \InvalidArgumentException + * When one key is bound to two different actions in the layer. + */ + protected function invert(array $layer, Scope $scope): array { + $inverted = []; + + foreach ($layer as $entry) { + foreach ($entry['keys'] as $key) { + $token = $key->token(); + + if (isset($inverted[$token]) && $inverted[$token]['action'] !== $entry['action']) { + throw new \InvalidArgumentException(sprintf('Key "%s" is bound to both %s and %s in the %s scope.', $this->keyLabel($key), $inverted[$token]['action']->name, $entry['action']->name, $scope->label())); + } + + $inverted[$token] = ['key' => $key, 'action' => $entry['action']]; + } + } + + return $inverted; + } + + /** + * Assemble the two lookup tables a scoped map needs from an inverted map. + * + * @param array $inverted + * The inverted map. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The scoped map. + */ + protected function toScoped(array $inverted): ScopedKeyMap { + $by_key = []; + $by_action = []; + + foreach ($inverted as $token => $entry) { + $by_key[$token] = $entry['action']; + $by_action[$entry['action']->name][] = $entry['key']; + } + + return new ScopedKeyMap($by_key, $by_action); + } + + /** + * Reject printable-character bindings where they would be un-typeable. + * + * @param array $inverted + * The inverted bindings for the scope. + * @param \DrevOps\Tui\Input\Scope $scope + * The scope being checked. + * + * @throws \InvalidArgumentException + * When a printable character is bound in the base or a text-entry scope. + */ + protected function assertTypeable(array $inverted, Scope $scope): void { + foreach ($inverted as $entry) { + if (!$entry['key']->isChar()) { + continue; + } + + // Control characters (e.g. Ctrl-E) are command keys, never typed content, + // so they may be bound where a printable character may not. + if ($this->isControl((string) $entry['key']->char)) { + continue; + } + + if ($scope->fieldType instanceof FieldType) { + throw new \InvalidArgumentException(sprintf('The %s scope consumes typed characters, so the printable character "%s" cannot be bound to an action there.', $scope->label(), $this->keyLabel($entry['key']))); + } + + throw new \InvalidArgumentException(sprintf('The base scope may not bind the printable character "%s"; it would be un-typeable in text widgets. Bind it in a specific non-text scope instead.', $this->keyLabel($entry['key']))); + } + } + + /** + * Whether a single-byte character is a control character. + * + * @param string $char + * The character. + * + * @return bool + * TRUE for a control character (below the printable ASCII range). + */ + protected function isControl(string $char): bool { + return $char !== '' && ord($char) < 0x20; + } + + /** + * Normalise authored keys to Key objects. + * + * @param list<\DrevOps\Tui\Input\Key|\DrevOps\Tui\Input\KeyName|string> $keys + * The authored keys. + * @param \DrevOps\Tui\Input\Scope $scope + * The scope, for the error message. + * + * @return list<\DrevOps\Tui\Input\Key> + * The normalised keys. + * + * @throws \InvalidArgumentException + * When a character binding is not exactly one character. + */ + protected function normalise(array $keys, Scope $scope): array { + $out = []; + + foreach ($keys as $key) { + if ($key instanceof Key) { + $out[] = $key; + } + elseif ($key instanceof KeyName) { + $out[] = Key::named($key); + } + elseif (strlen($key) === 1) { + $out[] = Key::char($key); + } + else { + throw new \InvalidArgumentException(sprintf('A character binding in the %s scope must be a single character, got "%s".', $scope->label(), $key)); + } + } + + return $out; + } + + /** + * A readable label for a key, for error messages. + * + * @param \DrevOps\Tui\Input\Key $key + * The key. + * + * @return string + * The label. + */ + protected function keyLabel(Key $key): string { + if ($key->name instanceof KeyName) { + return $key->name->name; + } + + $char = (string) $key->char; + + return $this->isControl($char) ? '^' . chr(ord($char) + 0x40) : $char; + } + +} diff --git a/src/Input/KeyMapManager.php b/src/Input/KeyMapManager.php new file mode 100644 index 0000000..62f9dea --- /dev/null +++ b/src/Input/KeyMapManager.php @@ -0,0 +1,85 @@ + preset-class registry. + * + * @var array> + */ + protected static array $registry = [ + 'default' => DefaultKeyMap::class, + 'vim' => VimKeyMap::class, + ]; + + /** + * Register a preset class under a name so a config can select it. + * + * @param string $name + * The preset name. + * @param string $class + * The preset class name. + * + * @throws \InvalidArgumentException + * When the class is not a DefaultKeyMap (or subclass) - registration fails + * early rather than at the later create() call. + */ + public static function register(string $name, string $class): void { + if (!is_a($class, DefaultKeyMap::class, TRUE)) { + throw new \InvalidArgumentException(sprintf('Key-map preset class "%s" must extend %s.', $class, DefaultKeyMap::class)); + } + + self::$registry[$name] = $class; + } + + /** + * Create a resolved key map from a preset and optional overrides. + * + * Lowest friction first: a fully-qualified preset class name is instantiated + * directly, so a config can point at a consumer's own preset class with no + * registration. Otherwise the name is looked up in the registry ("default", + * "vim" or a name passed to {@see register()}). An unknown name fails + * loudly - a typo should not silently fall back to the defaults. + * + * @param string $name + * A registered name, a preset class name, or "" for the default preset. + * @param list<\DrevOps\Tui\Input\Binding> $overrides + * Bindings appended after the preset's own, retuning individual bindings. + * + * @return \DrevOps\Tui\Input\KeyMap + * The resolved, validated key map. + * + * @throws \InvalidArgumentException + * When the name is neither registered nor a preset class name, or a binding + * conflicts or is malformed. + */ + public static function create(string $name = 'default', array $overrides = []): KeyMap { + $name = $name === '' ? 'default' : $name; + + $class = self::$registry[$name] ?? (is_a($name, DefaultKeyMap::class, TRUE) ? $name : NULL); + + if ($class === NULL) { + throw new \InvalidArgumentException(sprintf('Unknown key-map preset "%s". Use a registered name (%s), register one with KeyMapManager::register(), or pass a DefaultKeyMap subclass name.', $name, implode(', ', array_keys(self::$registry)))); + } + + return new KeyMap(array_merge((new $class())->bindings(), $overrides)); + } + +} diff --git a/src/Input/Scope.php b/src/Input/Scope.php new file mode 100644 index 0000000..49a1ae7 --- /dev/null +++ b/src/Input/Scope.php @@ -0,0 +1,146 @@ +navigation) { + return self::NAVIGATION_TOKEN; + } + + return $this->fieldType instanceof FieldType ? self::FIELD_PREFIX . $this->fieldType->name : self::BASE_TOKEN; + } + + /** + * Whether this scope's widget consumes printable characters as typed input. + * + * @return bool + * TRUE when a printable character is reserved for typing and may not be + * bound to an action. + */ + public function consumesText(): bool { + return $this->fieldType instanceof FieldType && in_array($this->fieldType, self::TEXT_ENTRY, TRUE); + } + + /** + * A human-readable label for this scope, used in error messages. + * + * @return string + * The label. + */ + public function label(): string { + if ($this->navigation) { + return 'navigation'; + } + + return $this->fieldType instanceof FieldType ? $this->fieldType->value : 'base'; + } + +} diff --git a/src/Input/ScopedKeyMap.php b/src/Input/ScopedKeyMap.php new file mode 100644 index 0000000..b9b44ac --- /dev/null +++ b/src/Input/ScopedKeyMap.php @@ -0,0 +1,77 @@ + $byKey + * The action each key triggers, keyed by the key's token. + * @param array> $byAction + * The keys bound to each action, keyed by the action's name. + */ + public function __construct( + protected array $byKey = [], + protected array $byAction = [], + ) { + } + + /** + * Whether a key press triggers an action in this scope. + * + * @param \DrevOps\Tui\Input\Key $key + * The key pressed. + * @param \DrevOps\Tui\Input\Action $action + * The action to test for. + * + * @return bool + * TRUE when the key is bound to the action. + */ + public function matches(Key $key, Action $action): bool { + return ($this->byKey[$key->token()] ?? NULL) === $action; + } + + /** + * The keys bound to an action in this scope, in declaration order. + * + * @param \DrevOps\Tui\Input\Action $action + * The action. + * + * @return list<\DrevOps\Tui\Input\Key> + * The keys, empty when the action is unbound here. + */ + public function keysFor(Action $action): array { + return $this->byAction[$action->name] ?? []; + } + + /** + * The primary (first-declared) key bound to an action, for hint display. + * + * @param \DrevOps\Tui\Input\Action $action + * The action. + * + * @return \DrevOps\Tui\Input\Key|null + * The primary key, or NULL when the action is unbound here. + */ + public function primary(Action $action): ?Key { + return $this->keysFor($action)[0] ?? NULL; + } + +} diff --git a/src/Input/VimKeyMap.php b/src/Input/VimKeyMap.php new file mode 100644 index 0000000..34afc65 --- /dev/null +++ b/src/Input/VimKeyMap.php @@ -0,0 +1,39 @@ +keymap = $config->keymap ?? KeyMapManager::create(); $this->externalEditor = $external_editor ?? new ExternalEditor(); - $this->widgets = new WidgetFactory($this->externalEditor->isAvailable()); + $this->widgets = new WidgetFactory($this->keymap, $this->externalEditor->isAvailable()); + $this->nav = $this->keymap->navigation(); $this->scroller = new Scroller(); $this->navigator = new Navigator(new Panel('hub', $config->title, '', [], $config->panels)); } @@ -253,8 +268,9 @@ public function answers(): Answers { public function frame(int $height = 12): string { 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; - return $this->theme->renderEditor($label, $this->editor->view($this->theme), $this->editor->rendersHint()); + return $this->theme->renderEditor($label, $this->editor->view($this->theme), $this->editor->rendersHint(), $keys); } $panel = $this->navigator->current(); @@ -286,7 +302,7 @@ public function frame(int $height = 12): string { $this->offset = $viewport->offset; $header = [$this->theme->renderBreadcrumbLine($this->navigator)]; - $footer = [$this->theme->renderStatusLine()]; + $footer = [$this->theme->renderStatusLine($this->nav)]; return $this->theme->renderFrame($header, $body, $footer, $viewport, $height); } @@ -329,20 +345,20 @@ protected function handleEditing(Key $key): void { * The key. */ protected function handleNavigation(Key $key): void { - if ($key->isChar() && $key->char === 'q') { + if ($this->nav->matches($key, Action::Quit)) { $this->done = TRUE; return; } - if ($key->is(KeyName::MouseWheelUp)) { + if ($this->nav->matches($key, Action::ScrollUp)) { $this->offset = max(0, $this->offset - 1); $this->followCursor = FALSE; return; } - if ($key->is(KeyName::MouseWheelDown)) { + if ($this->nav->matches($key, Action::ScrollDown)) { $this->offset++; $this->followCursor = FALSE; @@ -352,25 +368,25 @@ protected function handleNavigation(Key $key): void { $this->followCursor = TRUE; $count = $this->theme->itemCount($this->navigator->current()) + ($this->buttonsVisible() ? 2 : 0); - if ($key->is(KeyName::Up)) { + if ($this->nav->matches($key, Action::MoveUp)) { $this->cursor = max(0, $this->cursor - 1); } - elseif ($key->is(KeyName::Down)) { + elseif ($this->nav->matches($key, Action::MoveDown)) { $this->cursor = min(max(0, $count - 1), $this->cursor + 1); } - elseif ($key->is(KeyName::Left) || $key->is(KeyName::Right)) { + elseif ($this->nav->matches($key, Action::MoveLeft) || $this->nav->matches($key, Action::MoveRight)) { // The submit/cancel buttons are inline, so Left/Right moves between them. $base = $this->theme->itemCount($this->navigator->current()); if ($this->buttonsVisible() && $this->cursor >= $base) { - $this->cursor = max($base, min($count - 1, $this->cursor + ($key->is(KeyName::Right) ? 1 : -1))); + $this->cursor = max($base, min($count - 1, $this->cursor + ($this->nav->matches($key, Action::MoveRight) ? 1 : -1))); } } - elseif ($key->is(KeyName::Escape)) { + elseif ($this->nav->matches($key, Action::Back)) { if ($this->navigator->pop()) { $this->cursor = 0; } } - elseif ($key->is(KeyName::Enter)) { + elseif ($this->nav->matches($key, Action::Activate)) { $this->activate(); } } diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php index ea40850..04c8d33 100644 --- a/src/Theme/DefaultTheme.php +++ b/src/Theme/DefaultTheme.php @@ -9,6 +9,10 @@ use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Panel; +use DrevOps\Tui\Input\Action; +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\Navigator; @@ -820,27 +824,83 @@ public function renderBanner(string $logo, string $version): string { return implode("\n", $lines); } + /** + * {@inheritdoc} + */ + public function keyHint(Key $key): string { + $name = $key->name; + + if (!$name instanceof KeyName) { + $char = (string) $key->char; + + // Render a control character (e.g. Ctrl-E) in caret notation. + return $char !== '' && ord($char) < 0x20 ? '^' . chr(ord($char) + 0x40) : $char; + } + + return match ($name) { + KeyName::Up, KeyName::MouseWheelUp => $this->arrowUp(), + KeyName::Down, KeyName::MouseWheelDown => $this->arrowDown(), + KeyName::Left => $this->arrowLeft(), + KeyName::Right => $this->arrowRight(), + KeyName::Enter => $this->enter(), + KeyName::Escape => 'esc', + KeyName::Tab => 'tab', + KeyName::Space => 'space', + KeyName::Backspace => $this->unicode ? '⌫' : 'bksp', + KeyName::Delete => 'del', + KeyName::Home => 'home', + KeyName::End => 'end', + KeyName::PageUp => 'pgup', + KeyName::PageDown => 'pgdn', + }; + } + + /** + * {@inheritdoc} + */ + public function keysHint(ScopedKeyMap $keys, string $label, Action ...$actions): string { + $glyphs = []; + + foreach ($actions as $action) { + $key = $keys->primary($action); + + if ($key instanceof Key) { + $glyphs[] = $this->keyHint($key); + } + } + + return $glyphs === [] ? '' : implode('/', $glyphs) . ' ' . $label; + } + /** * Render the status line shown at the foot of a panel. * + * @param \DrevOps\Tui\Input\ScopedKeyMap $nav + * The navigation-scope bindings, so the hint reflects the active keys. + * * @return string * The themed status line (hint text and arrow glyphs). */ - public function renderStatusLine(): string { - return $this->renderHintLine($this->arrowUp() . '/' . $this->arrowDown() . ' move', $this->enter() . ' select', 'esc back'); + 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), + ); } /** * Render a dimmed line of key hints, joined with the dot glyph. * * @param string ...$hints - * The hint fragments (e.g. "enter accept", "esc cancel"). + * The hint fragments (e.g. "enter accept", "esc cancel"). Empty fragments - + * an unbound action - are dropped so the line has no dangling separators. * * @return string * The themed hint line. */ public function renderHintLine(string ...$hints): string { - return $this->footer(implode(' ' . $this->dot() . ' ', $hints)); + return $this->footer(implode(' ' . $this->dot() . ' ', array_filter($hints))); } /** @@ -868,12 +928,17 @@ public function renderEditorHeader(string $label): string { * @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 \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. * * @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): string { - $hint = $this->renderHintLine($this->enter() . ' accept', 'esc cancel'); + 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]; if ($this->borderStyle() !== self::BORDER_NONE) { diff --git a/src/Theme/ThemeInterface.php b/src/Theme/ThemeInterface.php index 2dccce6..97cc003 100644 --- a/src/Theme/ThemeInterface.php +++ b/src/Theme/ThemeInterface.php @@ -4,6 +4,10 @@ namespace DrevOps\Tui\Theme; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\ScopedKeyMap; + /** * A theme's look: one method per themeable element. * @@ -213,4 +217,34 @@ public function caret(): string; */ public function mask(): string; + /** + * Render a single key as its hint glyph (an arrow, a word or the character). + * + * @param \DrevOps\Tui\Input\Key $key + * The key to render. + * + * @return string + * The glyph, respecting the theme's Unicode mode. + */ + public function keyHint(Key $key): string; + + /** + * Render a hint fragment: the primary keys of one or more actions, labelled. + * + * The glyphs are drawn from the live bindings, so a hint never contradicts a + * remapped key. An action with no bound key contributes nothing, and when no + * action is bound the fragment is empty. + * + * @param \DrevOps\Tui\Input\ScopedKeyMap $keys + * The scope's bindings. + * @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 lead the fragment. + * + * @return string + * The fragment (e.g. "↑/↓ move"), or an empty string when nothing is bound. + */ + public function keysHint(ScopedKeyMap $keys, string $label, Action ...$actions): string; + } diff --git a/src/Widget/AbstractWidget.php b/src/Widget/AbstractWidget.php index cc6a44b..57b32f0 100644 --- a/src/Widget/AbstractWidget.php +++ b/src/Widget/AbstractWidget.php @@ -4,8 +4,11 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\KeyMapManager; +use DrevOps\Tui\Input\Scope; +use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -15,6 +18,14 @@ */ abstract class AbstractWidget implements WidgetInterface { + /** + * The resolved key bindings for this widget's scope. + * + * Injected by the widget factory; when a widget is constructed directly (for + * a test or a one-off), it falls back to the default preset for its scope. + */ + protected ?ScopedKeyMap $scoped = NULL; + /** * Whether a valid value has been accepted. */ @@ -85,6 +96,15 @@ public function rendersHint(): bool { return FALSE; } + /** + * {@inheritdoc} + */ + public function setKeys(ScopedKeyMap $keys): static { + $this->scoped = $keys; + + return $this; + } + /** * The in-progress value before acceptance. * @@ -94,7 +114,30 @@ public function rendersHint(): bool { abstract protected function liveValue(): mixed; /** - * Cancel the widget when the key is Escape. + * The scope whose default bindings apply when none are injected. + * + * Widgets whose bindings differ from the base defaults override this; the + * base scope is the right fallback for the rest. + * + * @return \DrevOps\Tui\Input\Scope + * The widget's binding scope. + */ + protected function keyScope(): Scope { + return Scope::base(); + } + + /** + * The resolved bindings for this widget, defaulting to the built-in preset. + * + * @return \DrevOps\Tui\Input\ScopedKeyMap + * The scoped bindings. + */ + protected function keys(): ScopedKeyMap { + return $this->scoped ??= KeyMapManager::create()->scope($this->keyScope()); + } + + /** + * Cancel the widget when the key triggers the cancel action. * * @param \DrevOps\Tui\Input\Key $key * The key to test. @@ -103,7 +146,7 @@ abstract protected function liveValue(): mixed; * TRUE when the key cancelled the widget. */ protected function handleCancel(Key $key): bool { - if ($key->is(KeyName::Escape)) { + if ($this->keys()->matches($key, Action::Cancel)) { $this->cancelled = TRUE; return TRUE; diff --git a/src/Widget/ConfirmWidget.php b/src/Widget/ConfirmWidget.php index 2cdc3bc..e7dd64f 100644 --- a/src/Widget/ConfirmWidget.php +++ b/src/Widget/ConfirmWidget.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -29,57 +31,43 @@ public function __construct(protected bool $current = FALSE, ?\Closure $validate parent::__construct($validate, $transform); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Confirm); + } + /** * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->current); return; } - if ($this->isToggle($key)) { + if ($keys->matches($key, Action::Toggle)) { $this->current = !$this->current; return; } - if ($key->isChar()) { - $this->applyChar($key->char ?? ''); - } - } - - /** - * Whether the key toggles the choice. - * - * @param \DrevOps\Tui\Input\Key $key - * The key to test. - * - * @return bool - * TRUE when the key toggles. - */ - protected function isToggle(Key $key): bool { - return in_array($key->name, [KeyName::Left, KeyName::Right, KeyName::Space, KeyName::Up, KeyName::Down], TRUE); - } - - /** - * Set the choice from a typed character (y/n). - * - * @param string $char - * The typed character. - */ - protected function applyChar(string $char): void { - $char = strtolower($char); - - if ($char === 'y') { + if ($keys->matches($key, Action::Yes)) { $this->current = TRUE; + + return; } - elseif ($char === 'n') { + + if ($keys->matches($key, Action::No)) { $this->current = FALSE; } } diff --git a/src/Widget/MultiSelectWidget.php b/src/Widget/MultiSelectWidget.php index d798398..7248ba6 100644 --- a/src/Widget/MultiSelectWidget.php +++ b/src/Widget/MultiSelectWidget.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -63,51 +65,61 @@ public function __construct(protected array $labels, array $default = [], ?\Clos } } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::MultiSelect); + } + /** * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->liveValue()); return; } - if ($key->is(KeyName::Up)) { + if ($keys->matches($key, Action::MoveUp)) { $this->cursor = max(0, $this->cursor - 1); return; } - if ($key->is(KeyName::Down)) { + if ($keys->matches($key, Action::MoveDown)) { $this->cursor = min(count($this->visible()) - 1, $this->cursor + 1); return; } - if ($key->is(KeyName::Space)) { + if ($keys->matches($key, Action::Toggle)) { $this->toggleCurrent(); return; } - if ($key->is(KeyName::Right)) { + if ($keys->matches($key, Action::SelectAll)) { $this->setAllVisible(TRUE); return; } - if ($key->is(KeyName::Left)) { + if ($keys->matches($key, Action::SelectNone)) { $this->setAllVisible(FALSE); return; } - if ($key->is(KeyName::Backspace)) { + if ($keys->matches($key, Action::DeleteBack)) { $this->filter = substr($this->filter, 0, -1); $this->cursor = 0; @@ -208,8 +220,10 @@ public function rendersHint(): bool { /** * Build the key-hint line shown beneath the option list. * - * Space is the non-obvious key here - nothing else signals that it toggles - * the highlighted option - so it leads, followed by the remaining bindings. + * 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. @@ -218,13 +232,13 @@ public function rendersHint(): bool { * The themed, dot-joined hint line. */ protected function hint(ThemeInterface $theme): string { - $fragments = [ - 'space select', - $theme->arrowUp() . '/' . $theme->arrowDown() . ' move', - $theme->arrowLeft() . '/' . $theme->arrowRight() . ' none/all', - $theme->enter() . ' accept', - 'esc cancel', - ]; + $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)); } diff --git a/src/Widget/PauseWidget.php b/src/Widget/PauseWidget.php index c863ff2..2354a51 100644 --- a/src/Widget/PauseWidget.php +++ b/src/Widget/PauseWidget.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -15,15 +17,25 @@ */ class PauseWidget extends AbstractWidget { + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Pause); + } + /** * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter) || $key->is(KeyName::Space)) { + if ($keys->matches($key, Action::Accept)) { $this->accept(TRUE); } } @@ -39,7 +51,10 @@ protected function liveValue(): mixed { * {@inheritdoc} */ public function view(ThemeInterface $theme): string { - return $theme->highlight($theme->enter()) . ' Press Enter to continue'; + $key = $this->keys()->primary(Action::Accept); + $glyph = $key instanceof Key ? $theme->keyHint($key) : $theme->enter(); + + return 'Press ' . $theme->highlight($glyph) . ' to continue'; } } diff --git a/src/Widget/SearchWidget.php b/src/Widget/SearchWidget.php index 671540b..cd18c45 100644 --- a/src/Widget/SearchWidget.php +++ b/src/Widget/SearchWidget.php @@ -4,8 +4,8 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -55,11 +55,13 @@ public function __construct(protected array $labels, string $default = '', ?\Clo * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { if ($this->visible() !== []) { $this->accept($this->liveValue()); } @@ -67,26 +69,26 @@ public function handle(Key $key): void { return; } - if ($key->is(KeyName::Up)) { + if ($keys->matches($key, Action::MoveUp)) { $this->cursor = max(0, $this->cursor - 1); return; } - if ($key->is(KeyName::Down)) { + if ($keys->matches($key, Action::MoveDown)) { $this->cursor = min(count($this->visible()) - 1, $this->cursor + 1); return; } - if ($key->is(KeyName::Backspace)) { + if ($keys->matches($key, Action::DeleteBack)) { $this->filter = substr($this->filter, 0, -1); $this->cursor = 0; return; } - if ($key->is(KeyName::Space)) { + if ($keys->matches($key, Action::InsertSpace)) { $this->filter .= ' '; $this->cursor = 0; diff --git a/src/Widget/SelectWidget.php b/src/Widget/SelectWidget.php index 104ea2f..159630b 100644 --- a/src/Widget/SelectWidget.php +++ b/src/Widget/SelectWidget.php @@ -4,8 +4,8 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -50,23 +50,25 @@ public function __construct(protected array $labels, string $default = '', ?\Clo * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Up)) { + if ($keys->matches($key, Action::MoveUp)) { $this->cursor = max(0, $this->cursor - 1); return; } - if ($key->is(KeyName::Down)) { + if ($keys->matches($key, Action::MoveDown)) { $this->cursor = min(count($this->values) - 1, $this->cursor + 1); return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->liveValue()); } } diff --git a/src/Widget/SuggestWidget.php b/src/Widget/SuggestWidget.php index 3b0c282..04f63cc 100644 --- a/src/Widget/SuggestWidget.php +++ b/src/Widget/SuggestWidget.php @@ -4,8 +4,8 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -40,36 +40,38 @@ public function __construct(protected array $values, protected string $buffer = * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->liveValue()); return; } - if ($key->is(KeyName::Down)) { + if ($keys->matches($key, Action::MoveDown)) { $this->highlight = min(count($this->matches()) - 1, $this->highlight + 1); return; } - if ($key->is(KeyName::Up)) { + if ($keys->matches($key, Action::MoveUp)) { $this->highlight = max(-1, $this->highlight - 1); return; } - if ($key->is(KeyName::Backspace)) { + if ($keys->matches($key, Action::DeleteBack)) { $this->buffer = substr($this->buffer, 0, -1); $this->highlight = -1; return; } - if ($key->is(KeyName::Space)) { + if ($keys->matches($key, Action::InsertSpace)) { $this->buffer .= ' '; $this->highlight = -1; diff --git a/src/Widget/TextWidget.php b/src/Widget/TextWidget.php index 4304f99..eda2b07 100644 --- a/src/Widget/TextWidget.php +++ b/src/Widget/TextWidget.php @@ -4,8 +4,8 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -39,35 +39,37 @@ public function __construct(protected string $buffer = '', ?\Closure $validate = * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->liveValue()); return; } - if ($key->is(KeyName::Backspace)) { + if ($keys->matches($key, Action::DeleteBack)) { $this->backspace(); return; } - if ($key->is(KeyName::Left)) { + if ($keys->matches($key, Action::MoveLeft)) { $this->cursor = max(0, $this->cursor - 1); return; } - if ($key->is(KeyName::Right)) { + if ($keys->matches($key, Action::MoveRight)) { $this->cursor = min(strlen($this->buffer), $this->cursor + 1); return; } - if ($key->is(KeyName::Space)) { + if ($keys->matches($key, Action::InsertSpace)) { $this->insert(' '); return; diff --git a/src/Widget/TextareaWidget.php b/src/Widget/TextareaWidget.php index 9625efe..9ecaea7 100644 --- a/src/Widget/TextareaWidget.php +++ b/src/Widget/TextareaWidget.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -15,11 +17,6 @@ */ class TextareaWidget extends TextWidget { - /** - * The key that requests the external-editor handoff (Ctrl-E). - */ - protected const string EDITOR_KEY = "\x05"; - /** * Whether the external-editor handoff has been requested. */ @@ -41,14 +38,24 @@ public function __construct(string $buffer = '', ?\Closure $validate = NULL, ?\C parent::__construct($buffer, $validate, $transform); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Textarea); + } + /** * {@inheritdoc} */ #[\Override] public function handle(Key $key): void { - if ($key->isChar() && $key->char === self::EDITOR_KEY) { - // Only act when the handoff is offered; otherwise swallow the control key - // rather than inserting a raw byte into the buffer. + $keys = $this->keys(); + + if ($keys->matches($key, Action::ExternalEdit)) { + // Only act when the handoff is offered; either way the bound key is + // swallowed rather than inserting a raw control byte into the buffer. if ($this->externalEdit) { $this->externalEditRequested = TRUE; } @@ -56,30 +63,26 @@ public function handle(Key $key): void { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::NewLine)) { $this->insert("\n"); return; } - if ($key->is(KeyName::Tab)) { - $this->accept($this->liveValue()); - - return; - } - - if ($key->is(KeyName::Up)) { + if ($keys->matches($key, Action::MoveUp)) { $this->moveLine(-1); return; } - if ($key->is(KeyName::Down)) { + if ($keys->matches($key, Action::MoveDown)) { $this->moveLine(1); return; } + // The parent handles the rest, including Accept, which this scope binds to + // Tab rather than Enter. parent::handle($key); } @@ -162,12 +165,19 @@ 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); - $hint_text = 'enter newline ' . $theme->dot() . ' tab accept'; + $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) { - $hint_text .= ' ' . $theme->dot() . ' ctrl-e editor'; + $fragments[] = $theme->keysHint($this->keys(), 'editor', Action::ExternalEdit); } - $out = $text . "\n" . $theme->footer($hint_text); + $hint = $theme->footer(implode(' ' . $theme->dot() . ' ', array_filter($fragments))); + + $out = $text . "\n" . $hint; return $this->error === NULL ? $out : $out . "\n" . $theme->error($this->error); } diff --git a/src/Widget/WidgetFactory.php b/src/Widget/WidgetFactory.php index e828eda..1623d88 100644 --- a/src/Widget/WidgetFactory.php +++ b/src/Widget/WidgetFactory.php @@ -6,6 +6,8 @@ use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\KeyMap; +use DrevOps\Tui\Input\KeyMapManager; /** * Builds the widget for a field, seeded with the field's current value. @@ -14,18 +16,26 @@ */ class WidgetFactory { + /** + * The resolved key bindings to inject into each widget. + */ + protected KeyMap $keymap; + /** * Construct a widget factory. * + * @param \DrevOps\Tui\Input\KeyMap|null $keymap + * The resolved key bindings; NULL uses the default preset. * @param bool $externalEditorAvailable * Whether an external editor is launchable here. A textarea field opts in * per-field; the handoff shows only when one is also available. */ - public function __construct(protected bool $externalEditorAvailable = FALSE) { + public function __construct(?KeyMap $keymap = NULL, protected bool $externalEditorAvailable = FALSE) { + $this->keymap = $keymap ?? KeyMapManager::create(); } /** - * Create a widget for a field. + * Create a widget for a field, wired with its scope's key bindings. * * @param \DrevOps\Tui\Config\Field $field * The field. @@ -38,7 +48,7 @@ public function __construct(protected bool $externalEditorAvailable = FALSE) { public function create(Field $field, mixed $current): WidgetInterface { $labels = $this->labels($field); - return match ($field->type) { + $widget = match ($field->type) { FieldType::Confirm => new ConfirmWidget((bool) $current), FieldType::Toggle => new ToggleWidget($labels, is_string($current) ? $current : ''), FieldType::Select => new SelectWidget($labels, is_string($current) ? $current : ''), @@ -52,6 +62,8 @@ public function create(Field $field, mixed $current): WidgetInterface { FieldType::Pause => new PauseWidget(), default => new TextWidget(is_string($current) ? $current : ''), }; + + return $widget->setKeys($this->keymap->forField($field->type)); } /** diff --git a/src/Widget/WidgetInterface.php b/src/Widget/WidgetInterface.php index ac10b3b..455e9ce 100644 --- a/src/Widget/WidgetInterface.php +++ b/src/Widget/WidgetInterface.php @@ -5,6 +5,7 @@ namespace DrevOps\Tui\Widget; use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\ScopedKeyMap; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -22,6 +23,17 @@ interface WidgetInterface { */ public function handle(Key $key): void; + /** + * Give the widget the resolved bindings for its scope. + * + * @param \DrevOps\Tui\Input\ScopedKeyMap $keys + * The scoped key bindings. + * + * @return static + * The widget, for chaining. + */ + public function setKeys(ScopedKeyMap $keys): static; + /** * Whether a valid value has been accepted. */ diff --git a/tests/phpunit/Unit/Builder/FormTest.php b/tests/phpunit/Unit/Builder/FormTest.php index 5c0ae66..8abe597 100644 --- a/tests/phpunit/Unit/Builder/FormTest.php +++ b/tests/phpunit/Unit/Builder/FormTest.php @@ -14,6 +14,12 @@ use DrevOps\Tui\Config\Fixup; use DrevOps\Tui\Derive\Derive; use DrevOps\Tui\Discovery\Dotenv; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Binding; +use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyMap; +use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -239,4 +245,34 @@ public function testToggleNumericStringOptionsDefaultToFirstValue(): void { $this->assertSame('0', $config->field('flag')?->default); } + public function testDefaultKeymapWhenUnset(): void { + $config = Form::create('T') + ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) + ->build(); + + $this->assertInstanceOf(KeyMap::class, $config->keymap); + $this->assertTrue($config->keymap->navigation()->matches(Key::named(KeyName::Up), Action::MoveUp)); + } + + public function testKeysAppliesPresetAndOverrides(): void { + $config = Form::create('T') + ->keys('vim', [new Binding(Scope::navigation(), Action::Quit, 'x')]) + ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) + ->build(); + + $this->assertInstanceOf(KeyMap::class, $config->keymap); + $nav = $config->keymap->navigation(); + $this->assertTrue($nav->matches(Key::char('j'), Action::MoveDown)); + $this->assertTrue($nav->matches(Key::char('x'), Action::Quit)); + } + + public function testInvalidKeyBindingThrowsAtBuild(): void { + $this->expectException(\InvalidArgumentException::class); + + Form::create('T') + ->keys('default', [new Binding(Scope::navigation(), Action::Quit, KeyName::Enter)]) + ->panel('a', 'A', fn(PanelBuilder $p): FieldBuilder => $p->text('x')) + ->build(); + } + } diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php new file mode 100644 index 0000000..1ec5622 --- /dev/null +++ b/tests/phpunit/Unit/Input/KeyMapTest.php @@ -0,0 +1,229 @@ +scope($scope); + + $this->assertSame($expected, $map->matches($key, $action)); + } + + public static function dataProviderDefaultBindings(): \Iterator { + // Navigation. + yield 'nav up moves up' => [Scope::navigation(), Key::named(KeyName::Up), Action::MoveUp, TRUE]; + yield 'nav enter activates' => [Scope::navigation(), Key::named(KeyName::Enter), Action::Activate, TRUE]; + yield 'nav enter is not accept' => [Scope::navigation(), Key::named(KeyName::Enter), Action::Accept, FALSE]; + 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]; + // 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]; + yield 'text backspace deletes' => [Scope::field(FieldType::Text), Key::named(KeyName::Backspace), Action::DeleteBack, TRUE]; + // Textarea overrides the base. + yield 'textarea enter is newline' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Enter), Action::NewLine, TRUE]; + yield 'textarea enter is not accept' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Enter), Action::Accept, FALSE]; + yield 'textarea tab accepts' => [Scope::field(FieldType::Textarea), Key::named(KeyName::Tab), Action::Accept, TRUE]; + // A control char is bindable in a text-entry scope; a printable one is not. + yield 'textarea ctrl-e is external edit' => [Scope::field(FieldType::Textarea), Key::char("\x05"), Action::ExternalEdit, TRUE]; + // Password reveal toggle. + yield 'password tab reveals' => [Scope::field(FieldType::Password), Key::named(KeyName::Tab), Action::Reveal, TRUE]; + // Confirm. + yield 'confirm up toggles' => [Scope::field(FieldType::Confirm), Key::named(KeyName::Up), Action::Toggle, TRUE]; + yield 'confirm y is yes' => [Scope::field(FieldType::Confirm), Key::char('y'), Action::Yes, TRUE]; + yield 'confirm uppercase y is yes' => [Scope::field(FieldType::Confirm), Key::char('Y'), Action::Yes, TRUE]; + yield 'confirm enter accepts' => [Scope::field(FieldType::Confirm), Key::named(KeyName::Enter), Action::Accept, TRUE]; + // Multi-select. + yield 'multiselect space toggles' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Space), Action::Toggle, TRUE]; + yield 'multiselect right selects all' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Right), Action::SelectAll, TRUE]; + yield 'multiselect up moves up (inherited)' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Up), Action::MoveUp, TRUE]; + yield 'multisearch space toggles' => [Scope::field(FieldType::MultiSearch), Key::named(KeyName::Space), Action::Toggle, TRUE]; + // Pause binds two keys to accept. + yield 'pause enter accepts' => [Scope::field(FieldType::Pause), Key::named(KeyName::Enter), Action::Accept, TRUE]; + yield 'pause space accepts' => [Scope::field(FieldType::Pause), Key::named(KeyName::Space), Action::Accept, TRUE]; + // A scope with no overrides falls back to the base bindings. + yield 'select falls back to base' => [Scope::field(FieldType::Select), Key::named(KeyName::Up), Action::MoveUp, TRUE]; + } + + public function testForFieldFallsBackToBaseInstance(): void { + $map = KeyMapManager::create(); + + // Select has no overrides, so it is the very same base instance. + $this->assertSame($map->scope(Scope::base()), $map->forField(FieldType::Select)); + } + + #[DataProvider('dataProviderVimPreset')] + public function testVimPreset(Scope $scope, Key $key, Action $action, bool $expected): void { + $map = KeyMapManager::create('vim')->scope($scope); + + $this->assertSame($expected, $map->matches($key, $action)); + } + + public static function dataProviderVimPreset(): \Iterator { + yield 'nav j moves down' => [Scope::navigation(), Key::char('j'), Action::MoveDown, TRUE]; + yield 'nav k moves up' => [Scope::navigation(), Key::char('k'), Action::MoveUp, TRUE]; + yield 'nav h moves left' => [Scope::navigation(), Key::char('h'), Action::MoveLeft, TRUE]; + yield 'nav l moves right' => [Scope::navigation(), Key::char('l'), Action::MoveRight, TRUE]; + yield 'nav arrows still work' => [Scope::navigation(), Key::named(KeyName::Down), Action::MoveDown, TRUE]; + yield 'select j moves down' => [Scope::field(FieldType::Select), Key::char('j'), Action::MoveDown, TRUE]; + // A text field must keep j typeable, so it is not movement there. + yield 'text j is not movement' => [Scope::field(FieldType::Text), Key::char('j'), Action::MoveDown, FALSE]; + } + + public function testOverrideRetunesOneScope(): void { + $map = KeyMapManager::create('default', [ + new Binding(Scope::field(FieldType::Select), Action::Accept, 'l', KeyName::Enter), + ])->forField(FieldType::Select); + + $this->assertTrue($map->matches(Key::char('l'), Action::Accept)); + $this->assertTrue($map->matches(Key::named(KeyName::Enter), Action::Accept)); + } + + public function testScopeAccessorResolvesEachKind(): void { + $map = KeyMapManager::create(); + + $this->assertInstanceOf(ScopedKeyMap::class, $map->scope(Scope::base())); + $this->assertSame($map->navigation(), $map->scope(Scope::navigation())); + $this->assertSame($map->forField(FieldType::Textarea), $map->scope(Scope::field(FieldType::Textarea))); + } + + public function testKeysForAndPrimaryAndUnbound(): void { + $nav = KeyMapManager::create()->navigation(); + + $this->assertTrue($nav->primary(Action::MoveUp)?->is(KeyName::Up)); + $this->assertCount(1, $nav->keysFor(Action::Quit)); + + // Newline is not a navigation action. + $this->assertNotInstanceOf(Key::class, $nav->primary(Action::NewLine)); + $this->assertSame([], $nav->keysFor(Action::NewLine)); + $this->assertFalse($nav->matches(Key::named(KeyName::Enter), Action::NewLine)); + } + + public function testNormalisesAllKeyForms(): void { + $map = new KeyMap([ + new Binding(Scope::field(FieldType::Select), Action::Accept, Key::named(KeyName::Tab), KeyName::Enter, 'l'), + ]); + $select = $map->forField(FieldType::Select); + + $this->assertTrue($select->matches(Key::named(KeyName::Tab), Action::Accept)); + $this->assertTrue($select->matches(Key::named(KeyName::Enter), Action::Accept)); + $this->assertTrue($select->matches(Key::char('l'), Action::Accept)); + } + + #[DataProvider('dataProviderFailsLoudly')] + public function testFailsLoudly(Binding $binding, string $message): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + KeyMapManager::create('default', [$binding]); + } + + public static function dataProviderFailsLoudly(): \Iterator { + yield 'conflict in one scope' => [ + new Binding(Scope::navigation(), Action::Quit, KeyName::Enter), + 'bound to both Activate and Quit in the navigation scope', + ]; + yield 'printable char in the base scope' => [ + new Binding(Scope::base(), Action::Accept, 'x'), + 'base scope may not bind the printable character', + ]; + yield 'printable char in a text-entry scope' => [ + new Binding(Scope::field(FieldType::Search), Action::MoveDown, 'j'), + 'search scope consumes typed characters', + ]; + yield 'multi-character binding' => [ + new Binding(Scope::field(FieldType::Select), Action::Accept, 'ab'), + 'must be a single character', + ]; + } + + public function testUnknownPresetThrows(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown key-map preset "nope"'); + + KeyMapManager::create('nope'); + } + + public function testRegisterAndSelectByName(): void { + KeyMapManager::register('registered-vim', VimKeyMap::class); + + $map = KeyMapManager::create('registered-vim')->navigation(); + + $this->assertTrue($map->matches(Key::char('j'), Action::MoveDown)); + } + + public function testRegisterRejectsNonPreset(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must extend'); + + KeyMapManager::register('bad', \stdClass::class); + } + + public function testCreateAcceptsPresetClassName(): void { + $map = KeyMapManager::create(VimKeyMap::class)->navigation(); + + $this->assertTrue($map->matches(Key::char('k'), Action::MoveUp)); + } + + public function testEmptyPresetNameIsDefault(): void { + $map = KeyMapManager::create('')->forField(FieldType::Textarea); + + $this->assertTrue($map->matches(Key::named(KeyName::Enter), Action::NewLine)); + } + + #[DataProvider('dataProviderScope')] + public function testScope(Scope $scope, string $token, string $label, bool $consumes_text): void { + $this->assertSame($token, $scope->token()); + $this->assertSame($label, $scope->label()); + $this->assertSame($consumes_text, $scope->consumesText()); + } + + public static function dataProviderScope(): \Iterator { + yield 'base' => [Scope::base(), '@base', 'base', FALSE]; + yield 'navigation' => [Scope::navigation(), '@navigation', 'navigation', FALSE]; + yield 'text is a text-entry scope' => [Scope::field(FieldType::Text), 'field:Text', 'text', TRUE]; + yield 'select is not a text-entry scope' => [Scope::field(FieldType::Select), 'field:Select', 'select', FALSE]; + } + + public function testBindingHoldsItsDeclaration(): void { + $binding = new Binding(Scope::navigation(), Action::Quit, KeyName::Escape, 'x'); + + $this->assertTrue($binding->scope->navigation); + $this->assertSame(Action::Quit, $binding->action); + $this->assertSame([KeyName::Escape, 'x'], $binding->keys); + } + +} diff --git a/tests/phpunit/Unit/Input/KeyStreamTest.php b/tests/phpunit/Unit/Input/KeyStreamTest.php index cbcae90..7542ef4 100644 --- a/tests/phpunit/Unit/Input/KeyStreamTest.php +++ b/tests/phpunit/Unit/Input/KeyStreamTest.php @@ -57,4 +57,18 @@ public function testConstructReindexes(): void { $this->assertNotInstanceOf(Key::class, $stream->read()); } + public function testEqualsAndToken(): void { + $this->assertTrue(Key::named(KeyName::Enter)->equals(Key::named(KeyName::Enter))); + $this->assertFalse(Key::named(KeyName::Enter)->equals(Key::named(KeyName::Escape))); + + $this->assertTrue(Key::char('a')->equals(Key::char('a'))); + $this->assertFalse(Key::char('a')->equals(Key::char('b'))); + + // A named key and a character never collide. + $this->assertFalse(Key::named(KeyName::Space)->equals(Key::char(' '))); + + $this->assertSame('name:Enter', Key::named(KeyName::Enter)->token()); + $this->assertSame('char:a', Key::char('a')->token()); + } + } diff --git a/tests/phpunit/Unit/Theme/ThemeRenderTest.php b/tests/phpunit/Unit/Theme/ThemeRenderTest.php index 323a00b..120b5ba 100644 --- a/tests/phpunit/Unit/Theme/ThemeRenderTest.php +++ b/tests/phpunit/Unit/Theme/ThemeRenderTest.php @@ -9,11 +9,16 @@ use DrevOps\Tui\Config\Field; use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Panel; +use DrevOps\Tui\Input\Action; +use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyMapManager; +use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Render\Ansi; use DrevOps\Tui\Render\Navigator; use DrevOps\Tui\Render\Viewport; use DrevOps\Tui\Theme\DefaultTheme; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -181,13 +186,58 @@ public function testItemCount(): void { } public function testStatusLineIsThemed(): void { - $line = (new DefaultTheme())->renderStatusLine(); + $line = (new DefaultTheme())->renderStatusLine(KeyMapManager::create()->navigation()); // Themed with the footer role (dim gray) and composed from arrow glyphs. $this->assertStringContainsString("\033[90m", $line); $this->assertStringContainsString('↑/↓ move', Ansi::strip($line)); } + #[DataProvider('dataProviderKeyHint')] + public function testKeyHint(Key $key, string $unicode, string $ascii): void { + $this->assertSame($unicode, (new DefaultTheme())->keyHint($key)); + $this->assertSame($ascii, (new DefaultTheme(76, ['color' => FALSE, 'unicode' => FALSE]))->keyHint($key)); + } + + public static function dataProviderKeyHint(): \Iterator { + yield 'up' => [Key::named(KeyName::Up), '↑', '^']; + yield 'down' => [Key::named(KeyName::Down), '↓', 'v']; + yield 'left' => [Key::named(KeyName::Left), '←', '<']; + yield 'right' => [Key::named(KeyName::Right), '→', '>']; + yield 'enter' => [Key::named(KeyName::Enter), '↵', '<']; + yield 'escape' => [Key::named(KeyName::Escape), 'esc', 'esc']; + yield 'tab' => [Key::named(KeyName::Tab), 'tab', 'tab']; + yield 'space' => [Key::named(KeyName::Space), 'space', 'space']; + yield 'backspace' => [Key::named(KeyName::Backspace), '⌫', 'bksp']; + yield 'delete' => [Key::named(KeyName::Delete), 'del', 'del']; + yield 'home' => [Key::named(KeyName::Home), 'home', 'home']; + yield 'end' => [Key::named(KeyName::End), 'end', 'end']; + yield 'page up' => [Key::named(KeyName::PageUp), 'pgup', 'pgup']; + yield 'page down' => [Key::named(KeyName::PageDown), 'pgdn', 'pgdn']; + yield 'wheel up' => [Key::named(KeyName::MouseWheelUp), '↑', '^']; + yield 'wheel down' => [Key::named(KeyName::MouseWheelDown), '↓', 'v']; + yield 'character' => [Key::char('j'), 'j', 'j']; + yield 'control character in caret notation' => [Key::char("\x05"), '^E', '^E']; + } + + public function testKeysHintDropsUnboundActions(): void { + $nav = KeyMapManager::create()->navigation(); + $theme = new DefaultTheme(); + + $this->assertSame('↑/↓ move', $theme->keysHint($nav, 'move', Action::MoveUp, Action::MoveDown)); + // Newline is not bound in the navigation scope, so the fragment is empty. + $this->assertSame('', $theme->keysHint($nav, 'newline', Action::NewLine)); + } + + public function testRenderEditorDerivesHintFromKeys(): void { + $keys = KeyMapManager::create()->forField(FieldType::Text); + $editor = Ansi::strip((new DefaultTheme())->renderEditor('Name', 'value', FALSE, $keys)); + + // The generic accept/cancel hint reflects the active bindings. + $this->assertStringContainsString('↵ accept', $editor); + $this->assertStringContainsString('esc cancel', $editor); + } + public function testHorizontalArrowGlyphs(): void { $unicode = new DefaultTheme(); $this->assertSame('←', $unicode->arrowLeft()); diff --git a/tests/phpunit/Unit/Widget/PauseWidgetTest.php b/tests/phpunit/Unit/Widget/PauseWidgetTest.php index 8dc87cc..c4f29ad 100644 --- a/tests/phpunit/Unit/Widget/PauseWidgetTest.php +++ b/tests/phpunit/Unit/Widget/PauseWidgetTest.php @@ -51,7 +51,11 @@ public function testOtherKeysIgnored(): void { public function testCancelAndView(): void { $widget = new PauseWidget(); - $this->assertStringContainsString('Press Enter to continue', $widget->view(new DefaultTheme())); + // The prompt key glyph is drawn from the live binding (Enter by default). + $view = $widget->view(new DefaultTheme()); + $this->assertStringContainsString('Press ', $view); + $this->assertStringContainsString('to continue', $view); + $this->assertStringContainsString('↵', $view); $widget->handle(Key::named(KeyName::Escape)); $this->assertTrue($widget->isCancelled()); diff --git a/tests/phpunit/Unit/Widget/SelectWidgetTest.php b/tests/phpunit/Unit/Widget/SelectWidgetTest.php index 307c44a..365d5dc 100644 --- a/tests/phpunit/Unit/Widget/SelectWidgetTest.php +++ b/tests/phpunit/Unit/Widget/SelectWidgetTest.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Tests\Unit\Widget; +use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Input\ArrayKeyStream; use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Theme\DefaultTheme; use DrevOps\Tui\Widget\AbstractWidget; @@ -64,4 +66,15 @@ public function testCancel(): void { $this->assertTrue($widget->isCancelled()); } + public function testSetKeysInjectsBindings(): void { + // An injected scope map takes over from the lazy default: the vim select + // scope binds j to move-down, which the default preset does not. + $widget = (new SelectWidget(['a' => 'A', 'b' => 'B'], 'a')) + ->setKeys(KeyMapManager::create('vim')->forField(FieldType::Select)); + + $widget->handle(Key::char('j')); + + $this->assertSame('b', $widget->value()); + } + } diff --git a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php index 9f4e63b..0826eb2 100644 --- a/tests/phpunit/Unit/Widget/WidgetFactoryTest.php +++ b/tests/phpunit/Unit/Widget/WidgetFactoryTest.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Config\FieldType; use DrevOps\Tui\Config\Option; use DrevOps\Tui\Input\Key; +use DrevOps\Tui\Input\KeyMapManager; use DrevOps\Tui\Input\KeyName; use DrevOps\Tui\Widget\ConfirmWidget; use DrevOps\Tui\Widget\MultiSearchWidget; @@ -90,7 +91,7 @@ public function testMultiselectWithNonArrayValueHasNoDefaults(): void { public function testTextareaExternalEditorOfferedWhenOptedInAndAvailable(): void { $field = new Field('f', 'F', '', FieldType::Textarea, '', externalEditor: TRUE); - $widget = (new WidgetFactory(TRUE))->create($field, 'x'); + $widget = (new WidgetFactory(externalEditorAvailable: TRUE))->create($field, 'x'); $this->assertInstanceOf(TextareaWidget::class, $widget); $widget->handle(Key::char("\x05")); @@ -100,7 +101,7 @@ public function testTextareaExternalEditorOfferedWhenOptedInAndAvailable(): void public function testTextareaExternalEditorNotOfferedWhenUnavailable(): void { $field = new Field('f', 'F', '', FieldType::Textarea, '', externalEditor: TRUE); - $widget = (new WidgetFactory(FALSE))->create($field, 'x'); + $widget = (new WidgetFactory(externalEditorAvailable: FALSE))->create($field, 'x'); $this->assertInstanceOf(TextareaWidget::class, $widget); $widget->handle(Key::char("\x05")); @@ -108,13 +109,23 @@ public function testTextareaExternalEditorNotOfferedWhenUnavailable(): void { } public function testTextareaExternalEditorNotOfferedWhenNotOptedIn(): void { - $widget = (new WidgetFactory(TRUE))->create($this->field(FieldType::Textarea), 'x'); + $widget = (new WidgetFactory(externalEditorAvailable: TRUE))->create($this->field(FieldType::Textarea), 'x'); $this->assertInstanceOf(TextareaWidget::class, $widget); $widget->handle(Key::char("\x05")); $this->assertFalse($widget->wantsExternalEdit()); } + public function testInjectsScopedKeymapIntoWidget(): void { + // The vim preset binds j to move-down in the select scope, so the injected + // widget responds to j where a default-preset widget would not. + $widget = (new WidgetFactory(KeyMapManager::create('vim')))->create($this->fieldWithOptions(FieldType::Select), 'a'); + + $widget->handle(Key::char('j')); + + $this->assertSame('b', $widget->value()); + } + /** * A field of the given type. * From f1a7d14f91fe15816ddd9b7a608f18f45c5b4762 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 10 Jul 2026 21:51:32 +1000 Subject: [PATCH 2/2] [#14] Routed external-editor, reveal and toggle keys through the keymap. --- src/Input/DefaultKeyMap.php | 2 ++ src/Input/KeyMap.php | 2 +- src/Input/Scope.php | 1 + src/Theme/DefaultTheme.php | 4 +-- src/Widget/PasswordWidget.php | 24 ++++++++++++--- src/Widget/ToggleWidget.php | 31 ++++++++++---------- tests/phpunit/Unit/Input/KeyMapTest.php | 3 ++ tests/phpunit/Unit/Theme/ThemeRenderTest.php | 2 +- 8 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/Input/DefaultKeyMap.php b/src/Input/DefaultKeyMap.php index 7064966..ee5a815 100644 --- a/src/Input/DefaultKeyMap.php +++ b/src/Input/DefaultKeyMap.php @@ -55,6 +55,8 @@ public function bindings(): array { new Binding(Scope::field(FieldType::Confirm), Action::Yes, 'y', 'Y'), new Binding(Scope::field(FieldType::Confirm), Action::No, 'n', 'N'), + new Binding(Scope::field(FieldType::Toggle), Action::Toggle, KeyName::Left, KeyName::Right, KeyName::Space, KeyName::Up, KeyName::Down), + // The password reveal toggle cycles the display mode on Tab. new Binding(Scope::field(FieldType::Password), Action::Reveal, KeyName::Tab), diff --git a/src/Input/KeyMap.php b/src/Input/KeyMap.php index ea34ac3..bf142b9 100644 --- a/src/Input/KeyMap.php +++ b/src/Input/KeyMap.php @@ -309,7 +309,7 @@ protected function keyLabel(Key $key): string { $char = (string) $key->char; - return $this->isControl($char) ? '^' . chr(ord($char) + 0x40) : $char; + return $this->isControl($char) ? 'ctrl-' . strtolower(chr(ord($char) + 0x40)) : $char; } } diff --git a/src/Input/Scope.php b/src/Input/Scope.php index 49a1ae7..7992d76 100644 --- a/src/Input/Scope.php +++ b/src/Input/Scope.php @@ -36,6 +36,7 @@ FieldType::Suggest, FieldType::MultiSelect, FieldType::MultiSearch, + FieldType::Toggle, ]; /** diff --git a/src/Theme/DefaultTheme.php b/src/Theme/DefaultTheme.php index 04c8d33..235c2c8 100644 --- a/src/Theme/DefaultTheme.php +++ b/src/Theme/DefaultTheme.php @@ -833,8 +833,8 @@ public function keyHint(Key $key): string { if (!$name instanceof KeyName) { $char = (string) $key->char; - // Render a control character (e.g. Ctrl-E) in caret notation. - return $char !== '' && ord($char) < 0x20 ? '^' . chr(ord($char) + 0x40) : $char; + // Render a control character (e.g. Ctrl-E) as "ctrl-e". + return $char !== '' && ord($char) < 0x20 ? 'ctrl-' . strtolower(chr(ord($char) + 0x40)) : $char; } return match ($name) { diff --git a/src/Widget/PasswordWidget.php b/src/Widget/PasswordWidget.php index 110bd08..0f10c4c 100644 --- a/src/Widget/PasswordWidget.php +++ b/src/Widget/PasswordWidget.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -48,18 +50,28 @@ public function __construct(string $buffer = '', ?\Closure $validate = NULL, ?\C parent::__construct($buffer, $validate, $transform); } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Password); + } + /** * {@inheritdoc} */ #[\Override] public function handle(Key $key): void { - if ($this->revealable && $key->is(KeyName::Tab)) { + $keys = $this->keys(); + + if ($this->revealable && $keys->matches($key, Action::Reveal)) { $this->display = $this->display->next(); return; } - if ($this->confirm && $key->is(KeyName::Enter)) { + if ($this->confirm && $keys->matches($key, Action::Accept)) { $this->submit(); return; @@ -118,7 +130,11 @@ public function view(ThemeInterface $theme): string { } if ($this->revealable) { - $rows[] = $theme->footer($theme->enter() . ' accept ' . $theme->dot() . ' tab reveal ' . $theme->dot() . ' esc cancel'); + $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) { diff --git a/src/Widget/ToggleWidget.php b/src/Widget/ToggleWidget.php index 2648900..2f8e4db 100644 --- a/src/Widget/ToggleWidget.php +++ b/src/Widget/ToggleWidget.php @@ -4,8 +4,10 @@ namespace DrevOps\Tui\Widget; +use DrevOps\Tui\Config\FieldType; +use DrevOps\Tui\Input\Action; use DrevOps\Tui\Input\Key; -use DrevOps\Tui\Input\KeyName; +use DrevOps\Tui\Input\Scope; use DrevOps\Tui\Theme\ThemeInterface; /** @@ -46,21 +48,31 @@ public function __construct(protected array $labels, string $default = '', ?\Clo $this->cursor = $index === FALSE ? 0 : $index; } + /** + * {@inheritdoc} + */ + #[\Override] + protected function keyScope(): Scope { + return Scope::field(FieldType::Toggle); + } + /** * {@inheritdoc} */ public function handle(Key $key): void { + $keys = $this->keys(); + if ($this->handleCancel($key)) { return; } - if ($key->is(KeyName::Enter)) { + if ($keys->matches($key, Action::Accept)) { $this->accept($this->liveValue()); return; } - if ($this->isToggle($key)) { + if ($keys->matches($key, Action::Toggle)) { $this->flip(); return; @@ -71,19 +83,6 @@ public function handle(Key $key): void { } } - /** - * Whether the key flips the switch. - * - * @param \DrevOps\Tui\Input\Key $key - * The key to test. - * - * @return bool - * TRUE when the key flips. - */ - protected function isToggle(Key $key): bool { - return in_array($key->name, [KeyName::Left, KeyName::Right, KeyName::Space, KeyName::Up, KeyName::Down], TRUE); - } - /** * Move the selection to the next value, wrapping at the end. */ diff --git a/tests/phpunit/Unit/Input/KeyMapTest.php b/tests/phpunit/Unit/Input/KeyMapTest.php index 1ec5622..413f668 100644 --- a/tests/phpunit/Unit/Input/KeyMapTest.php +++ b/tests/phpunit/Unit/Input/KeyMapTest.php @@ -66,6 +66,9 @@ public static function dataProviderDefaultBindings(): \Iterator { yield 'confirm y is yes' => [Scope::field(FieldType::Confirm), Key::char('y'), Action::Yes, TRUE]; yield 'confirm uppercase y is yes' => [Scope::field(FieldType::Confirm), Key::char('Y'), Action::Yes, TRUE]; yield 'confirm enter accepts' => [Scope::field(FieldType::Confirm), Key::named(KeyName::Enter), Action::Accept, TRUE]; + // Toggle switch. + yield 'toggle space flips' => [Scope::field(FieldType::Toggle), Key::named(KeyName::Space), Action::Toggle, TRUE]; + yield 'toggle enter accepts' => [Scope::field(FieldType::Toggle), Key::named(KeyName::Enter), Action::Accept, TRUE]; // Multi-select. yield 'multiselect space toggles' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Space), Action::Toggle, TRUE]; yield 'multiselect right selects all' => [Scope::field(FieldType::MultiSelect), Key::named(KeyName::Right), Action::SelectAll, TRUE]; diff --git a/tests/phpunit/Unit/Theme/ThemeRenderTest.php b/tests/phpunit/Unit/Theme/ThemeRenderTest.php index 120b5ba..02e9ff0 100644 --- a/tests/phpunit/Unit/Theme/ThemeRenderTest.php +++ b/tests/phpunit/Unit/Theme/ThemeRenderTest.php @@ -217,7 +217,7 @@ public static function dataProviderKeyHint(): \Iterator { yield 'wheel up' => [Key::named(KeyName::MouseWheelUp), '↑', '^']; yield 'wheel down' => [Key::named(KeyName::MouseWheelDown), '↓', 'v']; yield 'character' => [Key::char('j'), 'j', 'j']; - yield 'control character in caret notation' => [Key::char("\x05"), '^E', '^E']; + yield 'control character spelled out' => [Key::char("\x05"), 'ctrl-e', 'ctrl-e']; } public function testKeysHintDropsUnboundActions(): void {