diff --git a/README.md b/README.md index 1a043e7..249a516 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ including persistence behavior and manual recovery. | `Ctrl+E` | Enter or leave Privileged Mode | | `b` | Review and restore the session Pre-edit Backup | | `n` | Add a Host Entry | -| `Shift+N` | Add a Host Entry based on the selected Host Entry | +| `Shift+N` | Add a Host Entry based on the selected Host Entry (requires an enhanced keyboard protocol) | | `e` | Open the selected Host Entry in the Entry Editor | | `d` | Delete the selected Host Entry | | `Space` | Activate or deactivate the selected Host Entry | diff --git a/docs/user-guide.md b/docs/user-guide.md index c490c63..6a256ae 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -63,7 +63,8 @@ separate from the Entry Editor used to change one Host Entry. In Privileged Mode: - Press `n` to add a Host Entry. -- Press `Shift+N` to add a Host Entry based on the selected Host Entry. +- Press `Shift+N` to add a Host Entry based on the selected Host Entry. This + requires a terminal that supports the Kitty enhanced keyboard protocol. - Select a non-default Host Entry and press `e` to open the Entry Editor. - Press `d` and confirm to delete the selected Host Entry. - Press `Space` to activate or deactivate the selected Host Entry. @@ -254,7 +255,7 @@ continually retrying it. | Key | Action | | --- | --- | | `n` | Add a Host Entry | -| `Shift+N` | Add a Host Entry based on the selected Host Entry | +| `Shift+N` | Add a Host Entry based on the selected Host Entry (requires an enhanced keyboard protocol) | | `e` | Open the selected Host Entry in the Entry Editor | | `d` | Delete the selected Host Entry | | `Space` | Activate or deactivate the selected Host Entry | diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 1708bec..9aa3b74 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -38,6 +38,7 @@ from .help_modal import HelpModal from .custom_footer import CustomFooter from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS +from .keyboard_driver import HostsKeyboardDriver from .table_handler import TableHandler from .details_handler import DetailsHandler from .edit_handler import EditHandler @@ -75,7 +76,7 @@ class HostsManagerApp(App): search_term: reactive[str] = reactive("") def __init__(self): - super().__init__() + super().__init__(driver_class=HostsKeyboardDriver) self.title = "/etc/hosts Manager" self.register_theme(HOSTS_DARK_THEME) self.theme = "hosts-dark" diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index 132de5c..7fd12e6 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -51,7 +51,7 @@ class HelpModal(ModalScreen[None]): yield Static("Privileged Mode", classes="help-section") yield Static( "Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n" - "Shift+N New from selected b Review Pre-edit Backup", + "Shift+N New from selected (enhanced terminal) b Review Pre-edit Backup", classes="help-copy", ) yield Button("Close", id="help-close", variant="primary") diff --git a/src/hosts/tui/keyboard_driver.py b/src/hosts/tui/keyboard_driver.py new file mode 100644 index 0000000..bb107d5 --- /dev/null +++ b/src/hosts/tui/keyboard_driver.py @@ -0,0 +1,54 @@ +"""Textual driver that uses the hosts enhanced-keyboard parser.""" + +from codecs import getincrementaldecoder +import os +import selectors + +from textual._loop import loop_last +from textual._parser import ParseError +from textual.drivers.linux_driver import LinuxDriver + +from .keyboard_protocol import HostsXTermParser + + +class HostsKeyboardDriver(LinuxDriver): + """Use enhanced Kitty keyboard events without collapsing their modifiers.""" + + def run_input_thread(self) -> None: + """Dispatch input through the modifier-preserving parser.""" + selector = selectors.SelectSelector() + selector.register(self.fileno, selectors.EVENT_READ) + + parser = HostsXTermParser(self._debug) + feed = parser.feed + tick = parser.tick + decode = getincrementaldecoder("utf-8")().decode + + def process_selector_events( + selector_events: list[tuple[selectors.SelectorKey, int]], + final: bool = False, + ) -> None: + for last, (_selector_key, mask) in loop_last(selector_events): + if mask & selectors.EVENT_READ: + unicode_data = decode( + os.read(self.fileno, 1024 * 4), final=final and last + ) + if not unicode_data: + break + for event in feed(unicode_data): + self.process_message(event) + for event in tick(): + self.process_message(event) + + try: + while not self.exit_event.is_set(): + process_selector_events(selector.select(0.1)) + selector.unregister(self.fileno) + process_selector_events(selector.select(0.1), final=True) + finally: + selector.close() + try: + for _event in feed(""): + pass + except (EOFError, ParseError): + pass diff --git a/src/hosts/tui/keyboard_protocol.py b/src/hosts/tui/keyboard_protocol.py new file mode 100644 index 0000000..7197beb --- /dev/null +++ b/src/hosts/tui/keyboard_protocol.py @@ -0,0 +1,66 @@ +"""Keyboard-protocol adapters for shortcuts requiring modifier fidelity.""" + +from functools import lru_cache + +from textual import events +from textual._keyboard_protocol import FUNCTIONAL_KEYS, MODIFIER_FUNCTIONAL_KEYS +from textual._xterm_parser import ( + SPECIAL_KEY_TO_CHARACTER, + XTermParser, + _re_extended_key, +) +from textual.keys import _character_to_key + + +class HostsXTermParser(XTermParser): + """Preserve Kitty keyboard modifiers for text-producing keys. + + Textual's parser intentionally folds Shift and lock modifiers into the + associated text. ``Shift+N`` and Caps Lock ``N`` therefore become + indistinguishable. This adapter keeps their modifier state in ``Key.key`` + while retaining the associated text for editable inputs. + """ + + @lru_cache(maxsize=1024) + def _parse_extended_key(self, sequence: str) -> list[events.Key] | None: + if (match := _re_extended_key.fullmatch(sequence)) is None: + return None + + codes, end = match.groups(default="") + codepoint_str, modifiers_str, text_str, *_ = codes.split(";") + ["", "", ""] + codepoint = int(codepoint_str or "1") + modifiers = int(modifiers_str or "0") + key_events: list[events.Key] = [] + + for text in self._parse_colon_codepoints(text_str): + key = FUNCTIONAL_KEYS.get(f"{codepoint}{end}") + if key is None: + key = _character_to_key(chr(codepoint) if codepoint else text or "") + + key_tokens: list[str] = [] + if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS: + modifier_bits = modifiers - 1 + modifiers_by_bit = ( + "shift", + "alt", + "ctrl", + "super", + "hyper", + "meta", + "caps_lock", + "num_lock", + ) + for bit, modifier in enumerate(modifiers_by_bit): + if modifier_bits & (1 << bit): + key_tokens.append(modifier) + + key_tokens.append(key) + key_events.append( + events.Key( + "+".join(key_tokens), + text + or (None if modifiers else SPECIAL_KEY_TO_CHARACTER.get(key, None)), + ) + ) + + return key_events diff --git a/tests/test_app.py b/tests/test_app.py index 4050baa..378cb77 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch import pytest from rich.cells import cell_len from textual.app import SuspendNotSupported +from textual.events import Key from textual.widgets import Button, Input, RadioButton, Static from src.hosts.core.filters import FilterOptions @@ -20,6 +21,8 @@ from src.hosts.tui.backup_restore_modal import BackupRestoreModal from src.hosts.tui.filter_modal import FilterModal from src.hosts.tui.help_modal import HelpModal from src.hosts.tui.add_entry_modal import AddEntryModal +from src.hosts.tui.keyboard_protocol import HostsXTermParser +from src.hosts.tui.keyboard_driver import HostsKeyboardDriver from src.hosts.tui.privilege_prompt import ( render_sudo_authentication_notice, sudo_authentication_screen, @@ -49,6 +52,11 @@ def app_with_filterable_entries() -> HostsManagerApp: return app +def test_app_uses_the_modifier_preserving_keyboard_driver(): + """The live terminal app receives enhanced keyboard events through the adapter.""" + assert HostsManagerApp().driver_class is HostsKeyboardDriver + + @pytest.mark.asyncio async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): """The filter shortcut opens a modal whose Apply action changes the view state.""" @@ -134,6 +142,47 @@ async def test_new_from_selected_prefills_the_highlighted_visible_entry(): assert not source.is_active +def test_enhanced_shift_n_preserves_the_shift_modifier(): + """Kitty Shift+N remains distinct from an uppercase text character.""" + event = list(HostsXTermParser().feed("\x1b[110;2;78u"))[0] + + assert isinstance(event, Key) + assert event.key == "shift+n" + assert event.character == "N" + + +def test_enhanced_caps_lock_n_does_not_become_shift_n(): + """Caps Lock does not invoke the New from selected binding.""" + event = list(HostsXTermParser().feed("\x1b[110;65;78u"))[0] + + assert isinstance(event, Key) + assert event.key == "caps_lock+n" + assert event.character == "N" + + +@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.""" + app = app_with_filterable_entries() + app.edit_mode = True + + async with app.run_test(size=(120, 40)) as pilot: + app.table_handler.populate_entries_table() + app.query_one("#entries-table").focus() + + shift_n = list(HostsXTermParser().feed("\x1b[110;2;78u"))[0] + app.post_message(shift_n) + await pilot.pause() + assert isinstance(app.screen, AddEntryModal) + + await pilot.press("escape") + await pilot.pause() + caps_lock_n = list(HostsXTermParser().feed("\x1b[110;65;78u"))[0] + app.post_message(caps_lock_n) + await pilot.pause() + assert not isinstance(app.screen, AddEntryModal) + + @pytest.mark.asyncio async def test_new_from_selected_requires_a_visible_host_entry(): """Shift+N does not reuse a stale selection when filters hide every entry."""