Skip to content

Additive custom keymaps with conflict detection - #225

Merged
Maxteabag merged 14 commits into
mainfrom
feat/custom-keybindings
May 25, 2026
Merged

Maxteabag merged 14 commits into
mainfrom
feat/custom-keybindings

Conversation

@Maxteabag

Copy link
Copy Markdown
Owner

Builds on #131 — thanks @xtrasmal for kicking this off.

Overrides now merge over the defaults rather than replacing the whole map, the footer and help screen reflect your custom keys, and a config with conflicting bindings is rejected at startup with a clear error. Also wired more places into the keymap (value view, autocomplete, connection editor, error/file pickers) so overrides actually take effect there too.

xtrasmal and others added 3 commits February 2, 2026 14:24
Implements user-facing JSON keymap configuration following the
ThemeManager pattern. Users can now customize all keybindings by
creating JSON files in ~/.sqlit/keymaps/ and setting custom_keymap
in settings.json.

Features:
- KeymapManager class for loading/validating custom keymaps
- FileBasedKeymapProvider implementing KeymapProvider interface
- JSON structure with leader_commands and action_keys arrays
- Graceful error handling (invalid configs don't crash app)
- Comprehensive test suite (11 tests covering all scenarios)
- User documentation with examples and troubleshooting
- Template keymap showing all default bindings

Integration:
- Loads during app startup after theme initialization
- Settings key: custom_keymap (default/name)
- Directory: ~/.sqlit/keymaps/
- Follows established sqlit configuration patterns

Files:
- sqlit/core/keymap_manager.py: Manager implementation
- config/keymap.template.json: User template with examples
- docs/custom-keymaps.md: Complete user guide
- tests/ui/keybindings/test_keymap_manager.py: Test coverage

This enables power users to customize keybindings per CONTRIBUTING.md
line 243 which explicitly permits keyboard binding customization.
Builds on #131 by xtrasmal. Overrides now merge over the defaults
rather than replacing the whole map, the footer and help screen
reflect custom keys, and a config with conflicting bindings is
rejected at startup. Also wires more screens (value view,
autocomplete, connection editor, etc.) into the keymap so user
overrides actually take effect there too.

Co-authored-by: Xander <1174877+xtrasmal@users.noreply.github.com>
JSON shape is now a pure key remap:
{ "keymap": { "action_keys": { "<state>": { "<action>": "<key>" } },
              "leader_commands": { "<menu>": { "<action>": "<key>" } } } }
Unknown (state, action) pairs are rejected with a helpful error.
Label, category, guard, primary, show, priority are all inherited
from defaults — they are no longer user-facing.

KeymapManager moves from sqlit/core/ to sqlit/domains/shell/app/,
alongside ThemeManager. settings_store is now a required argument;
the lazy import from domains is gone and core no longer depends on
domains for this code path.

Also fixes a bug where ":" in INSERT mode could be re-dispatched
through the action router and start command mode mid-SQL-string;
QueryInsertModeState now forbids enter_command_mode. Strips id=
attributes that had no matching keymap default and were therefore
inert.
Maxteabag added 11 commits May 25, 2026 00:10
# Conflicts:
#	sqlit/domains/shell/state/machine.py
When a custom keymap fails to load (invalid JSON, unknown action,
conflicting bindings), the error now shows up as an error-severity
notification in the TUI instead of being buried in stderr where a
running-in-a-terminal user never sees it.

KeymapManager stores the message on self.load_error; startup_flow
picks it up after mount and surfaces it via app.notify, falling back
to stderr on headless or when notify fails. Mirrors the existing
_warn_on_missing_actions / _warn_on_keyring_error pattern.
app.notify() raises NoMatches mid-run_on_mount because the
notification rack's refresh chain touches widgets (#results-area
DataTable) that aren't mounted until after refresh. The notify
exception was swallowed and the user saw nothing.

Wrap the notify call in call_after_refresh so it fires once the
initial paint completes. The existing _warn_on_missing_actions and
_warn_on_keyring_error have the same latent bug — left untouched
here since they rarely fire in practice.
Setting a key to null/""/[] in the keymap removes the default for that
(action, state) — no key triggers it anymore. Makes the natural case
where the user wants a key already taken by another default trivially
solvable:

  "query_normal": {
    "enter_insert_mode": "u",
    "undo": null
  }

Previously this would fail with "key 'u' is bound to multiple actions"
and the user had no in-config way to resolve it. The conflict-error
message now points at the null-unbind option, and the modal's
"defaults are in effect" hint uses the resolved CUSTOM_KEYMAP_DIR
instead of the hardcoded legacy ~/.sqlit/keymaps/ path.
Short pointer in the Configuration section with a minimal example and
a link to docs/custom-keymaps.md. Doesn't add it to the main Keybindings
table — kept advanced-usage voluntary per CONTRIBUTING.md.
The customization flow is now: drop a single JSON file at
~/.config/sqlit/keymap.json, restart sqlit. No settings.json edit,
no mkdir.

- KeymapManager picks up the default file automatically when no
  custom_keymap setting is set. The setting still wins for the
  power-user case where you want several named keymaps in keymaps/.
- README's Custom keybindings subsection is now self-contained
  (path, JSON shape, list/null semantics, multi-keymap escape hatch).
- docs/custom-keymaps.md deleted — the workflow + error messages
  carry the user without separate documentation. Errors already
  list valid actions/states on typo and suggest the null unbind
  on conflict.
- keymap.template.json points at the new path.
…affold

A user editing keymap.json no longer needs to know Textual's internal
key names — `:`, `?`, `/`, `$`, `%`, `*`, `^`, `;`, `@`, `#`, `!`, `&`,
`~`, brackets, etc. work directly. The loader expands them to canonical
Textual variants on read (e.g. `:` covers `colon`, `shift+semicolon`,
and `:` to handle every terminal's emission), so a binding written with
the friendly form works everywhere.

config/keymap.template.json is now the full default keymap in friendly
form (~9KB across every state and menu). A user copies lines they want
to change rather than memorizing action names.

KeymapManager auto-creates an empty keymap.json scaffold in the config
dir on first run so the customization file is discoverable. README
points at both files.

Conflict detection now ignores user-preserved default overlaps (e.g.
the intentional `d` → delete_connection vs delete_connection_folder
overlap in `tree`, both kept by state-machine disambiguation) — only
new user-introduced collisions are flagged.

docs/keymap-actions.md and docs/custom-keymaps.md removed — the
template + README + error messages carry the user without a separate
documentation file to maintain.
…dead code

`_expand_user_key` was naively concatenating the user's modifier prefix
onto each canonical variant. For inputs like `shift+:` — where `:` already
expands to `shift+semicolon` — this produced `shift+shift+semicolon`,
which Textual silently ignores. Now the merge unions user modifiers with
whatever the canonical already carries, so each modifier appears once.

Edge case: input ending in `+` (e.g. `ctrl++`, meaning ctrl + the plus
key) is preserved verbatim — caught by a regression test before pushing.

Also removes `canonical_to_friendly` and `_CANONICAL_TO_FRIENDLY` — the
template-from-defaults generator they were written for was never wired
up, so they were dead code.
# Conflicts:
#	sqlit/domains/shell/state/machine.py
@Maxteabag
Maxteabag merged commit 73204ea into main May 25, 2026
11 checks passed
@Maxteabag
Maxteabag deleted the feat/custom-keybindings branch May 25, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants