Skip to content

[#14] Added configurable key bindings with a vim preset.#30

Merged
AlexSkrypnyk merged 2 commits into
mainfrom
feature/14-configurable-keymap
Jul 10, 2026
Merged

[#14] Added configurable key bindings with a vim preset.#30
AlexSkrypnyk merged 2 commits into
mainfrom
feature/14-configurable-keymap

Conversation

@AlexSkrypnyk

Copy link
Copy Markdown
Member

Closes #14

Summary

Makes the TUI's key bindings configurable instead of hardcoded, adding a semantic keymap subsystem under src/Input/ that mirrors the existing theme subsystem: an Action enum of intents (MoveUp, Accept, Toggle, NewLine, ExternalEdit, Reveal, Quit, and more), a Scope value object (base / navigation / per-FieldType), Binding, ScopedKeyMap, a validating KeyMap, DefaultKeyMap and VimKeyMap presets, and a KeyMapManager registry/factory. Form::keys($preset, $overrides) resolves and validates the map at build time into Config->keymap, which PanelController and WidgetFactory inject into the panel dispatcher and every widget; each now asks matches($key, Action) instead of testing a raw KeyName, including the textarea's Ctrl-E external-editor handoff, the password reveal toggle and confirm submit, and the toggle switch. On-screen key hints are derived from the live bindings so they can never drift from what a key actually does, and a bad binding - a conflict, an un-typeable printable character, an unknown preset - throws at form-build time rather than mid-session. Ships a vim preset and a playground/8-key-bindings/ example; the defaults reproduce the previous, hardcoded keys exactly.

Changes

  • Added the src/Input/ keymap subsystem: Action (a pure enum of the semantic intents widgets understand: movement, Accept/Cancel, panel Activate/Back/Quit/scrolling, text editing including NewLine/ExternalEdit, list Toggle/SelectAll/SelectNone, confirm Yes/No, password Reveal), Scope (base / navigation / per-FieldType, and which scopes consume typed characters), Binding (a scope plus an action plus its keys), and ScopedKeyMap (matches(), keysFor(), primary() for one scope).
  • Added KeyMap, which layers a flat binding list (a preset's defaults, then any overrides, later wins) into per-scope maps and validates eagerly: a key bound to two different actions in one scope, or a printable character bound where it would be un-typeable, throws InvalidArgumentException at resolution time.
  • Added DefaultKeyMap (reproduces the previous hardcoded bindings exactly) and VimKeyMap (adds h/j/k/l in the navigation and select scopes only, leaving every text and filter scope on arrows), plus KeyMapManager as the name-to-class registry and factory (create($preset, $overrides)), which rejects an unknown preset name outright.
  • Wired Form::keys($preset, $overrides) to resolve through KeyMapManager::create() into the new Config->keymap property at Form::build() time, so a bad binding fails at form-build time rather than mid-session.
  • Injected the resolved KeyMap into PanelController (keeps the navigation scope for its own dispatch) and into WidgetFactory, which calls the new WidgetInterface::setKeys() on every widget it builds, handing it the bindings for its field type.
  • Routed every widget through matches(): AbstractWidget gained setKeys(), a keyScope() hook (defaulting to the base scope) and a lazy keys() accessor that falls back to the default preset when a widget is built directly; handle()/handleCancel() across all widgets now call $this->keys()->matches($key, Action::...) in place of $key->is(KeyName::...), including the textarea's Ctrl-E external-editor handoff, the password reveal toggle and confirm submit, and the toggle switch.
  • Derived on-screen hints from the live bindings: ThemeInterface/DefaultTheme gained keyHint(Key) and keysHint(ScopedKeyMap, label, Action...), and renderStatusLine(), renderEditor() and the multiselect/password/textarea hint lines now build their footer text from the actual bound keys instead of a literal string.
  • Added playground/8-key-bindings/run.php, running one form under --keys=default, --keys=vim or --keys=custom (a per-scope override sample), so the effect of each is visible side by side.
  • Updated the README with a "Key bindings" section (->keys() usage, per-widget-type overrides, presets and validation rules) and regenerated docs/architecture/architecture.puml/.svg and dataflow-tui.puml/.svg to show KeyMap/KeyMapManager alongside the existing theme components.
  • Added tests/phpunit/Unit/Input/KeyMapTest.php and extended FormTest, KeyStreamTest, ThemeRenderTest, WidgetFactoryTest, SelectWidgetTest and PauseWidgetTest to cover the new resolution, injection and derived-hint behavior.

Before / After

BEFORE - every call site hardcodes its own KeyName check
┌────────────────────────────────────────────────────────────────────┐
│ PanelController::handleNavigation()                                │
│   if ($key->is(KeyName::Up))     { $this->cursor--; }              │
│   if ($key->is(KeyName::Enter))  { $this->activate(); }            │
│                                                                    │
│ AbstractWidget::handleCancel() - copied into every widget          │
│   if ($key->is(KeyName::Escape)) { $this->cancelled = TRUE; }      │
│                                                                    │
│ TextareaWidget, PasswordWidget, ToggleWidget, ConfirmWidget each   │
│ re-test their own fixed KeyName, or a private isToggle() helper -  │
│ 40+ sites in total. Remapping a key means editing library source.  │
└────────────────────────────────────────────────────────────────────┘

AFTER - a semantic Action layer resolved by one configurable KeyMap
┌────────────────────────────────────────────────────────────────────┐
│ Form::keys('vim', [new Binding(Scope::navigation(),                │
│                                 Action::Quit, 'x')])               │
│                            │                                       │
│                            ▼                                       │
│ KeyMapManager::create() - validates eagerly: a conflicting,        │
│ un-typeable or unknown-preset binding throws here, not mid-session │
│                            │                                       │
│                            ▼                                       │
│ Config->keymap : KeyMap - injected into PanelController and        │
│                           WidgetFactory                            │
│                            │                                       │
│                            ▼                                       │
│ $nav->matches($key, Action::MoveUp)                                │
│ $this->keys()->matches($key, Action::Cancel)     (every widget)    │
│                            │                                       │
│                            ▼                                       │
│ Footer hints render from that same ScopedKeyMap - they can never   │
│ drift from what a key actually does.                               │
└────────────────────────────────────────────────────────────────────┘

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 32 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 087779ca-86dc-46bf-ac21-1c8c61c09ec7

📥 Commits

Reviewing files that changed from the base of the PR and between 35337ae and f1a7d14.

⛔ Files ignored due to path filters (2)
  • docs/architecture/architecture.svg is excluded by !**/*.svg
  • docs/architecture/dataflow-tui.svg is excluded by !**/*.svg
📒 Files selected for processing (41)
  • README.md
  • docs/architecture/README.md
  • docs/architecture/architecture.puml
  • docs/architecture/dataflow-tui.puml
  • playground/2-custom-theme/OceanTheme.php
  • playground/8-key-bindings/run.php
  • playground/README.md
  • src/Builder/Form.php
  • src/Config/Config.php
  • src/Input/Action.php
  • src/Input/Binding.php
  • src/Input/DefaultKeyMap.php
  • src/Input/Key.php
  • src/Input/KeyMap.php
  • src/Input/KeyMapManager.php
  • src/Input/Scope.php
  • src/Input/ScopedKeyMap.php
  • src/Input/VimKeyMap.php
  • src/Render/PanelController.php
  • src/Theme/DefaultTheme.php
  • src/Theme/ThemeInterface.php
  • src/Widget/AbstractWidget.php
  • src/Widget/ConfirmWidget.php
  • src/Widget/MultiSelectWidget.php
  • src/Widget/PasswordWidget.php
  • src/Widget/PauseWidget.php
  • src/Widget/SearchWidget.php
  • src/Widget/SelectWidget.php
  • src/Widget/SuggestWidget.php
  • src/Widget/TextWidget.php
  • src/Widget/TextareaWidget.php
  • src/Widget/ToggleWidget.php
  • src/Widget/WidgetFactory.php
  • src/Widget/WidgetInterface.php
  • tests/phpunit/Unit/Builder/FormTest.php
  • tests/phpunit/Unit/Input/KeyMapTest.php
  • tests/phpunit/Unit/Input/KeyStreamTest.php
  • tests/phpunit/Unit/Theme/ThemeRenderTest.php
  • tests/phpunit/Unit/Widget/PauseWidgetTest.php
  • tests/phpunit/Unit/Widget/SelectWidgetTest.php
  • tests/phpunit/Unit/Widget/WidgetFactoryTest.php
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/14-configurable-keymap

Comment @coderabbitai help to get the list of available commands.

@AlexSkrypnyk AlexSkrypnyk added A2 Board worker 2 AUTOMERGE Worker auto-merges this PR once it is ready for review labels Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Code Coverage Report:
  2026-07-10 12:06:24

 Summary:
  Classes: 91.04% (61/67)
  Methods: 97.69% (423/433)
  Lines:   99.32% (1757/1769)

DrevOps\Tui\Answers\Answer
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Answers\Answers
  Methods: 100.00% ( 9/ 9)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Answers\SummaryFormatter
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Builder\FieldBuilder
  Methods: 100.00% (22/22)   Lines: 100.00% ( 73/ 73)
DrevOps\Tui\Builder\Form
  Methods:  93.33% (14/15)   Lines:  98.44% ( 63/ 64)
DrevOps\Tui\Builder\PanelBuilder
  Methods: 100.00% (17/17)   Lines: 100.00% ( 29/ 29)
DrevOps\Tui\Condition\CompositeCondition
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 19/ 19)
DrevOps\Tui\Condition\Condition
  Methods: 100.00% (10/10)   Lines: 100.00% ( 38/ 38)
DrevOps\Tui\Config\Config
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 17/ 17)
DrevOps\Tui\Config\Field
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  2/  2)
DrevOps\Tui\Config\NumberBounds
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Config\Option
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Config\Panel
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Derive\Derive
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 14/ 14)
DrevOps\Tui\Derive\Deriver
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% ( 13/ 13)
DrevOps\Tui\Derive\Transform
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  7/  7)
DrevOps\Tui\Discovery\AbstractDiscover
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Discovery\Dotenv
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 19/ 19)
DrevOps\Tui\Discovery\JsonValue
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 16/ 16)
DrevOps\Tui\Discovery\PathExists
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  3/  3)
DrevOps\Tui\Discovery\Scan
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Engine\Engine
  Methods: 100.00% (12/12)   Lines: 100.00% ( 92/ 92)
