From 55705ce53061d365cc71e4ad090efb6530bbd64f Mon Sep 17 00:00:00 2001 From: Xander <1174877+xtrasmal@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:24:23 +0100 Subject: [PATCH] feat(keybindings): add file-based custom keymap configuration 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. --- config/keymap.template.json | 234 +++++++++++++++ config/settings.template.json | 1 + docs/custom-keymaps.md | 283 ++++++++++++++++++ sqlit/core/__init__.py | 2 + sqlit/core/keymap_manager.py | 304 ++++++++++++++++++++ sqlit/domains/shell/app/main.py | 4 + sqlit/domains/shell/app/startup_flow.py | 3 + tests/ui/keybindings/test_keymap_manager.py | 291 +++++++++++++++++++ 8 files changed, 1122 insertions(+) create mode 100644 config/keymap.template.json create mode 100644 docs/custom-keymaps.md create mode 100644 sqlit/core/keymap_manager.py create mode 100644 tests/ui/keybindings/test_keymap_manager.py diff --git a/config/keymap.template.json b/config/keymap.template.json new file mode 100644 index 00000000..86a35311 --- /dev/null +++ b/config/keymap.template.json @@ -0,0 +1,234 @@ +{ + "_note": "Copy to ~/.sqlit/keymaps/my-custom.json and customize keybindings", + "_usage": "Set 'custom_keymap' in settings.json to 'my-custom' (without .json extension)", + "keymap": { + "leader_commands": [ + { + "_comment": "Leader commands are triggered by pressing the leader key (default: space) followed by another key", + "key": "q", + "action": "quit", + "label": "Quit", + "category": "Actions", + "guard": null, + "menu": "leader" + }, + { + "key": "h", + "action": "show_help", + "label": "Help", + "category": "Actions", + "menu": "leader" + }, + { + "key": "e", + "action": "toggle_explorer", + "label": "Toggle Explorer", + "category": "View", + "menu": "leader" + }, + { + "key": "f", + "action": "toggle_fullscreen", + "label": "Toggle Maximize", + "category": "View", + "menu": "leader" + }, + { + "key": "c", + "action": "show_connection_picker", + "label": "Connect", + "category": "Connection", + "menu": "leader" + }, + { + "key": "x", + "action": "disconnect", + "label": "Disconnect", + "category": "Connection", + "guard": "has_connection", + "menu": "leader" + }, + { + "key": "z", + "action": "cancel_operation", + "label": "Cancel", + "category": "Actions", + "guard": "query_executing", + "menu": "leader" + }, + { + "key": "t", + "action": "change_theme", + "label": "Change Theme", + "category": "Actions", + "menu": "leader" + }, + { + "key": "space", + "action": "telescope", + "label": "Telescope", + "category": "Actions", + "menu": "leader" + }, + { + "key": "slash", + "action": "telescope_filter", + "label": "Telescope Search", + "category": "Actions", + "menu": "leader" + } + ], + "action_keys": [ + { + "_comment": "Action keys are direct keybindings that work in specific contexts", + "key": "space", + "action": "leader_key", + "context": "global", + "guard": null, + "primary": true, + "show": false, + "priority": true + }, + { + "key": "ctrl+q", + "action": "quit", + "context": "global", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "escape", + "action": "cancel_operation", + "context": "global", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "question_mark", + "action": "show_help", + "context": "global", + "primary": true, + "show": false, + "priority": false + }, + { + "_comment": "Query editor keybindings (normal mode)", + "key": "i", + "action": "enter_insert_mode", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "I", + "action": "prepend_insert_mode", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "o", + "action": "open_line_below", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "O", + "action": "open_line_above", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "enter", + "action": "execute_query", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "_comment": "Vim cursor movement (normal mode)", + "key": "h", + "action": "cursor_left", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "j", + "action": "cursor_down", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "k", + "action": "cursor_up", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "l", + "action": "cursor_right", + "context": "query_normal", + "primary": true, + "show": false, + "priority": false + }, + { + "_comment": "Query editor keybindings (insert mode)", + "key": "escape", + "action": "exit_insert_mode", + "context": "query_insert", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "ctrl+enter", + "action": "execute_query_insert", + "context": "query_insert", + "primary": true, + "show": false, + "priority": false + }, + { + "_comment": "Navigation between panes", + "key": "e", + "action": "focus_explorer", + "context": "navigation", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "q", + "action": "focus_query", + "context": "navigation", + "primary": true, + "show": false, + "priority": false + }, + { + "key": "r", + "action": "focus_results", + "context": "navigation", + "primary": true, + "show": false, + "priority": false + } + ] + } +} 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/docs/custom-keymaps.md b/docs/custom-keymaps.md new file mode 100644 index 00000000..6f3c548f --- /dev/null +++ b/docs/custom-keymaps.md @@ -0,0 +1,283 @@ +# Custom Keymaps + +sqlit supports custom keymaps, allowing you to customize all keyboard shortcuts to match your preferences. + +## Quick Start + +1. **Copy the template:** + ```bash + mkdir -p ~/.sqlit/keymaps + cp config/keymap.template.json ~/.sqlit/keymaps/my-custom.json + ``` + +2. **Edit the keymap:** + Open `~/.sqlit/keymaps/my-custom.json` and customize the keybindings. + +3. **Enable your custom keymap:** + Edit `~/.sqlit/settings.json` and set: + ```json + { + "custom_keymap": "my-custom" + } + ``` + Note: Use the filename without the `.json` extension. + +4. **Restart sqlit** to load your custom keymap. + +## Keymap Structure + +Custom keymaps are JSON files with two main sections: + +### Leader Commands + +Leader commands are triggered by pressing the leader key (default: `space`) followed by another key. + +```json +{ + "keymap": { + "leader_commands": [ + { + "key": "q", + "action": "quit", + "label": "Quit", + "category": "Actions", + "guard": null, + "menu": "leader" + } + ] + } +} +``` + +**Fields:** +- `key` (required): The key to press after the leader key +- `action` (required): The action to execute +- `label` (required): Display label in the help menu +- `category` (required): Category for grouping in help menu +- `guard` (optional): Condition that must be met (e.g., `"has_connection"`) +- `menu` (optional): Menu ID (default: `"leader"`) + +### Action Keys + +Action keys are direct keybindings that work in specific contexts. + +```json +{ + "keymap": { + "action_keys": [ + { + "key": "i", + "action": "enter_insert_mode", + "context": "query_normal", + "guard": null, + "primary": true, + "show": false, + "priority": false + } + ] + } +} +``` + +**Fields:** +- `key` (required): The key combination (e.g., `"i"`, `"ctrl+q"`, `"escape"`) +- `action` (required): The action to execute +- `context` (optional): Context where this binding is active (e.g., `"query_normal"`, `"tree"`, `"results"`) +- `guard` (optional): Condition that must be met +- `primary` (optional): Whether this is the primary binding for the action (default: `true`) +- `show` (optional): Whether to show in Textual's binding hints (default: `false`) +- `priority` (optional): Whether to give priority to this binding (default: `false`) + +## Special Key Names + +Some keys use special names in the keymap: + +- `space` - Space bar +- `escape` - Escape key +- `enter` - Enter/Return key +- `backspace` - Backspace key +- `delete` - Delete key +- `tab` - Tab key +- `question_mark` - ? key +- `slash` - / key +- `dollar_sign` - $ key +- `percent_sign` - % key +- `asterisk` - * key +- `ctrl+` - Ctrl key combinations (e.g., `"ctrl+q"`, `"ctrl+enter"`) +- `shift+` - Shift key combinations (e.g., `"shift+tab"`) + +## Common Actions + +### Global Actions +- `quit` - Exit sqlit +- `show_help` - Show help menu +- `cancel_operation` - Cancel current operation +- `leader_key` - Trigger leader menu + +### Navigation Actions +- `focus_explorer` - Focus the database explorer +- `focus_query` - Focus the query editor +- `focus_results` - Focus the results table + +### Connection Actions +- `show_connection_picker` - Show connection picker +- `disconnect` - Disconnect from database +- `new_connection` - Create new connection + +### Query Editor Actions (Normal Mode) +- `enter_insert_mode` - Enter insert mode +- `prepend_insert_mode` - Enter insert mode at line start +- `append_insert_mode` - Enter insert mode after cursor +- `append_line_end` - Enter insert mode at line end +- `open_line_below` - Open new line below and enter insert mode +- `open_line_above` - Open new line above and enter insert mode +- `execute_query` - Execute the query + +### Query Editor Actions (Insert Mode) +- `exit_insert_mode` - Return to normal mode +- `execute_query_insert` - Execute query from insert mode + +### Vim-style Navigation +- `cursor_left`, `cursor_down`, `cursor_up`, `cursor_right` - Move cursor +- `cursor_word_forward` - Move to next word +- `cursor_word_back` - Move to previous word +- `cursor_line_start` - Move to line start +- `cursor_line_end` - Move to line end +- `cursor_last_line` - Move to last line + +### Results Actions +- `view_cell` - View cell value +- `edit_cell` - Edit cell value +- `delete_row` - Delete row +- `results_yank_leader_key` - Trigger results copy menu +- `clear_results` - Clear results +- `results_filter` - Filter results + +## Contexts + +Action keys can be scoped to specific contexts: + +- `global` - Active everywhere +- `query_normal` - Query editor in normal mode +- `query_insert` - Query editor in insert mode +- `tree` - Database explorer tree +- `results` - Results table +- `navigation` - For navigation actions +- `autocomplete` - Autocomplete dropdown + +## Guards + +Guards are conditions that must be met for a keybinding to be active: + +- `has_connection` - Database is connected +- `query_executing` - Query is currently executing + +## Examples + +### Vim-style Leader Key + +Change the leader key from space to comma: + +```json +{ + "keymap": { + "action_keys": [ + { + "key": "comma", + "action": "leader_key", + "context": "global", + "primary": true, + "priority": true + } + ] + } +} +``` + +### Emacs-style Bindings + +Use Ctrl+X Ctrl+C to quit instead of `q`: + +```json +{ + "keymap": { + "leader_commands": [], + "action_keys": [ + { + "key": "ctrl+x", + "action": "leader_key", + "context": "global", + "primary": true, + "priority": true + } + ] + } +} +``` + +Then add a leader command: +```json +{ + "key": "ctrl+c", + "action": "quit", + "label": "Quit", + "category": "Actions", + "menu": "leader" +} +``` + +### Custom Query Execution + +Execute query with Ctrl+Enter in normal mode: + +```json +{ + "keymap": { + "action_keys": [ + { + "key": "ctrl+enter", + "action": "execute_query", + "context": "query_normal", + "primary": true + } + ] + } +} +``` + +## Troubleshooting + +### Keymap not loading + +1. Check that `custom_keymap` in `~/.sqlit/settings.json` matches your filename (without `.json`) +2. Check console output for error messages: `sqlit 2>&1 | grep keymap` +3. Verify JSON syntax with: `python -m json.tool ~/.sqlit/keymaps/my-custom.json` + +### Invalid JSON + +If sqlit reports JSON errors, validate your file: + +```bash +python -m json.tool ~/.sqlit/keymaps/my-custom.json > /dev/null +``` + +### Missing required fields + +Each leader command must have: `key`, `action`, `label`, `category` +Each action key must have: `key`, `action` + +## Resetting to Default + +To revert to the default keymap, set in `~/.sqlit/settings.json`: + +```json +{ + "custom_keymap": "default" +} +``` + +Or remove the `custom_keymap` field entirely. + +## Complete Example + +See `config/keymap.template.json` for a complete example with all common keybindings. diff --git a/sqlit/core/__init__.py b/sqlit/core/__init__.py index 761fd29f..3a70d523 100644 --- a/sqlit/core/__init__.py +++ b/sqlit/core/__init__.py @@ -10,12 +10,14 @@ reset_keymap, set_keymap, ) +from .keymap_manager import KeymapManager from .leader_commands import get_leader_commands from .vim import VimMode __all__ = [ "ActionKeyDef", "InputContext", + "KeymapManager", "KeymapProvider", "LeaderCommandDef", "VimMode", diff --git a/sqlit/core/keymap_manager.py b/sqlit/core/keymap_manager.py new file mode 100644 index 00000000..50f6af4c --- /dev/null +++ b/sqlit/core/keymap_manager.py @@ -0,0 +1,304 @@ +"""Keymap management utilities for sqlit.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from sqlit.shared.core.protocols import SettingsStoreProtocol +from sqlit.shared.core.store import CONFIG_DIR + +from .keymap import ( + ActionKeyDef, + KeymapProvider, + LeaderCommandDef, + get_keymap, + set_keymap, +) + +CUSTOM_KEYMAP_SETTINGS_KEY = "custom_keymap" +CUSTOM_KEYMAP_DIR = CONFIG_DIR / "keymaps" +LEADER_COMMAND_FIELDS = {"key", "action", "label", "category", "guard", "menu"} +ACTION_KEY_FIELDS = {"key", "action", "context", "guard", "primary", "show", "priority"} + + +class FileBasedKeymapProvider(KeymapProvider): + """Keymap provider that loads from JSON file.""" + + 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: + """Get the keymap name.""" + return self._name + + def get_leader_commands(self) -> list[LeaderCommandDef]: + """Get all leader command definitions.""" + return list(self._leader_commands) + + def get_action_keys(self) -> list[ActionKeyDef]: + """Get all regular action key definitions.""" + return list(self._action_keys) + + +class KeymapManager: + """Centralized keymap handling for the app.""" + + def __init__( + self, + settings_store: SettingsStoreProtocol | None = None, + ) -> None: + from sqlit.domains.shell.store.settings import SettingsStore + + self._settings_store = settings_store or SettingsStore.get_instance() + self._custom_keymap_name: str | None = None + self._custom_keymap_path: Path | None = None + + def initialize(self) -> dict: + """Initialize keymap from settings. + + Returns: + The loaded settings dictionary. + """ + settings = self._settings_store.load_all() + self.load_custom_keymap(settings) + return settings + + def load_custom_keymap(self, settings: dict) -> None: + """Load custom keymap from settings if specified. + + Args: + settings: Settings dictionary containing custom_keymap key. + """ + keymap_name = settings.get(CUSTOM_KEYMAP_SETTINGS_KEY) + if not keymap_name or not isinstance(keymap_name, str): + return + if keymap_name.strip() in ("", "default"): + return + + try: + path = self._resolve_keymap_path(keymap_name.strip()) + self._register_custom_keymap(path, keymap_name.strip()) + except Exception as exc: + print( + f"[sqlit] Failed to load custom keymap '{keymap_name}': {exc}", + file=sys.stderr, + ) + + def _resolve_keymap_path(self, keymap_name: str) -> Path: + """Resolve keymap name to file path. + + Args: + keymap_name: Name of the keymap (without .json extension). + + Returns: + Path to the keymap JSON file. + """ + if keymap_name.startswith(("~", "/")) or Path(keymap_name).is_absolute(): + return Path(keymap_name).expanduser() + + name = Path(keymap_name).stem + file_name = f"{name}.json" + return CUSTOM_KEYMAP_DIR / file_name + + def _register_custom_keymap(self, path: Path, keymap_name: str) -> None: + """Load and register a custom keymap from file. + + Args: + path: Path to the keymap JSON file. + keymap_name: Name of the keymap. + + Raises: + ValueError: If the keymap file is invalid. + """ + 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) + self._custom_keymap_name = keymap_name + self._custom_keymap_path = path.resolve() + + def _load_keymap_from_file(self, path: Path, keymap_name: str) -> FileBasedKeymapProvider: + """Load keymap data from JSON file. + + Args: + path: Path to the keymap JSON file. + keymap_name: Name of the keymap. + + Returns: + FileBasedKeymapProvider instance. + + Raises: + ValueError: If the JSON is invalid or missing required fields. + """ + 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.') + + leader_commands_data = keymap_data.get("leader_commands", []) + action_keys_data = keymap_data.get("action_keys", []) + + if not isinstance(leader_commands_data, list): + raise ValueError('"leader_commands" must be a list.') + if not isinstance(action_keys_data, list): + raise ValueError('"action_keys" must be a list.') + + leader_commands = self._parse_leader_commands(leader_commands_data) + action_keys = self._parse_action_keys(action_keys_data) + + return FileBasedKeymapProvider(keymap_name, leader_commands, action_keys) + + def _parse_leader_commands(self, data: list[Any]) -> list[LeaderCommandDef]: + """Parse leader commands from JSON data. + + Args: + data: List of leader command dictionaries. + + Returns: + List of LeaderCommandDef instances. + + Raises: + ValueError: If any command is invalid. + """ + commands = [] + for i, item in enumerate(data): + if not isinstance(item, dict): + raise ValueError(f'Leader command at index {i} must be an object.') + + key = item.get("key") + action = item.get("action") + label = item.get("label") + category = item.get("category") + + if not isinstance(key, str) or not key: + raise ValueError(f'Leader command at index {i} missing required "key".') + if not isinstance(action, str) or not action: + raise ValueError(f'Leader command at index {i} missing required "action".') + if not isinstance(label, str) or not label: + raise ValueError(f'Leader command at index {i} missing required "label".') + if not isinstance(category, str) or not category: + raise ValueError(f'Leader command at index {i} missing required "category".') + + guard = item.get("guard") + if guard is not None and not isinstance(guard, str): + raise ValueError(f'Leader command at index {i} "guard" must be a string.') + + menu = item.get("menu", "leader") + if not isinstance(menu, str): + raise ValueError(f'Leader command at index {i} "menu" must be a string.') + + commands.append( + LeaderCommandDef( + key=key, + action=action, + label=label, + category=category, + guard=guard, + menu=menu, + ) + ) + + return commands + + def _parse_action_keys(self, data: list[Any]) -> list[ActionKeyDef]: + """Parse action keys from JSON data. + + Args: + data: List of action key dictionaries. + + Returns: + List of ActionKeyDef instances. + + Raises: + ValueError: If any action key is invalid. + """ + action_keys = [] + for i, item in enumerate(data): + if not isinstance(item, dict): + raise ValueError(f'Action key at index {i} must be an object.') + + key = item.get("key") + action = item.get("action") + + if not isinstance(key, str) or not key: + raise ValueError(f'Action key at index {i} missing required "key".') + if not isinstance(action, str) or not action: + raise ValueError(f'Action key at index {i} missing required "action".') + + context = item.get("context") + if context is not None and not isinstance(context, str): + raise ValueError(f'Action key at index {i} "context" must be a string.') + + guard = item.get("guard") + if guard is not None and not isinstance(guard, str): + raise ValueError(f'Action key at index {i} "guard" must be a string.') + + primary = item.get("primary", True) + if not isinstance(primary, bool): + raise ValueError(f'Action key at index {i} "primary" must be a boolean.') + + show = item.get("show", False) + if not isinstance(show, bool): + raise ValueError(f'Action key at index {i} "show" must be a boolean.') + + priority = item.get("priority", False) + if not isinstance(priority, bool): + raise ValueError(f'Action key at index {i} "priority" must be a boolean.') + + action_keys.append( + ActionKeyDef( + key=key, + action=action, + context=context, + guard=guard, + primary=primary, + show=show, + priority=priority, + ) + ) + + return action_keys + + def get_custom_keymap_name(self) -> str | None: + """Get the name of the currently loaded custom keymap. + + Returns: + Keymap name or None if using default keymap. + """ + return self._custom_keymap_name + + def get_custom_keymap_path(self) -> Path | None: + """Get the path to the currently loaded custom keymap file. + + Returns: + Path to keymap file or None if using default keymap. + """ + return self._custom_keymap_path + + def reset_to_default(self) -> None: + """Reset to the default keymap.""" + from .keymap import reset_keymap + + reset_keymap() + self._custom_keymap_name = None + self._custom_keymap_path = None diff --git a/sqlit/domains/shell/app/main.py b/sqlit/domains/shell/app/main.py index f99ed82e..71230f70 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.core.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 diff --git a/sqlit/domains/shell/app/startup_flow.py b/sqlit/domains/shell/app/startup_flow.py index 84c482a7..dc4e9007 100644 --- a/sqlit/domains/shell/app/startup_flow.py +++ b/sqlit/domains/shell/app/startup_flow.py @@ -35,6 +35,9 @@ def run_on_mount(app: AppProtocol) -> None: settings = app._theme_manager.initialize() app._startup_stamp("settings_loaded") + app._keymap_manager.initialize() + 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) diff --git a/tests/ui/keybindings/test_keymap_manager.py b/tests/ui/keybindings/test_keymap_manager.py new file mode 100644 index 00000000..ab94bc3d --- /dev/null +++ b/tests/ui/keybindings/test_keymap_manager.py @@ -0,0 +1,291 @@ +"""Tests for the KeymapManager.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from sqlit.core.keymap import get_keymap, reset_keymap +from sqlit.core.keymap_manager import KeymapManager + + +class MockSettingsStore: + """Mock settings store for testing.""" + + 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) + + +@pytest.fixture(autouse=True) +def reset_keymap_after_test(): + """Reset keymap after each test to avoid cross-test pollution.""" + yield + reset_keymap() + + +class TestKeymapManager: + """Test the KeymapManager class.""" + + def test_initialize_with_no_custom_keymap(self): + """Should use default keymap when no custom keymap is specified.""" + settings_store = MockSettingsStore({}) + manager = KeymapManager(settings_store=settings_store) + + settings = manager.initialize() + + assert settings == {} + assert manager.get_custom_keymap_name() is None + assert manager.get_custom_keymap_path() is None + + def test_initialize_with_default_keymap_setting(self): + """Should use default keymap when custom_keymap is set to 'default'.""" + settings_store = MockSettingsStore({"custom_keymap": "default"}) + manager = KeymapManager(settings_store=settings_store) + + settings = manager.initialize() + + assert settings == {"custom_keymap": "default"} + assert manager.get_custom_keymap_name() is None + + def test_load_custom_keymap_from_file(self, tmp_path: Path): + """Should load custom keymap from JSON file.""" + keymap_file = tmp_path / "my-custom.json" + keymap_data = { + "keymap": { + "leader_commands": [ + { + "key": "x", + "action": "quit", + "label": "Exit", + "category": "Actions", + "menu": "leader", + } + ], + "action_keys": [ + { + "key": "ctrl+x", + "action": "quit", + "context": "global", + "primary": True, + "show": False, + "priority": False, + } + ], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + + manager.initialize() + + assert manager.get_custom_keymap_name() == str(keymap_file) + assert manager.get_custom_keymap_path() == keymap_file + + keymap = get_keymap() + assert keymap.leader("quit") == "x" + assert keymap.action("quit") == "ctrl+x" + + def test_load_custom_keymap_with_invalid_json(self, tmp_path: Path, capsys): + """Should handle invalid JSON gracefully.""" + keymap_file = tmp_path / "invalid.json" + keymap_file.write_text("not valid json", encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + + manager.initialize() + + captured = capsys.readouterr() + assert "Failed to load custom keymap" in captured.err + assert manager.get_custom_keymap_name() is None + + def test_load_custom_keymap_with_missing_file(self, tmp_path: Path, capsys): + """Should handle missing file gracefully.""" + keymap_file = tmp_path / "nonexistent.json" + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + + manager.initialize() + + captured = capsys.readouterr() + assert "Failed to load custom keymap" in captured.err + assert "not found" in captured.err + + def test_reset_to_default(self, tmp_path: Path): + """Should reset to default keymap.""" + keymap_file = tmp_path / "test.json" + keymap_data = { + "keymap": { + "leader_commands": [ + { + "key": "z", + "action": "quit", + "label": "Quit", + "category": "Actions", + } + ], + "action_keys": [], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + manager.initialize() + + assert manager.get_custom_keymap_name() is not None + + manager.reset_to_default() + + assert manager.get_custom_keymap_name() is None + assert manager.get_custom_keymap_path() is None + + def test_parse_leader_commands_with_all_fields(self, tmp_path: Path): + """Should parse leader commands with all optional fields.""" + keymap_file = tmp_path / "full.json" + keymap_data = { + "keymap": { + "leader_commands": [ + { + "key": "x", + "action": "disconnect", + "label": "Disconnect", + "category": "Connection", + "guard": "has_connection", + "menu": "custom", + } + ], + "action_keys": [], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + manager.initialize() + + keymap = get_keymap() + commands = keymap.get_leader_commands() + assert len(commands) == 1 + assert commands[0].key == "x" + assert commands[0].action == "disconnect" + assert commands[0].guard == "has_connection" + assert commands[0].menu == "custom" + + def test_parse_action_keys_with_all_fields(self, tmp_path: Path): + """Should parse action keys with all fields.""" + keymap_file = tmp_path / "actions.json" + keymap_data = { + "keymap": { + "leader_commands": [], + "action_keys": [ + { + "key": "i", + "action": "enter_insert_mode", + "context": "query_normal", + "guard": "not_executing", + "primary": False, + "show": True, + "priority": True, + } + ], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + manager.initialize() + + keymap = get_keymap() + action_keys = keymap.get_action_keys() + assert len(action_keys) == 1 + assert action_keys[0].key == "i" + assert action_keys[0].action == "enter_insert_mode" + assert action_keys[0].context == "query_normal" + assert action_keys[0].guard == "not_executing" + assert action_keys[0].primary is False + assert action_keys[0].show is True + assert action_keys[0].priority is True + + def test_parse_keymap_with_nested_keymap_object(self, tmp_path: Path): + """Should handle both nested and flat keymap structure.""" + keymap_file = tmp_path / "nested.json" + keymap_data = { + "keymap": { + "leader_commands": [ + {"key": "q", "action": "quit", "label": "Quit", "category": "Actions"} + ], + "action_keys": [], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + manager.initialize() + + keymap = get_keymap() + assert keymap.leader("quit") == "q" + + def test_invalid_leader_command_missing_required_field(self, tmp_path: Path, capsys): + """Should error on missing required fields.""" + keymap_file = tmp_path / "invalid.json" + keymap_data = { + "keymap": { + "leader_commands": [ + { + "key": "q", + "action": "quit", + # Missing "label" and "category" + } + ], + "action_keys": [], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + manager.initialize() + + captured = capsys.readouterr() + assert "Failed to load custom keymap" in captured.err + + def test_invalid_action_key_missing_required_field(self, tmp_path: Path, capsys): + """Should error on missing required action key fields.""" + keymap_file = tmp_path / "invalid-action.json" + keymap_data = { + "keymap": { + "leader_commands": [], + "action_keys": [ + { + "key": "i", + # Missing "action" + } + ], + } + } + keymap_file.write_text(json.dumps(keymap_data), encoding="utf-8") + + settings_store = MockSettingsStore({"custom_keymap": str(keymap_file)}) + manager = KeymapManager(settings_store=settings_store) + manager.initialize() + + captured = capsys.readouterr() + assert "Failed to load custom keymap" in captured.err