diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 4637241..62e08c4 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -67,7 +67,7 @@ HOSTS_MANAGER_BINDINGS = [ Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"), Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"), Binding( - "R", + "shift+r,R", "refresh_dns", "Update all DNS based Entries", show=False, diff --git a/src/hosts/tui/keyboard_protocol.py b/src/hosts/tui/keyboard_protocol.py index 7197beb..006b8d4 100644 --- a/src/hosts/tui/keyboard_protocol.py +++ b/src/hosts/tui/keyboard_protocol.py @@ -37,6 +37,17 @@ class HostsXTermParser(XTermParser): if key is None: key = _character_to_key(chr(codepoint) if codepoint else text or "") + # Shifted punctuation is a semantic text key rather than a + # portable physical-key shortcut. For example, ``?`` is Shift+/ + # on a US layout and Shift+ß on a German layout. Keep modifiers + # for letters, where Shift+N must remain distinct from Caps Lock + # N, but bind punctuation by the text it produced. + use_text_key = ( + text is not None and not text.isalpha() and not text.isspace() + ) + if use_text_key: + key = _character_to_key(text) + key_tokens: list[str] = [] if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS: modifier_bits = modifiers - 1 @@ -52,6 +63,12 @@ class HostsXTermParser(XTermParser): ) for bit, modifier in enumerate(modifiers_by_bit): if modifier_bits & (1 << bit): + if use_text_key and modifier in { + "shift", + "caps_lock", + "num_lock", + }: + continue key_tokens.append(modifier) key_tokens.append(key) diff --git a/tests/test_app.py b/tests/test_app.py index 37a4ead..1e892d8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -160,6 +160,74 @@ def test_enhanced_caps_lock_n_does_not_become_shift_n(): assert event.character == "N" +@pytest.mark.parametrize( + "sequence", + ( + "\x1b[47;2;63u", # US: Shift+/ + "\x1b[223;2;63u", # German: Shift+ß + ), +) +def test_enhanced_question_mark_uses_the_produced_text_key(sequence): + """Shifted punctuation binds by its text across keyboard layouts.""" + event = list(HostsXTermParser().feed(sequence))[0] + + assert isinstance(event, Key) + assert event.key == "question_mark" + assert event.character == "?" + + +def test_keyboard_protocol_events_match_all_character_and_modifier_bindings(): + """Terminal key events resolve to every app-level shortcut action.""" + sequences_and_actions = { + "\x1b[110u": "add_entry", + "\x1b[110;2;78u": "new_from_selected", + "N": "new_from_selected", + "\x1b[100u": "delete_entry", + "\x1b[101u": "edit_entry", + "\x1b[32u": "toggle_entry", + "\x1b[101;5u": "toggle_edit_mode", + "\x1b[99u": "config", + "\x1b[120;5u": "export_entries", + "\x1b[111;5u": "import_entries", + "\x1b[102;5u": "show_filters", + "\x1b[47;2;63u": "help", + "?": "help", + "\x1b[113u": "quit", + "\x1b[114;5u": "reload", + "\x1b[105u": "sort_by_ip", + "\x1b[104u": "sort_by_hostname", + "\x1b[115;5u": "save_file", + "\x1b[98u": "restore_backup", + "\x1b[1;2A": "move_entry_up", + "\x1b[1;2B": "move_entry_down", + "\x1b[122;5u": "undo", + "\x1b[121;5u": "redo", + "\x1b[114;2;82u": "refresh_dns", + "R": "refresh_dns", + "\x1b[114u": "update_single_dns", + "\x1b[27u": "exit_edit_entry", + "\t": "next_field", + "\x1b[Z": "prev_field", + "\x1b[9;2;9u": "prev_field", + "\x1b[99;5u": "quit", + } + actions_by_key = { + key: binding.action if hasattr(binding, "action") else binding[1] + for binding in HostsManagerApp.BINDINGS + for key in str(binding.key if hasattr(binding, "key") else binding[0]).split( + "," + ) + } + + for sequence, expected_action in sequences_and_actions.items(): + event = next( + event + for event in HostsXTermParser().feed(sequence) + if isinstance(event, Key) + ) + assert actions_by_key[event.key] == expected_action + + @pytest.mark.asyncio async def test_enhanced_shift_n_opens_new_from_selected_but_caps_lock_n_does_not(): """The real enhanced-key events distinguish Shift from Caps Lock at the app.""" @@ -234,6 +302,43 @@ async def test_new_from_selected_is_available_at_the_minimum_viewport(): assert not isinstance(app.screen, AddEntryModal) +@pytest.mark.parametrize("sequence", ("\x1b[47;2;63u", "\x1b[223;2;63u")) +@pytest.mark.asyncio +async def test_enhanced_question_mark_shortcut_opens_and_closes_help_overlay(sequence): + """Kitty Shift+? controls the dedicated help overlay.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(120, 40)) as pilot: + app.query_one("#entries-table").focus() + question_mark = list(HostsXTermParser().feed(sequence))[0] + app.post_message(question_mark) + await pilot.pause() + + assert isinstance(app.screen, HelpModal) + + question_mark = list(HostsXTermParser().feed(sequence))[0] + app.post_message(question_mark) + await pilot.pause() + + assert not isinstance(app.screen, HelpModal) + + +@pytest.mark.asyncio +async def test_enhanced_shift_r_shortcut_refreshes_all_dns_entries(): + """Kitty Shift+R invokes the documented refresh-all action.""" + app = app_with_filterable_entries() + + async with app.run_test(size=(120, 40)) as pilot: + app.query_one("#entries-table").focus() + shift_r = list(HostsXTermParser().feed("\x1b[114;2;82u"))[0] + app.post_message(shift_r) + await pilot.pause() + + assert "Cannot resolve DNS names" in str( + app.query_one("#message-rail", Static).render() + ) + + @pytest.mark.asyncio async def test_help_overlay_closes_to_the_control_that_opened_it(): """Help is an overlay and returns keyboard focus to its opener.""" diff --git a/tests/test_main.py b/tests/test_main.py index 601939a..a7ae686 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -704,7 +704,7 @@ class TestHostsManagerApp: for binding in app.BINDINGS: if hasattr(binding, "key"): # Binding object - binding_keys.append(binding.key) + binding_keys.extend(str(binding.key).split(",")) elif isinstance(binding, tuple) and len(binding) >= 1: # Tuple format (key, action, description) binding_keys.append(binding[0])