DrevOps\Tui\Handler\HandlerRegistry
  Methods:  85.71% ( 6/ 7)   Lines:  95.45% ( 21/ 22)
DrevOps\Tui\Input\ArrayKeyStream
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 12/ 12)
DrevOps\Tui\Input\Binding
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Input\DefaultKeyMap
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% ( 30/ 30)
DrevOps\Tui\Input\Key
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% (  7/  7)
DrevOps\Tui\Input\KeyMap
  Methods: 100.00% (12/12)   Lines: 100.00% ( 65/ 65)
DrevOps\Tui\Input\KeyMapManager
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  8/  8)
DrevOps\Tui\Input\KeyParser
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 68/ 68)
DrevOps\Tui\Input\Scope
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 11/ 11)
DrevOps\Tui\Input\ScopedKeyMap
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% (  4/  4)
DrevOps\Tui\Input\VimKeyMap
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  8/  8)
DrevOps\Tui\Render\Ansi
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% (  5/  5)
DrevOps\Tui\Render\Box
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 15/ 15)
DrevOps\Tui\Render\ExternalEditor
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 27/ 27)
DrevOps\Tui\Render\Navigator
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 15/ 15)
DrevOps\Tui\Render\PanelController
  Methods: 100.00% (16/16)   Lines: 100.00% ( 95/ 95)
