Fix #2: distinguish Shift+N from Caps Lock

This commit is contained in:
Philip Henning 2026-09-06 17:01:27 +02:00
parent f2e2d45480
commit 4615aed580
7 changed files with 176 additions and 5 deletions

View file

@ -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 |

View file

@ -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 |

View file

@ -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"

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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."""