diff --git a/README.md b/README.md index 49c0bee9..8ca45e6b 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,10 @@ Connections and settings are stored in `$XDG_CONFIG_HOME/sqlit/` (default: `~/.c If an older install left files in `~/.sqlit/`, they are moved to the new location automatically on first run. +### Custom keybindings + +Edit the `keymap.json` file in your sqlit config dir. See [`config/keymap.template.json`](config/keymap.template.json) for the full default keymap. Keymap.json need to only contain the overriding keymaps. + ## FAQ ### How are sensitive credentials stored? diff --git a/config/keymap.template.json b/config/keymap.template.json new file mode 100644 index 00000000..4fee0442 --- /dev/null +++ b/config/keymap.template.json @@ -0,0 +1,352 @@ +{ + "keymap": { + "action_keys": { + "global": { + "leader_key": "space", + "quit": "ctrl+q", + "cancel_operation": "escape", + "show_help": "?", + "enter_command_mode": ":", + "undo": "ctrl+z", + "redo": "ctrl+y" + }, + "navigation": { + "focus_explorer": "e", + "focus_query": "q", + "focus_results": "r" + }, + "tree": { + "new_connection": "n", + "enter_tree_visual_mode": "v", + "clear_connection_selection": "escape", + "select_table": "s", + "refresh_tree": [ + "f", + "R" + ], + "edit_connection": "e", + "rename_connection_folder": "M", + "delete_connection_folder": [ + "d", + "delete" + ], + "delete_connection": [ + "d", + "delete" + ], + "duplicate_connection": "D", + "move_connection_to_folder": "m", + "disconnect": "x", + "collapse_tree": "z", + "tree_cursor_down": [ + "j", + "down" + ], + "tree_cursor_up": [ + "k", + "up" + ], + "tree_filter": "/" + }, + "tree_filter": { + "tree_filter_close": "escape", + "tree_filter_accept": "enter" + }, + "tree_visual": { + "exit_tree_visual_mode": [ + "escape", + "v" + ] + }, + "query_normal": { + "enter_insert_mode": "i", + "prepend_insert_mode": "I", + "open_line_below": "o", + "open_line_above": "O", + "execute_query": "enter", + "paste": "p", + "yank_leader_key": "y", + "change_leader_key": "c", + "g_leader_key": "g", + "show_history": "backspace", + "new_query": "N", + "delete_leader_key": "d", + "change_line_end_motion": "C", + "delete_line_end": "D", + "undo": "u", + "redo": "ctrl+r", + "cursor_left": "h", + "cursor_down": "j", + "cursor_up": "k", + "cursor_right": "l", + "cursor_word_forward": "w", + "cursor_WORD_forward": "W", + "cursor_word_back": "b", + "cursor_WORD_back": "B", + "cursor_line_start": "0", + "cursor_first_non_blank": "^", + "cursor_line_end": "$", + "cursor_last_line": "G", + "cursor_matching_bracket": "%", + "cursor_find_char": "f", + "cursor_find_char_back": "F", + "cursor_till_char": "t", + "cursor_till_char_back": "T", + "enter_visual_mode": "v", + "enter_visual_line_mode": "V", + "delete_char": "x", + "append_insert_mode": "a", + "append_line_end": "A" + }, + "query_insert": { + "exit_insert_mode": "escape", + "execute_query_insert": "ctrl+enter", + "select_all": "ctrl+a", + "copy_selection": "ctrl+c", + "paste": "ctrl+v" + }, + "query_visual": { + "exit_visual_mode": [ + "escape", + "v" + ], + "switch_to_visual_line_mode": "V", + "visual_yank": "y", + "visual_delete": [ + "d", + "x" + ], + "visual_change": "c", + "visual_execute": "enter", + "cursor_left": [ + "h", + "left" + ], + "cursor_down": [ + "j", + "down" + ], + "cursor_up": [ + "k", + "up" + ], + "cursor_right": [ + "l", + "right" + ], + "cursor_word_forward": "w", + "cursor_WORD_forward": "W", + "cursor_word_back": "b", + "cursor_WORD_back": "B", + "cursor_line_start": "0", + "cursor_first_non_blank": "^", + "cursor_line_end": "$", + "cursor_last_line": "G", + "g_leader_key": "g", + "cursor_matching_bracket": "%", + "cursor_find_char": "f", + "cursor_find_char_back": "F", + "cursor_till_char": "t", + "cursor_till_char_back": "T" + }, + "query_visual_line": { + "exit_visual_line_mode": [ + "escape", + "V" + ], + "switch_to_visual_mode": "v", + "visual_line_yank": "y", + "visual_line_delete": [ + "d", + "x" + ], + "visual_line_change": "c", + "cursor_down": [ + "j", + "down" + ], + "cursor_up": [ + "k", + "up" + ], + "cursor_last_line": "G", + "g_leader_key": "g", + "visual_line_execute": "enter" + }, + "autocomplete": { + "autocomplete_next": [ + "ctrl+j", + "down" + ], + "autocomplete_prev": [ + "ctrl+k", + "up" + ], + "autocomplete_accept": "tab", + "autocomplete_close": "escape" + }, + "results": { + "view_cell": "v", + "view_cell_full": "V", + "edit_cell": "u", + "delete_row": "d", + "results_yank_leader_key": "y", + "clear_results": "x", + "results_filter": "/", + "results_cursor_left": "h", + "results_cursor_down": "j", + "results_cursor_up": "k", + "results_cursor_right": "l", + "rg_leader_key": "g", + "results_cursor_last_row": "G", + "results_page_up": "ctrl+u", + "results_page_down": "ctrl+d", + "results_cursor_first_column": "0", + "results_cursor_last_column": "$", + "results_column_picker": [ + "f", + "F" + ], + "next_result_section": "tab", + "prev_result_section": "shift+tab", + "toggle_result_section": "z" + }, + "results_filter": { + "results_filter_close": "escape", + "results_filter_accept": "enter" + }, + "value_view": { + "close_value_view": [ + "q", + "escape" + ], + "copy_value_view": "y", + "toggle_value_view_mode": "t", + "collapse_all_json_nodes": "z", + "expand_all_json_nodes": "Z" + } + }, + "leader_commands": { + "leader": { + "toggle_explorer": "e", + "toggle_fullscreen": "f", + "show_connection_picker": "c", + "disconnect": "x", + "cancel_operation": "z", + "change_theme": "t", + "edit_query_in_editor": "o", + "show_help": "h", + "telescope": "space", + "telescope_filter": "/", + "quit": "q" + }, + "delete": { + "line": "d", + "word": "w", + "WORD": "W", + "word_back": "b", + "WORD_back": "B", + "word_end": "e", + "WORD_end": "E", + "line_start": "0", + "line_end_motion": "$", + "line_end": "D", + "char": "x", + "char_back": "X", + "left": "h", + "down": "j", + "up": "k", + "right": "l", + "to_end": "G", + "find_char": "f", + "find_char_back": "F", + "till_char": "t", + "till_char_back": "T", + "matching_bracket": "%", + "inner": "i", + "around": "a" + }, + "yank": { + "line": "y", + "word": "w", + "WORD": "W", + "word_back": "b", + "WORD_back": "B", + "word_end": "e", + "WORD_end": "E", + "line_start": "0", + "line_end_motion": "$", + "left": "h", + "down": "j", + "up": "k", + "right": "l", + "to_end": "G", + "find_char": "f", + "find_char_back": "F", + "till_char": "t", + "till_char_back": "T", + "matching_bracket": "%", + "inner": "i", + "around": "a" + }, + "change": { + "line": "c", + "word": "w", + "WORD": "W", + "word_back": "b", + "WORD_back": "B", + "word_end": "e", + "WORD_end": "E", + "line_start": "0", + "line_end_motion": "$", + "left": "h", + "down": "j", + "up": "k", + "right": "l", + "to_end": "G", + "find_char": "f", + "find_char_back": "F", + "till_char": "t", + "till_char_back": "T", + "matching_bracket": "%", + "inner": "i", + "around": "a" + }, + "g": { + "first_line": "g", + "word_end_back": "e", + "WORD_end_back": "E", + "comment": "c", + "execute_query": "r", + "execute_single_statement": "s", + "execute_query_atomic": "t" + }, + "gc": { + "line": "c", + "down": "j", + "up": "k", + "to_end": "G", + "selection": "s", + "statement": "S" + }, + "ry": { + "cell": "c", + "row": "y", + "all": "a", + "export": "e" + }, + "rye": { + "csv": "c", + "json": "j" + }, + "rg": { + "first_row": "g" + }, + "vy": { + "value": "y", + "field": "f", + "all": "a" + } + } + } +} diff --git a/config/settings.template.json b/config/settings.template.json index f09352c1..727ad1b0 100644 --- a/config/settings.template.json +++ b/config/settings.template.json @@ -2,6 +2,7 @@ "_note": "Copy to .sqlit/settings.json (gitignored) and run: sqlit --settings .sqlit/settings.json", "theme": "tokyo-night", "custom_themes": [], + "custom_keymap": "default", "expanded_nodes": [], "process_worker": true, "process_worker_warm_on_idle": true, diff --git a/sqlit/core/__init__.py b/sqlit/core/__init__.py index 761fd29f..418a57c8 100644 --- a/sqlit/core/__init__.py +++ b/sqlit/core/__init__.py @@ -5,6 +5,7 @@ ActionKeyDef, KeymapProvider, LeaderCommandDef, + build_textual_keymap, format_key, get_keymap, reset_keymap, @@ -19,6 +20,7 @@ "KeymapProvider", "LeaderCommandDef", "VimMode", + "build_textual_keymap", "format_key", "get_keymap", "get_leader_commands", diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 5e93e0f4..f5048374 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections import defaultdict from dataclasses import dataclass from sqlit.shared.core.debug_events import emit_debug_event @@ -339,6 +340,9 @@ def _build_action_keys(self) -> list[ActionKeyDef]: ActionKeyDef("ctrl+q", "quit", "global"), ActionKeyDef("escape", "cancel_operation", "global"), ActionKeyDef("question_mark", "show_help", "global"), + ActionKeyDef("colon", "enter_command_mode", "global"), + ActionKeyDef("shift+semicolon", "enter_command_mode", "global", primary=False), + ActionKeyDef(":", "enter_command_mode", "global", primary=False), # Query (normal mode) ActionKeyDef("i", "enter_insert_mode", "query_normal"), ActionKeyDef("I", "prepend_insert_mode", "query_normal"), @@ -428,14 +432,19 @@ def _build_action_keys(self) -> list[ActionKeyDef]: # Query (insert mode) ActionKeyDef("escape", "exit_insert_mode", "query_insert"), ActionKeyDef("ctrl+enter", "execute_query_insert", "query_insert"), - ActionKeyDef("tab", "autocomplete_accept", "query_insert"), + # autocomplete_accept lives in the 'autocomplete' context below; + # the query_insert context still acquires it implicitly when the + # dropdown is visible. # Navigation ActionKeyDef("e", "focus_explorer", "navigation"), ActionKeyDef("q", "focus_query", "navigation"), ActionKeyDef("r", "focus_results", "navigation"), # Query (autocomplete) ActionKeyDef("ctrl+j", "autocomplete_next", "autocomplete"), + ActionKeyDef("down", "autocomplete_next", "autocomplete", primary=False), ActionKeyDef("ctrl+k", "autocomplete_prev", "autocomplete"), + ActionKeyDef("up", "autocomplete_prev", "autocomplete", primary=False), + ActionKeyDef("tab", "autocomplete_accept", "autocomplete"), ActionKeyDef("escape", "autocomplete_close", "autocomplete"), # Clipboard (only in insert mode for vim consistency) ActionKeyDef("ctrl+a", "select_all", "query_insert"), @@ -553,3 +562,18 @@ def emit_keybinding_snapshot(provider: KeymapProvider | None = None) -> None: keymap._action_emitted = True # type: ignore[attr-defined] except Exception: pass + + +def build_textual_keymap(provider: KeymapProvider) -> dict[str, str]: + """Build a Textual-compatible keymap dict from a KeymapProvider. + + Groups action keys by action name (across contexts) and joins the keys + with commas — Textual's set_keymap accepts comma-separated key lists. + Use the result with ``App.set_keymap(...)`` so that Bindings with + matching ``id=`` get their keys overridden at runtime. + """ + by_action: dict[str, list[str]] = defaultdict(list) + for ak in provider.get_action_keys(): + if ak.key not in by_action[ak.action]: + by_action[ak.action].append(ak.key) + return {action: ",".join(keys) for action, keys in by_action.items()} diff --git a/sqlit/domains/query/state/query_insert.py b/sqlit/domains/query/state/query_insert.py index 2b479440..90e5f467 100644 --- a/sqlit/domains/query/state/query_insert.py +++ b/sqlit/domains/query/state/query_insert.py @@ -24,12 +24,16 @@ def _setup_actions(self) -> None: # Undo/redo self.allows("undo", help="Undo") self.allows("redo", help="Redo") + # enter_command_mode is forbidden so ":" reaches the text area when + # the user is typing a SQL literal — the action router would otherwise + # re-dispatch the keymap entry and start command mode. self.forbids( "focus_explorer", "focus_results", "leader_key", "new_connection", "show_help", + "enter_command_mode", ) def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], list[DisplayBinding]]: diff --git a/sqlit/domains/query/ui/mixins/autocomplete.py b/sqlit/domains/query/ui/mixins/autocomplete.py index 9e6eead7..b2d9ab35 100644 --- a/sqlit/domains/query/ui/mixins/autocomplete.py +++ b/sqlit/domains/query/ui/mixins/autocomplete.py @@ -245,21 +245,30 @@ def on_key(self: AutocompleteMixinHost, event: Any) -> None: dropdown = self.autocomplete_dropdown - if event.key == "down": + from sqlit.core.keymap import get_keymap + keymap = get_keymap() + next_keys = keymap.keys_for_action("autocomplete_next") + prev_keys = keymap.keys_for_action("autocomplete_prev") + accept_keys = keymap.keys_for_action("autocomplete_accept") + close_keys = keymap.keys_for_action("autocomplete_close") + + if event.key in next_keys: dropdown.move_selection(1) event.prevent_default() event.stop() - elif event.key == "up": + elif event.key in prev_keys: dropdown.move_selection(-1) event.prevent_default() event.stop() - elif event.key in ("tab", "enter"): + elif event.key in accept_keys or event.key == "enter": + # 'enter' is hardcoded as a secondary accept key — it doubles as + # newline-insert when there are no suggestions, so accept-on-enter + # only fires when the dropdown has results. if self.vim_mode == VimMode.INSERT and dropdown.filtered_items: self._apply_autocomplete() event.prevent_default() event.stop() - elif event.key == "escape": - # Hide autocomplete AND exit insert mode (go to normal mode) + elif event.key in close_keys: self.action_exit_insert_mode() event.prevent_default() event.stop() diff --git a/sqlit/domains/query/ui/screens/query_history.py b/sqlit/domains/query/ui/screens/query_history.py index 01a31e2f..a3b8223d 100644 --- a/sqlit/domains/query/ui/screens/query_history.py +++ b/sqlit/domains/query/ui/screens/query_history.py @@ -23,8 +23,7 @@ class QueryHistoryScreen(ModalScreen): """Modal screen for query history selection.""" BINDINGS = [ - Binding("escape", "cancel", "Cancel", priority=True), - Binding("q", "cancel", "Cancel"), + Binding("escape,q", "cancel", "Cancel", priority=True), Binding("enter", "select", "Select"), Binding("d", "delete", "Delete"), Binding("asterisk", "toggle_star", "Star"), diff --git a/sqlit/domains/results/ui/screens/value_view.py b/sqlit/domains/results/ui/screens/value_view.py index bcc12d0e..f80a4729 100644 --- a/sqlit/domains/results/ui/screens/value_view.py +++ b/sqlit/domains/results/ui/screens/value_view.py @@ -18,13 +18,17 @@ class ValueViewScreen(ModalScreen): """Modal screen for viewing a single (potentially long) value with tree/syntax toggle.""" + # Each id matches an action name in DefaultKeymapProvider so that + # `App.set_keymap(...)` can remap them per the user's custom keymap. + # `enter` stays unbinded by id because it's a Textual-modal convention, + # not a user-overridable action. BINDINGS = [ - Binding("escape", "dismiss", "Close"), + Binding("escape,q", "dismiss", "Close", id="close_value_view"), Binding("enter", "dismiss", "Close"), - Binding("q", "dismiss", "Close"), - Binding("y", "copy", "Copy"), - Binding("t", "toggle_view", "Toggle View"), - Binding("z", "collapse_all", "Collapse All", show=False), + Binding("y", "copy", "Copy", id="copy_value_view"), + Binding("t", "toggle_view", "Toggle View", id="toggle_value_view_mode"), + Binding("z", "collapse_all", "Collapse All", show=False, id="collapse_all_json_nodes"), + Binding("Z", "expand_all", "Expand All", show=False, id="expand_all_json_nodes"), ] CSS = """ @@ -133,6 +137,17 @@ def action_collapse_all(self) -> None: except Exception: pass + def action_expand_all(self) -> None: + """Expand all tree nodes.""" + if self._is_json and self._tree_mode: + try: + tree = self.query_one("#json-tree-modal", JSONTreeView) + expand_all = getattr(tree, "action_expand_all", None) + if callable(expand_all): + expand_all() + except Exception: + pass + def action_copy(self) -> None: from sqlit.shared.ui.widgets import flash_widget diff --git a/sqlit/domains/shell/app/keymap_manager.py b/sqlit/domains/shell/app/keymap_manager.py new file mode 100644 index 00000000..75058ff1 --- /dev/null +++ b/sqlit/domains/shell/app/keymap_manager.py @@ -0,0 +1,535 @@ +"""Loads the user's custom keymap and registers it with the core keymap. + +Follows the same domain-service pattern as +[`ThemeManager`][sqlit.domains.shell.app.theme_manager] — a settings-driven +configuration step run during app startup. The loader looks for, in order: + +1. A named keymap selected by the ``custom_keymap`` setting, resolved + relative to ``/keymaps/.json`` (or an absolute path). +2. ``/keymap.json`` if it exists — picked up automatically + so a user can customize without editing settings.json. + +``CONFIG_DIR`` resolves to ``$SQLIT_CONFIG_DIR`` if set, otherwise +``$XDG_CONFIG_HOME/sqlit`` (defaulting to ``~/.config/sqlit``). See +:mod:`sqlit.shared.core.store`. + +The JSON is strictly a *key remapping*. The set of actions and the states +they live in are defined in :mod:`sqlit.core.keymap`; the user only +chooses which key(s) trigger each action:: + + { + "keymap": { + "action_keys": { + "": { + "": "" // single key + | ["", "..."] // primary + aliases + } + }, + "leader_commands": { + "": { + "": "" + } + } + } + } + +The loader validates that every ``(state, action)`` and ``(menu, action)`` +pair the user names exists in the defaults; unknown ones abort the load +with a clear error. After merging, the keymap is also validated for +conflicts (two actions claiming the same key in the same state/menu); on +conflict the loader falls back to defaults and prints every collision to +stderr so the user can fix their config. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from pathlib import Path +from typing import Any + +from sqlit.core.keymap import ( + ActionKeyDef, + DefaultKeymapProvider, + KeymapProvider, + LeaderCommandDef, + set_keymap, +) +from sqlit.shared.core.protocols import SettingsStoreProtocol +from sqlit.shared.core.store import CONFIG_DIR + +CUSTOM_KEYMAP_SETTINGS_KEY = "custom_keymap" +CUSTOM_KEYMAP_DIR = CONFIG_DIR / "keymaps" +# Picked up automatically when no `custom_keymap` setting is present. +# Lets a user customize their bindings by dropping a single file in +# their config dir — no settings edit, no mkdir. +DEFAULT_KEYMAP_FILE = CONFIG_DIR / "keymap.json" + +# Friendly literal characters → the canonical Textual key name(s) they +# expand to. Lets the user write `"?"` instead of `"question_mark"` and +# `":"` instead of spelling out the colon's three terminal variants. +# Multi-element values cover the platforms / terminals that emit +# different `event.key` strings for the same physical keypress. +_FRIENDLY_TO_CANONICAL: dict[str, list[str]] = { + "?": ["question_mark"], + "/": ["slash"], + "$": ["dollar_sign"], + "%": ["percent_sign"], + "*": ["asterisk"], + "^": ["circumflex_accent"], + ":": ["colon", "shift+semicolon", ":"], + ";": ["semicolon"], + "@": ["at"], + "#": ["number_sign"], + "!": ["exclamation_mark"], + "&": ["ampersand"], + "~": ["tilde"], + "`": ["grave_accent"], + "(": ["left_parenthesis"], + ")": ["right_parenthesis"], + "[": ["left_square_bracket"], + "]": ["right_square_bracket"], + "{": ["left_curly_bracket"], + "}": ["right_curly_bracket"], + "<": ["less_than_sign"], + ">": ["greater_than_sign"], + "|": ["vertical_line"], + "_": ["underscore"], +} + +def _expand_user_key(key: str) -> list[str]: + """Expand a single user-supplied key string to its canonical Textual form(s). + + Splits off modifier prefixes (``ctrl+``, ``shift+``, ``alt+``, ``cmd+``), + looks up the base character in the friendly-name table, and re-attaches + the modifiers to each canonical variant. Unknown bases pass through + unchanged so Textual's own key names (e.g. ``escape``, ``f5``) keep + working. + + When a canonical variant already carries one of the user's modifiers + (e.g. ``":"`` expands to include ``"shift+semicolon"`` and the user + wrote ``"shift+:"``), we deduplicate so we don't produce a malformed + ``shift+shift+semicolon`` that Textual would silently ignore. + """ + parts = key.split("+") + base = parts[-1] + modifiers = parts[:-1] + + # Trailing "+" in input (e.g. "ctrl++") means the literal plus key. + # Splitting leaves an empty base — preserve the user's input verbatim + # rather than trying to canonicalize. + if base == "": + return [key] + + canonicals = _FRIENDLY_TO_CANONICAL.get(base, [base]) + if not modifiers: + return list(canonicals) + + user_mods = set(modifiers) + expanded: list[str] = [] + seen: set[str] = set() + for canonical in canonicals: + canon_parts = canonical.split("+") + canon_mods = set(canon_parts[:-1]) + canon_base = canon_parts[-1] + # Union the user's modifiers with whatever the canonical already + # has — keeps each modifier exactly once. + merged_mods = sorted(user_mods | canon_mods) + combined = "+".join(merged_mods + [canon_base]) if merged_mods else canon_base + if combined not in seen: + seen.add(combined) + expanded.append(combined) + return expanded + + +class FileBasedKeymapProvider(KeymapProvider): + """Keymap provider built by merging user overrides onto the defaults.""" + + def __init__( + self, + name: str, + leader_commands: list[LeaderCommandDef], + action_keys: list[ActionKeyDef], + ): + self._name = name + self._leader_commands = leader_commands + self._action_keys = action_keys + + @property + def name(self) -> str: + return self._name + + def get_leader_commands(self) -> list[LeaderCommandDef]: + return list(self._leader_commands) + + def get_action_keys(self) -> list[ActionKeyDef]: + return list(self._action_keys) + + +class KeymapManager: + """Loads and applies a custom keymap during app startup.""" + + def __init__(self, settings_store: SettingsStoreProtocol) -> None: + self._settings_store = settings_store + # Last load error surfaced by startup_flow once the app is mounted, + # so the user sees it in the UI instead of only on stderr. None when + # the most recent load succeeded or no custom keymap was requested. + self.load_error: str | None = None + + def initialize(self) -> dict: + settings = self._settings_store.load_all() + self.load_custom_keymap(settings) + return settings + + def load_custom_keymap(self, settings: dict) -> None: + self.load_error = None + keymap_name = settings.get(CUSTOM_KEYMAP_SETTINGS_KEY) + if isinstance(keymap_name, str) and keymap_name.strip() not in ("", "default"): + # Explicit named keymap from settings — power-user path. + try: + path = self._resolve_keymap_path(keymap_name.strip()) + self._register_custom_keymap(path, keymap_name.strip()) + except Exception as exc: + self.load_error = f"Failed to load custom keymap '{keymap_name}': {exc}" + return + + # No setting → load the default file, creating an empty scaffold if + # it doesn't exist so the file is discoverable in the user's config + # dir even before they've customized anything. + self._ensure_default_keymap_scaffold() + if DEFAULT_KEYMAP_FILE.exists(): + try: + self._register_custom_keymap(DEFAULT_KEYMAP_FILE, DEFAULT_KEYMAP_FILE.name) + except Exception as exc: + self.load_error = f"Failed to load {DEFAULT_KEYMAP_FILE}: {exc}" + + @staticmethod + def _ensure_default_keymap_scaffold() -> None: + """Create an empty keymap.json on first run so users can discover it.""" + if DEFAULT_KEYMAP_FILE.exists(): + return + try: + DEFAULT_KEYMAP_FILE.parent.mkdir(parents=True, exist_ok=True) + DEFAULT_KEYMAP_FILE.write_text( + json.dumps( + {"keymap": {"action_keys": {}, "leader_commands": {}}}, + indent=2, + ) + "\n", + encoding="utf-8", + ) + except OSError: + # Read-only config dir or similar — silently skip; the user + # can still create the file themselves. + pass + + def _resolve_keymap_path(self, keymap_name: str) -> Path: + if keymap_name.startswith(("~", "/")) or Path(keymap_name).is_absolute(): + return Path(keymap_name).expanduser() + + name = Path(keymap_name).stem + return CUSTOM_KEYMAP_DIR / f"{name}.json" + + def _register_custom_keymap(self, path: Path, keymap_name: str) -> None: + path = path.expanduser() + if not path.exists(): + raise ValueError(f"Keymap file not found: {path}") + + keymap = self._load_keymap_from_file(path, keymap_name) + set_keymap(keymap) + + def _load_keymap_from_file(self, path: Path, keymap_name: str) -> FileBasedKeymapProvider: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise ValueError(f"Failed to read keymap JSON: {exc}") from exc + + if not isinstance(payload, dict): + raise ValueError("Keymap file must contain a JSON object.") + + keymap_data = payload.get("keymap", payload) + if not isinstance(keymap_data, dict): + raise ValueError('Keymap file "keymap" must be a JSON object.') + + defaults = DefaultKeymapProvider() + base_action = defaults.get_action_keys() + base_leader = defaults.get_leader_commands() + + user_action_overrides, action_unbinds = self._parse_action_overrides( + keymap_data.get("action_keys", {}), base_action + ) + user_leader_overrides, leader_unbinds = self._parse_leader_overrides( + keymap_data.get("leader_commands", {}), base_leader + ) + + merged_action = self._merge_action_keys( + base_action, user_action_overrides, action_unbinds + ) + merged_leader = self._merge_leader_commands( + base_leader, user_leader_overrides, leader_unbinds + ) + + self._detect_conflicts( + merged_leader, + merged_action, + user_leader_overrides, + user_action_overrides, + base_leader, + base_action, + ) + + return FileBasedKeymapProvider(keymap_name, merged_leader, merged_action) + + # ------------------------------------------------------------------ parsing + + @staticmethod + def _normalize_key_list(value: Any, where: str) -> list[str] | None: + """Return the user's key list, or None to mean "unbind this action". + + Accepts `null`, `""`, and `[]` as unbind sentinels. + """ + if value is None: + return None + if isinstance(value, str): + if not value: + return None + return [value] + if isinstance(value, list): + if not value: + return None + for k in value: + if not isinstance(k, str) or not k: + raise ValueError(f"{where}: every entry must be a non-empty string.") + return list(value) + raise ValueError(f"{where}: expected a string, list of strings, or null to unbind.") + + @staticmethod + def _parse_action_overrides( + data: Any, base: list[ActionKeyDef] + ) -> tuple[list[ActionKeyDef], set[tuple[str, str | None]]]: + if not isinstance(data, dict): + raise ValueError('"action_keys" must be a JSON object keyed by state name.') + + # Catalog of defaults grouped by (action, context) — the primary entry + # carries the canonical guard/show/priority that we inherit for the + # user's rebound keys. + defaults_by_pair: dict[tuple[str, str | None], ActionKeyDef] = {} + for ak in base: + existing = defaults_by_pair.get((ak.action, ak.context)) + if existing is None or (ak.primary and not existing.primary): + defaults_by_pair[(ak.action, ak.context)] = ak + + actions_in_state: dict[str | None, set[str]] = defaultdict(set) + for ak in base: + actions_in_state[ak.context].add(ak.action) + + out: list[ActionKeyDef] = [] + unbinds: set[tuple[str, str | None]] = set() + for state, mapping in data.items(): + if not isinstance(state, str) or not state: + raise ValueError('action_keys keys must be non-empty state names.') + if not isinstance(mapping, dict): + raise ValueError(f'action_keys."{state}" must be an object of action → key.') + + for action, keys in mapping.items(): + if not isinstance(action, str) or not action: + raise ValueError(f'action_keys."{state}": action names must be non-empty strings.') + + template = defaults_by_pair.get((action, state)) + if template is None: + suggestions = sorted(actions_in_state.get(state, set())) + hint = ( + f" Known actions in this state: {suggestions}" if suggestions + else f" State {state!r} has no actions in defaults." + ) + raise ValueError( + f"Unknown action {action!r} in state {state!r}.{hint}" + ) + + key_list = KeymapManager._normalize_key_list( + keys, where=f'action_keys."{state}"."{action}"' + ) + if key_list is None: + unbinds.add((action, state)) + continue + # Expand friendly chars (e.g. "?") to their canonical Textual + # variants. The first entry in the original user list is the + # primary; its expansion contributes all primary candidates; + # subsequent user entries are aliases. + first = True + for user_key in key_list: + for canonical in _expand_user_key(user_key): + out.append( + ActionKeyDef( + key=canonical, + action=action, + context=state, + guard=template.guard, + primary=first, + show=template.show, + priority=template.priority, + ) + ) + first = False + return out, unbinds + + @staticmethod + def _parse_leader_overrides( + data: Any, base: list[LeaderCommandDef] + ) -> tuple[list[LeaderCommandDef], set[tuple[str, str]]]: + if not isinstance(data, dict): + raise ValueError('"leader_commands" must be a JSON object keyed by menu name.') + + defaults_by_pair: dict[tuple[str, str], LeaderCommandDef] = { + (cmd.action, cmd.menu): cmd for cmd in base + } + actions_in_menu: dict[str, set[str]] = defaultdict(set) + for cmd in base: + actions_in_menu[cmd.menu].add(cmd.action) + + out: list[LeaderCommandDef] = [] + unbinds: set[tuple[str, str]] = set() + for menu, mapping in data.items(): + if not isinstance(menu, str) or not menu: + raise ValueError('leader_commands keys must be non-empty menu names.') + if not isinstance(mapping, dict): + raise ValueError(f'leader_commands."{menu}" must be an object of action → key.') + + for action, key in mapping.items(): + if not isinstance(action, str) or not action: + raise ValueError(f'leader_commands."{menu}": action names must be non-empty strings.') + + template = defaults_by_pair.get((action, menu)) + if template is None: + suggestions = sorted(actions_in_menu.get(menu, set())) + hint = ( + f" Known actions in this menu: {suggestions}" if suggestions + else f" Menu {menu!r} has no actions in defaults." + ) + raise ValueError( + f"Unknown leader action {action!r} in menu {menu!r}.{hint}" + ) + + # null / "" unbinds the default for this (action, menu). + if key is None or key == "": + unbinds.add((action, menu)) + continue + + if not isinstance(key, str): + raise ValueError( + f'leader_commands."{menu}"."{action}": expected a key string or null.' + ) + + # Expand friendly chars (`":"` → variant list, `"?"` → "question_mark", …). + for canonical in _expand_user_key(key): + out.append( + LeaderCommandDef( + key=canonical, + action=action, + label=template.label, + category=template.category, + guard=template.guard, + menu=menu, + ) + ) + return out, unbinds + + # ------------------------------------------------------------------- merge + + @staticmethod + def _merge_action_keys( + base: list[ActionKeyDef], + user: list[ActionKeyDef], + unbinds: set[tuple[str, str | None]], + ) -> list[ActionKeyDef]: + # User overrides specify the COMPLETE key list for each (action, state) + # they touch — drop every default with that identity, then append. + # Unbinds drop the defaults without adding anything. + overridden = {(u.action, u.context) for u in user} | unbinds + kept = [ak for ak in base if (ak.action, ak.context) not in overridden] + return kept + user + + @staticmethod + def _merge_leader_commands( + base: list[LeaderCommandDef], + user: list[LeaderCommandDef], + unbinds: set[tuple[str, str]], + ) -> list[LeaderCommandDef]: + overridden = {(u.action, u.menu) for u in user} | unbinds + kept = [cmd for cmd in base if (cmd.action, cmd.menu) not in overridden] + return kept + user + + # --------------------------------------------------------------- conflicts + + @staticmethod + def _detect_conflicts( + merged_leader: list[LeaderCommandDef], + merged_action: list[ActionKeyDef], + user_leader: list[LeaderCommandDef], + user_action: list[ActionKeyDef], + base_leader: list[LeaderCommandDef], + base_action: list[ActionKeyDef], + ) -> None: + """Raise ValueError on user-introduced bindings that collide. + + Defaults intentionally bind some keys to multiple actions in the + same state (e.g. ``d`` in ``tree`` for both delete_connection and + delete_connection_folder, disambiguated by tree-node state at + runtime). We never flag those — and if the user's config preserves + that exact overlap (e.g. they copied the full template verbatim), + we don't flag that either. We *do* flag any conflict the user + actually introduced — a new action joining an existing slot. + """ + conflicts: list[str] = [] + + def _by_slot_leader(commands): + out: dict[tuple[str, str], set[str]] = defaultdict(set) + for cmd in commands: + out[(cmd.key, cmd.menu)].add(cmd.action) + return out + + def _by_slot_action(action_keys): + out: dict[tuple[str, str | None], set[str]] = defaultdict(set) + for ak in action_keys: + out[(ak.key, ak.context)].add(ak.action) + return out + + base_leader_slots = _by_slot_leader(base_leader) + merged_leader_slots = _by_slot_leader(merged_leader) + user_leader_slots = {(u.key, u.menu) for u in user_leader} + for slot, actions in sorted(merged_leader_slots.items()): + if len(actions) <= 1 or slot not in user_leader_slots: + continue + # If the actions for this slot match the defaults exactly, the + # user just preserved a pre-existing (state-machine-disambiguated) + # overlap rather than creating a new one. + if actions == base_leader_slots.get(slot): + continue + key, menu = slot + conflicts.append( + f"leader key {key!r} in menu {menu!r} is bound to multiple actions: " + f"{sorted(actions)}" + ) + + base_action_slots = _by_slot_action(base_action) + merged_action_slots = _by_slot_action(merged_action) + user_action_slots = {(u.key, u.context) for u in user_action} + for slot, actions in sorted( + merged_action_slots.items(), key=lambda t: (t[0][0], t[0][1] or "") + ): + if len(actions) <= 1 or slot not in user_action_slots: + continue + if actions == base_action_slots.get(slot): + continue + key, ctx = slot + conflicts.append( + f"key {key!r} in state {ctx!r} is bound to multiple actions: " + f"{sorted(actions)}" + ) + + if conflicts: + lines = "\n - ".join(conflicts) + raise ValueError( + f"Conflicting keybindings detected ({len(conflicts)}):\n - {lines}\n" + f'Pick a different key, or unbind a colliding action by setting ' + f'its key to null (e.g. "undo": null).' + ) diff --git a/sqlit/domains/shell/app/main.py b/sqlit/domains/shell/app/main.py index ced435ab..aaffd636 100644 --- a/sqlit/domains/shell/app/main.py +++ b/sqlit/domains/shell/app/main.py @@ -24,6 +24,7 @@ from sqlit.core.input_context import InputContext from sqlit.core.key_router import resolve_action +from sqlit.domains.shell.app.keymap_manager import KeymapManager from sqlit.core.vim import VimMode from sqlit.domains.connections.domain.config import ConnectionConfig from sqlit.domains.connections.providers.model import DatabaseProvider @@ -190,6 +191,9 @@ def __init__( settings_store=self.services.settings_store, override_theme=self.services.runtime.theme, ) + self._keymap_manager = KeymapManager( + settings_store=self.services.settings_store, + ) self._spinner_index: int = 0 self._spinner_timer: Timer | None = None # Schema indexing state @@ -508,6 +512,13 @@ def _start_command_mode(self) -> None: self._command_buffer = "" self._update_status_bar() + def action_enter_command_mode(self) -> None: + # Entered by the keymap-bound key (default ":"). The query-insert + # guard lives in _handle_command_input so a user typing ":" in a + # SQL string still inserts it; this method is a no-op alias used + # only to satisfy the action validator for the keymap entry. + self._start_command_mode() + def _exit_command_mode(self) -> None: self._command_mode = False self._command_buffer = "" @@ -569,10 +580,12 @@ def _handle_count_digit(self, event: Key, ctx: InputContext) -> bool: return True def _handle_command_input(self, event: Key, ctx: InputContext) -> bool: + from sqlit.core.keymap import get_keymap from sqlit.core.vim import VimMode if not self._command_mode: - is_command_start = event.character == ":" or event.key in {":", "colon", "shift+semicolon"} + command_keys = get_keymap().keys_for_action("enter_command_mode") + is_command_start = event.key in command_keys or event.character in command_keys if not is_command_start: return False if ctx.focus == "query" and self.vim_mode == VimMode.INSERT: diff --git a/sqlit/domains/shell/app/startup_flow.py b/sqlit/domains/shell/app/startup_flow.py index 84c482a7..332ee665 100644 --- a/sqlit/domains/shell/app/startup_flow.py +++ b/sqlit/domains/shell/app/startup_flow.py @@ -35,6 +35,14 @@ def run_on_mount(app: AppProtocol) -> None: settings = app._theme_manager.initialize() app._startup_stamp("settings_loaded") + app._keymap_manager.initialize() + # Feed the (possibly user-customized) keymap into Textual so that any + # Binding with id= picks up the user's key. + from sqlit.core.keymap import build_textual_keymap, get_keymap + + app.set_keymap(build_textual_keymap(get_keymap())) + app._startup_stamp("keymap_loaded") + app._expanded_paths = set(settings.get("expanded_nodes", [])) if settings.get("debug_events_enabled"): setter = getattr(app, "_set_debug_events_enabled", None) @@ -98,6 +106,7 @@ def run_on_mount(app: AppProtocol) -> None: app._startup_stamp("footer_updated") _warn_on_missing_actions(app, is_headless) _warn_on_keyring_error(app, is_headless) + _warn_on_keymap_error(app, is_headless) if ( app.services.runtime.process_worker and app.services.runtime.process_worker_warm_on_idle @@ -156,6 +165,28 @@ def _warn_on_keyring_error(app: AppProtocol, is_headless: bool) -> None: print(f"[sqlit] {message}", file=sys.stderr) +def _warn_on_keymap_error(app: AppProtocol, is_headless: bool) -> None: + manager = getattr(app, "_keymap_manager", None) + error = getattr(manager, "load_error", None) if manager else None + if not error: + return + from sqlit.domains.shell.app.keymap_manager import CUSTOM_KEYMAP_DIR + message = f"{error}\nDefaults are in effect — fix {CUSTOM_KEYMAP_DIR} and restart." + if is_headless: + print(f"[sqlit] {message}", file=sys.stderr) + return + # Defer until the screen is composed — calling notify() during + # run_on_mount refreshes the notification rack, which touches widgets + # (`#results-area DataTable`) that aren't mounted yet. + def _show() -> None: + try: + app.notify(message, severity="error", timeout=15) + except Exception: + print(f"[sqlit] {message}", file=sys.stderr) + + app.call_after_refresh(_show) + + def apply_mock_settings(app: AppProtocol, settings: dict) -> None: app.services.apply_mock_settings(settings) diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index 2e021db2..65d282a0 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -11,12 +11,10 @@ from __future__ import annotations from sqlit.core.input_context import InputContext -from sqlit.core.keymap import format_key from sqlit.core.leader_commands import get_leader_commands from sqlit.core.state_base import ( ActionResult, DisplayBinding, - HelpEntry, State, resolve_display_key, ) @@ -138,11 +136,28 @@ def get_active_state_name(self, app: InputContext) -> str: return state.__class__.__name__ def generate_help_text(self) -> str: - """Generate structured help text with organized sections.""" - from sqlit.core.keymap import format_key + """Generate structured help text with organized sections. + Keys are resolved from the active keymap so custom keybindings show up + here too. Literal fallbacks are kept for sequences that aren't bound to + a single action (command-mode prefixes, composite vim sequences). + """ + from sqlit.core.keymap import format_key, get_keymap + + keymap = get_keymap() leader_key = resolve_display_key("leader_key") or "" + def k(action: str, fallback: str) -> str: + key = keymap.action(action) + return format_key(key) if key else fallback + + def ks(actions_and_fallbacks: list[tuple[str, str]], sep: str = "/") -> str: + return sep.join(k(a, f) for a, f in actions_and_fallbacks) + + def lk(action: str, menu: str, fallback: str) -> str: + key = keymap.leader(action, menu) + return format_key(key) if key else fallback + def section(title: str) -> str: divider = "-" * 62 return f"[bold $primary]{title}[/]\n[dim]{divider}[/]" @@ -161,139 +176,150 @@ def binding(key: str, desc: str, indent: int = 4) -> str: # ═══════════════════════════════════════════════════════════════════ lines.append(section("GLOBAL")) lines.append(binding(":q", "Quit")) - lines.append(binding(f"{leader_key}t", "Change theme")) - lines.append(binding(f"{leader_key}f", "Toggle fullscreen pane")) - lines.append(binding(f"{leader_key}e", "Toggle explorer visibility")) + lines.append(binding(f"{leader_key}{lk('change_theme', 'leader', 't')}", "Change theme")) + lines.append(binding(f"{leader_key}{lk('toggle_fullscreen', 'leader', 'f')}", "Toggle fullscreen pane")) + lines.append(binding(f"{leader_key}{lk('toggle_explorer', 'leader', 'e')}", "Toggle explorer visibility")) lines.append("") # ═══════════════════════════════════════════════════════════════════ # NAVIGATION # ═══════════════════════════════════════════════════════════════════ lines.append(section("NAVIGATION")) - lines.append(binding("e", "Focus Explorer pane")) - lines.append(binding("q", "Focus Query pane")) - lines.append(binding("r", "Focus Results pane")) + lines.append(binding(k("focus_explorer", "e"), "Focus Explorer pane")) + lines.append(binding(k("focus_query", "q"), "Focus Query pane")) + lines.append(binding(k("focus_results", "r"), "Focus Results pane")) lines.append(binding(leader_key, "Open command menu")) - lines.append(binding("?", "Show this help")) + lines.append(binding(k("show_help", "?"), "Show this help")) lines.append("") # ═══════════════════════════════════════════════════════════════════ # EXPLORER # ═══════════════════════════════════════════════════════════════════ lines.append(section("EXPLORER")) - lines.append(binding("j/k", "Move cursor down/up")) + lines.append(binding(ks([("tree_cursor_down", "j"), ("tree_cursor_up", "k")]), "Move cursor down/up")) lines.append(binding("", "Expand node / Connect")) - lines.append(binding("n", "New connection")) - lines.append(binding("s", "SELECT TOP 100 (on table/view)")) - lines.append(binding("/", "Filter tree")) - lines.append(binding("z", "Collapse all nodes")) - lines.append(binding("f", "Refresh tree")) + lines.append(binding(k("new_connection", "n"), "New connection")) + lines.append(binding(k("select_table", "s"), "SELECT TOP 100 (on table/view)")) + lines.append(binding(k("tree_filter", "/"), "Filter tree")) + lines.append(binding(k("collapse_tree", "z"), "Collapse all nodes")) + lines.append(binding(k("refresh_tree", "f"), "Refresh tree")) lines.append("") lines.append(subsection("On Connection Node:")) - lines.append(binding("e", "Edit connection")) - lines.append(binding("d", "Delete connection")) - lines.append(binding("D", "Duplicate connection")) - lines.append(binding("x", "Disconnect")) + lines.append(binding(k("edit_connection", "e"), "Edit connection")) + lines.append(binding(k("delete_connection", "d"), "Delete connection")) + lines.append(binding(k("duplicate_connection", "D"), "Duplicate connection")) + lines.append(binding(k("disconnect", "x"), "Disconnect")) lines.append("") # ═══════════════════════════════════════════════════════════════════ # QUERY EDITOR # ═══════════════════════════════════════════════════════════════════ + g_key = k("g_leader_key", "g") lines.append(section("QUERY EDITOR")) lines.append(subsection("Normal Mode:")) - lines.append(binding("i/I", "Enter INSERT mode")) - lines.append(binding("o/O", "Open line below/above")) - lines.append(binding("C", "Change to line end")) - lines.append(binding("D", "Delete to line end")) - lines.append(binding("/gr", "Execute query")) - lines.append(binding("gt", "Execute as transaction")) - lines.append(binding("", "Query history")) - lines.append(binding("N", "New query (clear)")) - lines.append(binding("u", "Undo")) - lines.append(binding("^r", "Redo")) + lines.append(binding(ks([("enter_insert_mode", "i"), ("prepend_insert_mode", "I")]), "Enter INSERT mode")) + lines.append(binding(ks([("open_line_below", "o"), ("open_line_above", "O")]), "Open line below/above")) + lines.append(binding(k("change_line_end_motion", "C"), "Change to line end")) + lines.append(binding(k("delete_line_end", "D"), "Delete to line end")) + lines.append(binding(f"{k('execute_query', '')}/{g_key}{lk('execute_query', 'g', 'r')}", "Execute query")) + lines.append(binding(f"{g_key}{lk('execute_query_atomic', 'g', 't')}", "Execute as transaction")) + lines.append(binding(k("show_history", ""), "Query history")) + lines.append(binding(k("new_query", "N"), "New query (clear)")) + lines.append(binding(k("undo", "u"), "Undo")) + lines.append(binding(k("redo", "^r"), "Redo")) lines.append("") lines.append(subsection("Insert Mode:")) - lines.append(binding("", "Exit to NORMAL mode")) - lines.append(binding("^", "Execute (stay in INSERT)")) - lines.append(binding("", "Accept autocomplete")) - lines.append(binding("^a", "Select all")) - lines.append(binding("^c", "Copy selection")) - lines.append(binding("^v", "Paste")) + lines.append(binding(k("exit_insert_mode", ""), "Exit to NORMAL mode")) + lines.append(binding(k("execute_query_insert", "^enter"), "Execute (stay in INSERT)")) + lines.append(binding(k("autocomplete_accept", ""), "Accept autocomplete")) + lines.append(binding(k("select_all", "^a"), "Select all")) + lines.append(binding(k("copy_selection", "^c"), "Copy selection")) + lines.append(binding(k("paste", "^v"), "Paste")) lines.append("") - lines.append(subsection("Visual Mode (v):")) - lines.append(binding("/v", "Exit visual mode")) - lines.append(binding("V", "Switch to visual line mode")) + lines.append(subsection(f"Visual Mode ({k('enter_visual_mode', 'v')}):")) + lines.append(binding(f"{k('exit_visual_mode', '')}/{k('enter_visual_mode', 'v')}", "Exit visual mode")) + lines.append(binding(k("switch_to_visual_line_mode", "V"), "Switch to visual line mode")) lines.append(binding("h/j/k/l", "Extend selection")) lines.append(binding("w/b/e/$", "Extend by word/line motions")) - lines.append(binding("y", "Yank selection")) - lines.append(binding("d", "Delete selection")) - lines.append(binding("c", "Change selection")) - lines.append(binding("", "Execute selection")) + lines.append(binding(k("visual_yank", "y"), "Yank selection")) + lines.append(binding(k("visual_delete", "d"), "Delete selection")) + lines.append(binding(k("visual_change", "c"), "Change selection")) + lines.append(binding(k("visual_execute", ""), "Execute selection")) lines.append("") - lines.append(subsection("Visual Line Mode (V):")) - lines.append(binding("/V", "Exit visual line mode")) - lines.append(binding("v", "Switch to visual mode")) + lines.append(subsection(f"Visual Line Mode ({k('enter_visual_line_mode', 'V')}):")) + lines.append(binding(f"{k('exit_visual_line_mode', '')}/{k('enter_visual_line_mode', 'V')}", "Exit visual line mode")) + lines.append(binding(k("switch_to_visual_mode", "v"), "Switch to visual mode")) lines.append(binding("j/k", "Extend selection down/up")) lines.append(binding("gg/G", "Extend to first/last line")) - lines.append(binding("y", "Yank selected lines")) - lines.append(binding("d", "Delete selected lines")) - lines.append(binding("c", "Change selected lines")) - lines.append(binding("", "Execute selected lines")) + lines.append(binding(k("visual_line_yank", "y"), "Yank selected lines")) + lines.append(binding(k("visual_line_delete", "d"), "Delete selected lines")) + lines.append(binding(k("visual_line_change", "c"), "Change selected lines")) + lines.append(binding(k("visual_line_execute", ""), "Execute selected lines")) lines.append("") + # Operator + motion sequences: resolve the operator key, keep "{motion}" literal. + yank_op = k("yank_leader_key", "y") + del_op = k("delete_leader_key", "d") + chg_op = k("change_leader_key", "c") lines.append(subsection("Vim Operators (Normal Mode):")) - lines.append(binding("y{motion}", "Copy")) - lines.append(binding("d{motion}", "Delete")) - lines.append(binding("c{motion}", "Change (delete + INSERT)")) - lines.append(binding("p", "Paste after cursor")) + lines.append(binding(f"{yank_op}{{motion}}", "Copy")) + lines.append(binding(f"{del_op}{{motion}}", "Delete")) + lines.append(binding(f"{chg_op}{{motion}}", "Change (delete + INSERT)")) + lines.append(binding(k("paste", "p"), "Paste after cursor")) lines.append("") lines.append(subsection("Vim Motions:")) - lines.append(binding("h/j/k/l", "Cursor left/down/up/right")) - lines.append(binding("w/W", "Word forward")) - lines.append(binding("b/B", "Word backward")) - lines.append(binding("0/^/$", "Line start/first char/end")) - lines.append(binding("gg/G", "File start/end")) - lines.append(binding("f{c}/F{c}", "Find char forward/back")) - lines.append(binding("t{c}/T{c}", "Till char forward/back")) - lines.append(binding("%", "Matching bracket")) + lines.append(binding(ks([("cursor_left", "h"), ("cursor_down", "j"), ("cursor_up", "k"), ("cursor_right", "l")]), "Cursor left/down/up/right")) + lines.append(binding(ks([("cursor_word_forward", "w"), ("cursor_WORD_forward", "W")]), "Word forward")) + lines.append(binding(ks([("cursor_word_back", "b"), ("cursor_WORD_back", "B")]), "Word backward")) + # `^` (first non-blank) is not a separate keymap action — kept as a literal. + lines.append(binding(f"{k('cursor_line_start', '0')}/^/{k('cursor_line_end', '$')}", "Line start/first char/end")) + lines.append(binding(f"{g_key}{lk('first_line', 'g', 'g')}/{k('cursor_last_line', 'G')}", "File start/end")) + lines.append(binding(f"{k('cursor_find_char', 'f')}{{c}}/{k('cursor_find_char_back', 'F')}{{c}}", "Find char forward/back")) + lines.append(binding(f"{k('cursor_till_char', 't')}{{c}}/{k('cursor_till_char_back', 'T')}{{c}}", "Till char forward/back")) + lines.append(binding(k("cursor_matching_bracket", "%"), "Matching bracket")) lines.append("") - lines.append(subsection("Text Objects (with i=inner, a=around):")) - lines.append(binding("iw/aw", "Word")) - lines.append(binding('i"/a"', "Double quotes")) - lines.append(binding("i'/a'", "Single quotes")) - lines.append(binding("i)/a)", "Parentheses")) - lines.append(binding("i}/a}", "Braces")) - lines.append(binding("i]/a]", "Brackets")) + # Text objects: inner/around are leader_commands inside operator menus (yank/delete/change). + # Resolve from the yank menu — by convention these stay aligned across menus. + inner = lk("inner", "yank", "i") + around = lk("around", "yank", "a") + lines.append(subsection(f"Text Objects (with {inner}=inner, {around}=around):")) + lines.append(binding(f"{inner}w/{around}w", "Word")) + lines.append(binding(f'{inner}"/{around}"', "Double quotes")) + lines.append(binding(f"{inner}'/{around}'", "Single quotes")) + lines.append(binding(f"{inner})/{around})", "Parentheses")) + lines.append(binding(f"{inner}}}/{around}}}", "Braces")) + lines.append(binding(f"{inner}]/{around}]", "Brackets")) lines.append("") # ═══════════════════════════════════════════════════════════════════ # RESULTS # ═══════════════════════════════════════════════════════════════════ lines.append(section("RESULTS")) - lines.append(binding("h/j/k/l", "Navigate cells")) - lines.append(binding("v", "Preview cell (inline)")) - lines.append(binding("V", "View full cell value")) - lines.append(binding("u", "Generate UPDATE statement")) - lines.append(binding("d", "Generate DELETE statement")) - lines.append(binding("/", "Filter rows")) - lines.append(binding("x", "Clear results")) - lines.append(binding("", "Next result set")) - lines.append(binding("", "Previous result set")) - lines.append(binding("z", "Collapse/expand result")) + lines.append(binding(ks([("results_cursor_left", "h"), ("results_cursor_down", "j"), ("results_cursor_up", "k"), ("results_cursor_right", "l")]), "Navigate cells")) + lines.append(binding(k("view_cell", "v"), "Preview cell (inline)")) + lines.append(binding(k("view_cell_full", "V"), "View full cell value")) + lines.append(binding(k("edit_cell", "u"), "Generate UPDATE statement")) + lines.append(binding(k("delete_row", "d"), "Generate DELETE statement")) + lines.append(binding(k("results_filter", "/"), "Filter rows")) + lines.append(binding(k("clear_results", "x"), "Clear results")) + lines.append(binding(k("next_result_section", ""), "Next result set")) + lines.append(binding(k("prev_result_section", ""), "Previous result set")) + lines.append(binding(k("toggle_result_section", "z"), "Collapse/expand result")) lines.append("") - lines.append(subsection("Copy Menu (y):")) - lines.append(binding("yc", "Copy cell")) - lines.append(binding("yy", "Copy row")) - lines.append(binding("ya", "Copy all")) - lines.append(binding("ye", "Export menu...")) + results_yank = k("results_yank_leader_key", "y") + lines.append(subsection(f"Copy Menu ({results_yank}):")) + lines.append(binding(f"{results_yank}{lk('cell', 'ry', 'c')}", "Copy cell")) + lines.append(binding(f"{results_yank}{lk('row', 'ry', 'y')}", "Copy row")) + lines.append(binding(f"{results_yank}{lk('all', 'ry', 'a')}", "Copy all")) + lines.append(binding(f"{results_yank}{lk('export', 'ry', 'e')}", "Export menu...")) lines.append("") # ═══════════════════════════════════════════════════════════════════ # FILTERING # ═══════════════════════════════════════════════════════════════════ lines.append(section("FILTERING")) - lines.append(binding("/", "Open filter (Explorer/Results)")) - lines.append(binding("", "Apply filter")) - lines.append(binding("", "Close filter")) + lines.append(binding(k("results_filter", "/"), "Open filter (Explorer/Results)")) + lines.append(binding(k("results_filter_accept", ""), "Apply filter")) + lines.append(binding(k("results_filter_close", ""), "Close filter")) lines.append(binding("~prefix", "Fuzzy match mode")) lines.append("") @@ -322,10 +348,10 @@ def binding(key: str, desc: str, indent: int = 4) -> str: lines.append(binding("/", "Search connections")) lines.append(binding("j/k", "Navigate list")) lines.append(binding("", "Connect to selected")) - lines.append(binding("n", "New connection")) - lines.append(binding("e", "Edit connection")) - lines.append(binding("d", "Delete connection")) - lines.append(binding("D", "Duplicate connection")) + lines.append(binding(k("new_connection", "n"), "New connection")) + lines.append(binding(k("edit_connection", "e"), "Edit connection")) + lines.append(binding(k("delete_connection", "d"), "Delete connection")) + lines.append(binding(k("duplicate_connection", "D"), "Duplicate connection")) lines.append(binding("", "Close picker")) lines.append("") diff --git a/sqlit/domains/shell/ui/screens/help.py b/sqlit/domains/shell/ui/screens/help.py index ad579c14..d6f1fb32 100644 --- a/sqlit/domains/shell/ui/screens/help.py +++ b/sqlit/domains/shell/ui/screens/help.py @@ -15,9 +15,7 @@ class HelpScreen(ModalScreen): """Modal screen showing keyboard shortcuts and navigation tips.""" BINDINGS = [ - Binding("escape", "dismiss", "Close"), - Binding("enter", "dismiss", "Close"), - Binding("q", "dismiss", "Close"), + Binding("escape,enter,q", "dismiss", "Close"), Binding("j", "scroll_down", "Scroll down", show=False), Binding("k", "scroll_up", "Scroll up", show=False), Binding("g", "scroll_home", "Scroll to top", show=False), diff --git a/sqlit/shared/ui/screens/error.py b/sqlit/shared/ui/screens/error.py index f29456ce..d788e5ad 100644 --- a/sqlit/shared/ui/screens/error.py +++ b/sqlit/shared/ui/screens/error.py @@ -14,8 +14,7 @@ class ErrorScreen(ModalScreen): """Modal screen for displaying error messages.""" BINDINGS = [ - Binding("enter", "close", "Close"), - Binding("escape", "close", "Close"), + Binding("enter,escape", "close", "Close"), Binding("y", "copy_message", "Copy"), ] diff --git a/sqlit/shared/ui/screens/file_picker.py b/sqlit/shared/ui/screens/file_picker.py index 2616b407..4302c6c5 100644 --- a/sqlit/shared/ui/screens/file_picker.py +++ b/sqlit/shared/ui/screens/file_picker.py @@ -30,8 +30,7 @@ class FilePickerScreen(ModalScreen[str | None]): BINDINGS = [ Binding("escape", "cancel", "Cancel", priority=True), - Binding("tab", "toggle_focus", "Switch focus", show=False), - Binding("shift+tab", "toggle_focus", "Switch focus", show=False), + Binding("tab,shift+tab", "toggle_focus", "Switch focus", show=False), Binding("s", "save", "Save", show=False), ] diff --git a/sqlit/shared/ui/widgets_text_area.py b/sqlit/shared/ui/widgets_text_area.py index 3181a000..16ce7cbe 100644 --- a/sqlit/shared/ui/widgets_text_area.py +++ b/sqlit/shared/ui/widgets_text_area.py @@ -131,33 +131,45 @@ async def _on_key(self, event: Key) -> None: """Intercept clipboard, undo/redo, and Enter keys.""" normalized_key = self._normalize_key(event.key) + from sqlit.core.keymap import get_keymap + keymap = get_keymap() + select_all_keys = keymap.keys_for_action("select_all") + copy_keys = keymap.keys_for_action("copy_selection") + # `paste` is bound in both query_normal (vim "p") and query_insert + # (ctrl+v). Restrict to the modifier-style keys for this handler so + # plain "p" in normal mode still reaches the vim operator. + paste_keys = [ + k for k in keymap.keys_for_action("paste") + if "+" in k or len(k) > 1 + ] + undo_keys = keymap.keys_for_action("undo") + redo_keys = keymap.keys_for_action("redo") + + clipboard_keys = set(select_all_keys + copy_keys + paste_keys) + # Clipboard shortcuts only work in INSERT mode (vim consistency) - if normalized_key in ("ctrl+a", "ctrl+c", "ctrl+v"): + if normalized_key in clipboard_keys: if not self._is_insert_mode(): # Block these in normal mode - use vim commands instead event.prevent_default() event.stop() return - # Handle CTRL+A (select all) - override Emacs beginning-of-line - if normalized_key == "ctrl+a": + if normalized_key in select_all_keys: if hasattr(self.app, "action_select_all"): self.app.action_select_all() event.prevent_default() event.stop() return - # Handle CTRL+C (copy) - override default behavior - if normalized_key == "ctrl+c": + if normalized_key in copy_keys: if hasattr(self.app, "action_copy_selection"): self.app.action_copy_selection() event.prevent_default() event.stop() return - # Handle CTRL+V (paste) - override default behavior - if normalized_key == "ctrl+v": - # Push undo state before paste + if normalized_key in paste_keys: self._push_undo_if_changed() if hasattr(self.app, "action_paste"): self.app.action_paste() @@ -166,16 +178,14 @@ async def _on_key(self, event: Key) -> None: return # Undo/redo work in both modes - # Handle CTRL+Z (undo) - if normalized_key == "ctrl+z": + if normalized_key in undo_keys and "+" in normalized_key: if hasattr(self.app, "action_undo"): self.app.action_undo() event.prevent_default() event.stop() return - # Handle CTRL+Y (redo) - if normalized_key == "ctrl+y": + if normalized_key in redo_keys and "+" in normalized_key: if hasattr(self.app, "action_redo"): self.app.action_redo() event.prevent_default() diff --git a/tests/ui/keybindings/test_keymap_manager.py b/tests/ui/keybindings/test_keymap_manager.py new file mode 100644 index 00000000..8f68e391 --- /dev/null +++ b/tests/ui/keybindings/test_keymap_manager.py @@ -0,0 +1,551 @@ +"""Tests for the state-nested KeymapManager.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from sqlit.core.keymap import get_keymap, reset_keymap +from sqlit.domains.shell.app.keymap_manager import FileBasedKeymapProvider, KeymapManager + + +class MockSettingsStore: + def __init__(self, settings: dict | None = None): + self.settings = settings or {} + + def load_all(self) -> dict: + return self.settings + + def save_all(self, settings: dict) -> None: + self.settings = settings + + def get(self, key: str, default=None): + return self.settings.get(key, default) + + +def _write(path: Path, payload: dict) -> Path: + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def _load(tmp_path: Path, name: str, payload: dict) -> KeymapManager: + file_path = _write(tmp_path / f"{name}.json", payload) + manager = KeymapManager(settings_store=MockSettingsStore({"custom_keymap": str(file_path)})) + manager.initialize() + return manager + + +@pytest.fixture(autouse=True) +def reset_keymap_after_test(): + yield + reset_keymap() + + +@pytest.fixture(autouse=True) +def isolate_default_keymap_file(tmp_path_factory, monkeypatch): + """Redirect DEFAULT_KEYMAP_FILE to a per-test tmp dir. + + The loader auto-creates a scaffold keymap.json when one isn't present; + we don't want that writing into the developer's real ~/.config/sqlit. + """ + from sqlit.domains.shell.app import keymap_manager as km_mod + tmp_dir = tmp_path_factory.mktemp("keymap-isolation") + monkeypatch.setattr(km_mod, "DEFAULT_KEYMAP_FILE", tmp_dir / "keymap.json") + + +class TestLifecycle: + def test_no_custom_keymap_leaves_defaults_intact(self): + # The loader auto-creates an empty scaffold, but defaults are unchanged. + manager = KeymapManager(settings_store=MockSettingsStore({})) + manager.initialize() + assert manager.load_error is None + assert get_keymap().action("enter_insert_mode") == "i" + + def test_default_sentinel_uses_scaffold_only(self): + manager = KeymapManager(settings_store=MockSettingsStore({"custom_keymap": "default"})) + manager.initialize() + assert manager.load_error is None + assert get_keymap().action("enter_insert_mode") == "i" + + def test_scaffold_created_on_first_run(self, tmp_path_factory): + # The autouse fixture redirected DEFAULT_KEYMAP_FILE to a fresh tmp dir. + from sqlit.domains.shell.app import keymap_manager as km_mod + assert not km_mod.DEFAULT_KEYMAP_FILE.exists() + KeymapManager(settings_store=MockSettingsStore({})).initialize() + assert km_mod.DEFAULT_KEYMAP_FILE.exists() + body = json.loads(km_mod.DEFAULT_KEYMAP_FILE.read_text()) + assert body == {"keymap": {"action_keys": {}, "leader_commands": {}}} + + def test_invalid_json_falls_back(self, tmp_path: Path): + path = tmp_path / "invalid.json" + path.write_text("not valid json", encoding="utf-8") + manager = KeymapManager(settings_store=MockSettingsStore({"custom_keymap": str(path)})) + manager.initialize() + assert manager.load_error and "Failed to load custom keymap" in manager.load_error + assert not isinstance(get_keymap(), FileBasedKeymapProvider) + + def test_missing_file_falls_back(self, tmp_path: Path): + manager = KeymapManager( + settings_store=MockSettingsStore({"custom_keymap": str(tmp_path / "nope.json")}) + ) + manager.initialize() + assert manager.load_error and "not found" in manager.load_error + + def test_load_error_cleared_on_clean_reinit(self, tmp_path: Path): + path = tmp_path / "bad.json" + path.write_text("not valid json", encoding="utf-8") + manager = KeymapManager(settings_store=MockSettingsStore({"custom_keymap": str(path)})) + manager.initialize() + assert manager.load_error is not None + # Re-running with the default sentinel must clear the prior error. + manager._settings_store = MockSettingsStore({"custom_keymap": "default"}) # type: ignore[attr-defined] + manager.initialize() + assert manager.load_error is None + + +class TestSimpleRemap: + """Remapping a single key for an existing (state, action) pair.""" + + def test_single_key_replaces_default(self, tmp_path: Path): + _load( + tmp_path, + "remap", + {"keymap": {"action_keys": {"query_normal": {"execute_query": "ctrl+enter"}}}}, + ) + keymap = get_keymap() + assert keymap.action("execute_query") == "ctrl+enter" + # Old "enter" no longer triggers execute_query in query_normal. + assert not any( + ak.key == "enter" and ak.action == "execute_query" and ak.context == "query_normal" + for ak in keymap.get_action_keys() + ) + + def test_leader_remap(self, tmp_path: Path): + _load( + tmp_path, + "leader", + {"keymap": {"leader_commands": {"leader": {"show_help": "question_mark"}}}}, + ) + assert get_keymap().leader("show_help", "leader") == "question_mark" + + def test_unrelated_defaults_remain(self, tmp_path: Path): + _load( + tmp_path, + "small", + {"keymap": {"leader_commands": {"leader": {"quit": "Z"}}}}, + ) + keymap = get_keymap() + # toggle_explorer wasn't touched — still on its default 'e'. + assert keymap.leader("toggle_explorer", "leader") == "e" + + +class TestAliases: + """List values let the user keep aliases explicitly.""" + + def test_list_creates_primary_plus_aliases(self, tmp_path: Path): + _load( + tmp_path, + "aliases", + {"keymap": {"action_keys": {"tree": {"refresh_tree": ["F", "ctrl+r"]}}}}, + ) + keys = get_keymap().keys_for_action("refresh_tree") + assert keys[0] == "F", "first entry is primary" + assert "ctrl+r" in keys, "alias is preserved" + # Default 'f' and 'R' both get replaced — user list is authoritative. + assert "f" not in keys + assert "R" not in keys + + def test_single_string_replaces_all_default_aliases(self, tmp_path: Path): + # Default tree_cursor_down has 'j' (primary) and 'down' (alias). + # Use ctrl+j to avoid colliding with other default tree bindings. + _load( + tmp_path, + "single", + {"keymap": {"action_keys": {"tree": {"tree_cursor_down": "ctrl+j"}}}}, + ) + keys = get_keymap().keys_for_action("tree_cursor_down") + assert keys == ["ctrl+j"], "single-string override replaces the entire key set" + + +class TestStrictness: + """Unknown (state, action) pairs are rejected with a helpful error.""" + + def test_unknown_action_in_state(self, tmp_path: Path): + manager = _load( + tmp_path, + "bad", + {"keymap": {"action_keys": {"tree": {"this_action_does_not_exist": "x"}}}}, + ) + assert manager.load_error is not None + assert "Unknown action 'this_action_does_not_exist' in state 'tree'" in manager.load_error + assert "Known actions" in manager.load_error + + def test_unknown_state(self, tmp_path: Path): + manager = _load( + tmp_path, + "bad-state", + {"keymap": {"action_keys": {"made_up_state": {"some_action": "x"}}}}, + ) + assert manager.load_error is not None + assert "Unknown action" in manager.load_error + assert "made_up_state" in manager.load_error + + def test_unknown_leader_action(self, tmp_path: Path): + manager = _load( + tmp_path, + "bad-leader", + {"keymap": {"leader_commands": {"leader": {"not_a_leader_action": "x"}}}}, + ) + assert manager.load_error is not None + assert "Unknown leader action" in manager.load_error + + def test_empty_string_in_list_is_rejected(self, tmp_path: Path): + manager = _load( + tmp_path, + "empty-in-list", + {"keymap": {"action_keys": {"tree": {"refresh_tree": ["f", ""]}}}}, + ) + assert manager.load_error is not None + assert "every entry must be a non-empty string" in manager.load_error + + +class TestDefaultKeymapFile: + """Drop ~/.config/sqlit/keymap.json with no settings edit → it loads.""" + + def test_default_file_picked_up_automatically(self, tmp_path: Path, monkeypatch): + # Point CONFIG_DIR-derived constants at a fresh dir. + from sqlit.domains.shell.app import keymap_manager as km_mod + default_path = tmp_path / "keymap.json" + default_path.write_text(json.dumps( + {"keymap": {"action_keys": {"query_normal": {"execute_query": "ctrl+enter"}}}} + )) + monkeypatch.setattr(km_mod, "DEFAULT_KEYMAP_FILE", default_path) + + manager = KeymapManager(settings_store=MockSettingsStore({})) + manager.initialize() + assert manager.load_error is None + assert get_keymap().action("execute_query") == "ctrl+enter" + + def test_settings_override_takes_precedence(self, tmp_path: Path, monkeypatch): + # If both default file and `custom_keymap` setting exist, the setting wins. + from sqlit.domains.shell.app import keymap_manager as km_mod + default_path = tmp_path / "keymap.json" + default_path.write_text(json.dumps( + {"keymap": {"action_keys": {"query_normal": {"execute_query": "ctrl+enter"}}}} + )) + monkeypatch.setattr(km_mod, "DEFAULT_KEYMAP_FILE", default_path) + + named = tmp_path / "named.json" + named.write_text(json.dumps( + {"keymap": {"action_keys": {"query_normal": {"execute_query": "ctrl+shift+enter"}}}} + )) + manager = KeymapManager(settings_store=MockSettingsStore({"custom_keymap": str(named)})) + manager.initialize() + assert get_keymap().action("execute_query") == "ctrl+shift+enter" + + def test_no_setting_creates_scaffold_and_loads_it(self, tmp_path: Path, monkeypatch): + from sqlit.domains.shell.app import keymap_manager as km_mod + target = tmp_path / "fresh-config" / "keymap.json" + monkeypatch.setattr(km_mod, "DEFAULT_KEYMAP_FILE", target) + assert not target.exists() + manager = KeymapManager(settings_store=MockSettingsStore({})) + manager.initialize() + assert manager.load_error is None + assert target.exists(), "scaffold must be written on first run" + # Defaults are still in effect since the scaffold is empty. + assert get_keymap().action("enter_insert_mode") == "i" + + +class TestFriendlyKeyNames: + """User can write `:` instead of `colon`, `?` instead of `question_mark`, etc.""" + + def test_question_mark_normalizes(self, tmp_path: Path): + _load( + tmp_path, + "qmark", + {"keymap": {"leader_commands": {"leader": {"show_help": "?"}}}}, + ) + # Internally stored as canonical Textual name. + assert get_keymap().leader("show_help", "leader") == "question_mark" + + def test_colon_expands_to_all_terminal_variants(self, tmp_path: Path): + # `:` is emitted as `colon`, `shift+semicolon`, or `:` depending on + # the terminal/platform — `_expand_user_key` covers all three so the + # binding works everywhere. + _load( + tmp_path, + "colon", + {"keymap": {"action_keys": {"global": {"enter_command_mode": ":"}}}}, + ) + keys = get_keymap().keys_for_action("enter_command_mode") + assert "colon" in keys + assert "shift+semicolon" in keys + assert ":" in keys + + def test_friendly_char_with_modifier(self, tmp_path: Path): + _load( + tmp_path, + "ctrl-slash", + {"keymap": {"action_keys": {"tree": {"tree_filter": "ctrl+/"}}}}, + ) + # ctrl+/ canonicalizes to ctrl+slash. + keys = get_keymap().keys_for_action("tree_filter") + assert "ctrl+slash" in keys + assert "/" not in keys + assert "slash" not in keys + + def test_canonical_names_still_work(self, tmp_path: Path): + # User can still write the canonical Textual name if they want to. + _load( + tmp_path, + "canonical", + {"keymap": {"leader_commands": {"leader": {"show_help": "question_mark"}}}}, + ) + assert get_keymap().leader("show_help", "leader") == "question_mark" + + def test_literal_plus_via_double_plus_preserved(self): + """`ctrl++` means ctrl + plus key; must round-trip unchanged.""" + from sqlit.domains.shell.app.keymap_manager import _expand_user_key + assert _expand_user_key("ctrl++") == ["ctrl++"] + assert _expand_user_key("++") == ["++"] + + def test_shift_modifier_does_not_double_when_canonical_already_has_shift( + self, tmp_path: Path + ): + """`shift+:` must not expand to `shift+shift+semicolon`. + + The friendly char `:` expands to three canonical variants, one of + which (`shift+semicolon`) already carries a shift prefix. Naively + concatenating the user's `shift+` prefix produces `shift+shift+...`, + which Textual won't match — the binding silently dies. + """ + _load( + tmp_path, + "shift-friendly", + { + "keymap": { + "action_keys": { + "query_normal": {"new_query": "shift+:"} + } + } + }, + ) + keys = get_keymap().keys_for_action("new_query") + # No canonical key should ever start with "shift+shift+". + bad = [k for k in keys if "shift+shift+" in k] + assert not bad, ( + f"shift+ expansion produced malformed canonical(s): {bad}; " + f"all keys: {keys}" + ) + + +class TestUnbind: + """null / "" / [] removes a default binding without adding a replacement.""" + + def test_null_unbinds_default(self, tmp_path: Path): + # Default: 'u' → undo in query_normal. User sets it to null. + _load( + tmp_path, + "unbind-null", + {"keymap": {"action_keys": {"query_normal": {"undo": None}}}}, + ) + keymap = get_keymap() + # No key in query_normal triggers undo anymore. + assert not any( + ak.action == "undo" and ak.context == "query_normal" + for ak in keymap.get_action_keys() + ) + + def test_unbind_resolves_user_vs_default_conflict(self, tmp_path: Path): + # User wants 'u' for enter_insert_mode. Without unbinding undo this + # collides with the default 'u' → undo. With it, the user's binding + # works cleanly. + manager = _load( + tmp_path, + "unbind-resolves", + { + "keymap": { + "action_keys": { + "query_normal": { + "enter_insert_mode": "u", + "undo": None, + } + } + } + }, + ) + assert manager.load_error is None + keymap = get_keymap() + assert keymap.action("enter_insert_mode") == "u" + assert not any(ak.action == "undo" and ak.context == "query_normal" + for ak in keymap.get_action_keys()) + + def test_empty_string_also_unbinds(self, tmp_path: Path): + _load( + tmp_path, + "unbind-empty", + {"keymap": {"action_keys": {"query_normal": {"undo": ""}}}}, + ) + assert not any( + ak.action == "undo" and ak.context == "query_normal" + for ak in get_keymap().get_action_keys() + ) + + def test_leader_null_unbinds(self, tmp_path: Path): + _load( + tmp_path, + "unbind-leader", + {"keymap": {"leader_commands": {"leader": {"toggle_explorer": None}}}}, + ) + assert not any( + c.action == "toggle_explorer" and c.menu == "leader" + for c in get_keymap().get_leader_commands() + ) + + +class TestConflicts: + """User-introduced collisions abort load with a clear error.""" + + def test_user_vs_default_action_key(self, tmp_path: Path): + # Default: 'i' → enter_insert_mode in query_normal. User binds 'i' to undo. + manager = _load( + tmp_path, + "conflict", + {"keymap": {"action_keys": {"query_normal": {"undo": "i"}}}}, + ) + assert manager.load_error is not None + assert "Conflicting keybindings detected" in manager.load_error + assert "'i'" in manager.load_error and "query_normal" in manager.load_error + + def test_user_vs_user_action_key(self, tmp_path: Path): + manager = _load( + tmp_path, + "self-conflict", + { + "keymap": { + "action_keys": { + "query_normal": { + "undo": "ctrl+x", + "redo": "ctrl+x", + } + } + } + }, + ) + assert manager.load_error is not None + assert "Conflicting keybindings detected" in manager.load_error + + def test_user_vs_default_leader(self, tmp_path: Path): + # Default: e → toggle_explorer. User binds e → quit. + manager = _load( + tmp_path, + "leader-conflict", + {"keymap": {"leader_commands": {"leader": {"quit": "e"}}}}, + ) + assert manager.load_error is not None + assert "leader key 'e'" in manager.load_error + + def test_default_only_overlaps_are_tolerated(self, tmp_path: Path): + # 'd' in tree binds two actions in defaults (state-guarded at runtime). + # An unrelated user override must not trip on that pre-existing overlap. + manager = _load( + tmp_path, + "untouched", + {"keymap": {"action_keys": {"query_normal": {"undo": "Z"}}}}, + ) + assert manager.load_error is None + + +class TestStartupNotification: + """The load error is surfaced to the user via app.notify on startup.""" + + def test_notifies_when_load_error_present(self, tmp_path: Path): + from sqlit.domains.shell.app.startup_flow import _warn_on_keymap_error + + path = tmp_path / "bad.json" + path.write_text("not valid json", encoding="utf-8") + manager = KeymapManager(settings_store=MockSettingsStore({"custom_keymap": str(path)})) + manager.initialize() + assert manager.load_error is not None + + notifications: list[tuple[str, str]] = [] + scheduled: list = [] + + class FakeApp: + _keymap_manager = manager + + def notify(self, message: str, *, severity: str = "information", timeout: float = 5) -> None: + notifications.append((severity, message)) + + def call_after_refresh(self, callback, *args, **kwargs) -> None: + scheduled.append((callback, args, kwargs)) + + _warn_on_keymap_error(FakeApp(), is_headless=False) # type: ignore[arg-type] + # notify is deferred via call_after_refresh; run the scheduled callback. + assert len(scheduled) == 1 + callback, args, kwargs = scheduled[0] + callback(*args, **kwargs) + assert len(notifications) == 1 + severity, message = notifications[0] + assert severity == "error" + assert "Failed to load custom keymap" in message + assert "Defaults are in effect" in message + + def test_silent_when_no_load_error(self, tmp_path: Path): + from sqlit.domains.shell.app.startup_flow import _warn_on_keymap_error + + manager = KeymapManager(settings_store=MockSettingsStore({})) + manager.initialize() + assert manager.load_error is None + + scheduled: list = [] + notifications: list[tuple[str, str]] = [] + + class FakeApp: + _keymap_manager = manager + + def notify(self, message: str, *, severity: str = "information", timeout: float = 5) -> None: + notifications.append((severity, message)) + + def call_after_refresh(self, callback, *args, **kwargs) -> None: + scheduled.append((callback, args, kwargs)) + + _warn_on_keymap_error(FakeApp(), is_headless=False) # type: ignore[arg-type] + # Nothing scheduled or notified when load_error is None. + assert scheduled == [] + assert notifications == [] + + +class TestMetadataInheritance: + """Action metadata (label, category, guard, priority, show) always comes from defaults.""" + + def test_leader_inherits_label_and_category(self, tmp_path: Path): + _load( + tmp_path, + "leader-meta", + {"keymap": {"leader_commands": {"leader": {"show_help": "question_mark"}}}}, + ) + match = next( + c for c in get_keymap().get_leader_commands() + if c.action == "show_help" and c.menu == "leader" + ) + assert match.key == "question_mark" + assert match.label == "Help" # inherited + assert match.category == "Actions" # inherited + + def test_action_key_inherits_flags(self, tmp_path: Path): + # leader_key in 'global' is primary=True, priority=True by default. + _load( + tmp_path, + "flags", + {"keymap": {"action_keys": {"global": {"leader_key": "comma"}}}}, + ) + match = next( + ak for ak in get_keymap().get_action_keys() + if ak.action == "leader_key" and ak.context == "global" + ) + assert match.key == "comma" + assert match.primary is True + assert match.priority is True