DrevOps\Tui\Render\Scroller
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 12/ 12)
DrevOps\Tui\Render\Terminal
  Methods: 100.00% (12/12)   Lines: 100.00% ( 35/ 35)
DrevOps\Tui\Render\TerminalControl
  Methods: 100.00% ( 9/ 9)   Lines: 100.00% (  9/  9)
DrevOps\Tui\Render\Viewport
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Resolver\InputResolver
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 27/ 27)
DrevOps\Tui\Schema\AgentHelp
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 25/ 25)
DrevOps\Tui\Schema\SchemaGenerator
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 28/ 28)
DrevOps\Tui\Schema\SchemaValidator
  Methods: 100.00% ( 8/ 8)   Lines: 100.00% ( 45/ 45)
DrevOps\Tui\Theme\DefaultTheme
  Methods:  92.06% (58/63)   Lines:  97.22% (210/216)
DrevOps\Tui\Theme\ThemeManager
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  8/  8)
DrevOps\Tui\Tui
  Methods: 100.00% (12/12)   Lines: 100.00% ( 26/ 26)
DrevOps\Tui\Widget\AbstractWidget
  Methods: 100.00% (13/13)   Lines: 100.00% ( 24/ 24)
DrevOps\Tui\Widget\ConfirmWidget
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 22/ 22)
DrevOps\Tui\Widget\MultiSearchWidget
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Widget\MultiSelectWidget
  Methods: 100.00% (10/10)   Lines: 100.00% ( 66/ 66)
DrevOps\Tui\Widget\NumberWidget
  Methods: 100.00% ( 9/ 9)   Lines: 100.00% ( 37/ 37)
DrevOps\Tui\Widget\PasswordDisplay
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  5/  5)
DrevOps\Tui\Widget\PasswordWidget
  Methods: 100.00% ( 8/ 8)   Lines: 100.00% ( 44/ 44)
DrevOps\Tui\Widget\PauseWidget
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 10/ 10)
DrevOps\Tui\Widget\SearchWidget
  Methods:  80.00% ( 4/ 5)   Lines:  94.74% ( 36/ 38)
DrevOps\Tui\Widget\SelectWidget
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Widget\SuggestWidget
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 38/ 38)
DrevOps\Tui\Widget\TextWidget
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 31/ 31)
DrevOps\Tui\Widget\TextareaWidget
  Methods: 100.00% ( 8/ 8)   Lines: 100.00% ( 52/ 52)
DrevOps\Tui\Widget\ToggleWidget
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 31/ 31)
DrevOps\Tui\Widget\WidgetFactory
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 28/ 28)
DrevOps\Tui\Widget\WidgetRunner
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  5/  5)

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.32%. Comparing base (db1d39d) to head (f1a7d14).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #30      +/-   ##
==========================================
+ Coverage   98.92%   99.32%   +0.39%     
==========================================
  Files          59       67       +8     
  Lines        1493     1769     +276     
==========================================
+ Hits         1477     1757     +280     
+ Misses         16       12       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@AlexSkrypnyk AlexSkrypnyk merged commit f5b741e into main Jul 10, 2026
9 checks passed
@AlexSkrypnyk AlexSkrypnyk deleted the feature/14-configurable-keymap branch July 10, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A2 Board worker 2 AUTOMERGE Worker auto-merges this PR once it is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make key bindings configurable

1 participant