Custom keybindings: registry, live rebinding, config UI - #52
Merged
Conversation
224 hard-coded BINDINGS entries across 25 screens become one KeySpec registry (controllers/keybindings.py) plus app.keys_ctl, which pushes a user's overrides onto Textual's App.set_keymap — the foundation for per-user rebinding. Guarded by a golden snapshot of every screen's pre-migration BINDINGS list.
Every BINDINGS list becomes bindings_for(scope), except ConfigScreen's
(kept literal so a bad rebind can never lock the config screen itself).
keys_ctl.apply() runs on boot/demo/onboarding-restart and after a config
save, so a rebind takes effect without a restart; HACLI.handle_bindings_clash
is a documented no-op for Textual's expected false-positive clashes when
several bindings share one id on purpose (e.g. nav.back).
Every place a key is shown to the user now reads the live keymap instead
of a hard-coded literal: the help screen's inactive-page bindings, and
every docked-log/search/mode-banner hint string. That surfaced a Rich
markup crash — hint text built from the "[" / "]" bracket-key display
strings can look like a markup close tag once adjacent fragments combine
("[" + "/" + "]" -> "[/]") — fixed by escaping ActivityLogPanel.set_hint's
input.
KeyCapturePopup swallows the next raw keypress (ctrl+c cancels, delete resets to default) and validates it against the working overrides via keybindings.validate() before accepting — a conflict re-prompts inline instead of dismissing. The config screen keeps its own uncommitted working copy, only merged into app_config on Save. Fixed a real bug validate() surfaced: two curated ids that share a key by default in the same scope (e.g. dashboard's Use-mode log.toggle and Edit-mode-only dashboard.edit_slot, both "a") were flagged as conflicting with each other even when resetting to that shared default — the exact mode-gated overlap check_action already keeps mutually exclusive. TWINS now exempts these known baseline pairs from the conflict check.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
BINDINGSentries across 25 screens with a singlecontrollers/keybindings.pyregistry (KeySpec/REGISTRY) plusapp.keys_ctl, which pushes a user's overrides onto Textual'sApp.set_keymap.BINDINGSbecomesbindings_for(scope);ConfigScreenis the one deliberate exception, so a bad rebind can never lock the config screen itself.[/]close tag.KeyCapturePopup), validated against conflicts (keybindings.validate) before it's accepted;ctrl+ccancels,deleteresets to default.log.toggleand Edit-mode-onlydashboard.edit_slot, both"a") were flagged as conflicting with each other even when just resetting to that shared default — the exact mode-gated overlapcheck_actionalready keeps mutually exclusive.TWINSnow exempts known baseline pairs.Test plan
pytest tests/unit/test_keybindings.py— registry migration guarded by a golden pre-migration snapshot (tests/unit/binding_snapshot.json), plus registry invariants and pure-function coveragepytest tests/test_keybindings_config.py— end-to-end: rebind via popup + save takes effect live, conflicting/reserved keys re-prompt inline, delete resets to default, reset-all clears overridesuv run ruff check ./uv run pyright— clean1382 passed, 1 skippeduv run hatty --demo(tmux-driven): boots clean,Ctrl+P→ Configuration → Keybindings renders correctly with live data