diff --git a/README.md b/README.md index 5763df618c..906c949981 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,9 @@ notify=disabled ## Color-depth: set to one of 1 (for monochrome), 16, 256, or 24bit color-depth=256 + +## Custom Keybindings: keybindings can be customized for user preferences (see elsewhere for default) +custom_hotkeys=GO_UP:G, GO_DOWN:B ``` > **NOTE:** Most of these configuration settings may be specified on the diff --git a/tests/config/test_keys.py b/tests/config/test_keys.py index c0807962d3..88df31e169 100644 --- a/tests/config/test_keys.py +++ b/tests/config/test_keys.py @@ -112,3 +112,83 @@ def test_updated_urwid_command_map() -> None: assert key in keys.keys_for_command(zt_cmd) except KeyError: pass + + +def test_override_keybindings_valid(mocker: MockerFixture) -> None: + custom_keybindings = {"GO_UP": "y"} + test_key_bindings: Dict[str, keys.KeyBinding] = { + "GO_UP": { + "keys": ["up", "k"], + "help_text": "Go up / Previous message", + "key_category": "navigation", + }, + "GO_DOWN": { + "keys": ["down", "j"], + "help_text": "Go down / Next message", + "key_category": "navigation", + }, + } + + # Mock the KEY_BINDINGS to use the test copy + mocker.patch.object(keys, "KEY_BINDINGS", test_key_bindings) + + # Now this will modify the test copy, not the original + keys.override_keybindings(custom_keybindings, test_key_bindings) + + assert test_key_bindings["GO_UP"]["keys"] == ["y"] + + +def test_override_keybindings_invalid_command(mocker: MockerFixture) -> None: + custom_keybindings = {"INVALID_COMMAND": "x"} + test_key_bindings: Dict[str, keys.KeyBinding] = { + "GO_UP": { + "keys": ["up", "k"], + "help_text": "Go up / Previous message", + "key_category": "navigation", + }, + "GO_DOWN": { + "keys": ["down", "j"], + "help_text": "Go down / Next message", + "key_category": "navigation", + }, + } + with pytest.raises(keys.InvalidCommand): + keys.override_keybindings(custom_keybindings, test_key_bindings) + + +def test_override_keybindings_conflict(mocker: MockerFixture) -> None: + custom_keybindings = {"GO_UP": "j"} # 'j' is originally for GO_DOWN + test_key_bindings: Dict[str, keys.KeyBinding] = { + "GO_UP": { + "keys": ["up", "k"], + "help_text": "Go up / Previous message", + "key_category": "navigation", + }, + "GO_DOWN": { + "keys": ["down", "j"], + "help_text": "Go down / Next message", + "key_category": "navigation", + }, + } + keys.override_keybindings(custom_keybindings, test_key_bindings) + assert "j" in test_key_bindings["GO_DOWN"]["keys"] # unchanged + assert test_key_bindings["GO_UP"]["keys"] != ["j"] # not updated due to conflict + + +def test_override_multiple_keybindings_valid(mocker: MockerFixture) -> None: + custom_keybindings = {"GO_UP": "y", "GO_DOWN": "b"} # 'y' and 'b' are unused + test_key_bindings: Dict[str, keys.KeyBinding] = { + "GO_UP": { + "keys": ["up", "k"], + "help_text": "Go up / Previous message", + "key_category": "navigation", + }, + "GO_DOWN": { + "keys": ["down", "j"], + "help_text": "Go down / Next message", + "key_category": "navigation", + }, + } + keys.override_keybindings(custom_keybindings, test_key_bindings) + assert test_key_bindings["GO_UP"]["keys"] == ["y"] + assert test_key_bindings["GO_DOWN"]["keys"] == ["b"] diff --git a/zulipterminal/cli/run.py b/zulipterminal/cli/run.py index a3ccd9d18e..25cf2ff91b 100755 --- a/zulipterminal/cli/run.py +++ b/zulipterminal/cli/run.py @@ -17,6 +17,7 @@ from urwid import display_common, set_encoding from zulipterminal.api_types import ServerSettings +from zulipterminal.config.keys import KEY_BINDINGS, override_keybindings from zulipterminal.config.themes import ( ThemeError, aliased_themes, @@ -554,6 +555,15 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: print_setting("color depth setting", zterm["color-depth"]) print_setting("notify setting", zterm["notify"]) + if "custom_keybindings" in zterm: + custom_keybindings_str = zterm["custom_keybindings"].value + _, key_value_pairs = custom_keybindings_str.split("=") + # Split each pair and convert to a dictionary + custom_keybindings = dict( + pair.split(":") for pair in key_value_pairs.split(", ") + ) + override_keybindings(custom_keybindings, KEY_BINDINGS) + ### Generate data not output to user, but into Controller # Generate urwid palette color_depth_str = zterm["color-depth"].value diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index f6f6c09c0c..e4a9702cbe 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -471,6 +471,49 @@ def commands_for_random_tips() -> List[KeyBinding]: ] +def override_keybindings( + custom_keybindings: Dict[str, str], existing_keybindings: Dict[str, KeyBinding] +) -> None: + reverse_key_map = { + key: cmd + for cmd, binding in existing_keybindings.items() + for key in binding["keys"] + } + requested_changes = {} + conflicts = {} + + # Collect requested changes and detect conflicts + for command, new_key in custom_keybindings.items(): + if command not in existing_keybindings: + raise InvalidCommand(f"Invalid command {command} in custom keybindings") + + current_keys = existing_keybindings[command]["keys"] + if new_key not in current_keys: + requested_changes[command] = new_key + if new_key in reverse_key_map and reverse_key_map[new_key] != command: + conflicting_cmd = reverse_key_map[new_key] + conflicts[command] = conflicting_cmd + + # Resolve direct swaps + for command, new_key in custom_keybindings.items(): + if command in conflicts: + conflicting_cmd = conflicts[command] + if ( + conflicting_cmd in custom_keybindings + and custom_keybindings[conflicting_cmd] in current_keys + ): + del conflicts[command] + del conflicts[conflicting_cmd] + + if conflicts: + # Handle unresolved conflicts, e.g., by warning the user + return + + # Apply changes + for command, new_key in requested_changes.items(): + existing_keybindings[command]["keys"] = [new_key] + + # Refer urwid/command_map.py # Adds alternate keys for standard urwid navigational commands. for zt_cmd, urwid_cmd in ZT_TO_URWID_CMD_MAPPING.items():