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 | | `Ctrl+E` | Enter or leave Privileged Mode |
| `b` | Review and restore the session Pre-edit Backup | | `b` | Review and restore the session Pre-edit Backup |
| `n` | Add a Host Entry | | `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 | | `e` | Open the selected Host Entry in the Entry Editor |
| `d` | Delete the selected Host Entry | | `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate 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: In Privileged Mode:
- Press `n` to add a Host Entry. - 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. - 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 `d` and confirm to delete the selected Host Entry.
- Press `Space` to activate or deactivate the selected Host Entry. - Press `Space` to activate or deactivate the selected Host Entry.
@ -254,7 +255,7 @@ continually retrying it.
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `n` | Add a Host Entry | | `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 | | `e` | Open the selected Host Entry in the Entry Editor |
| `d` | Delete the selected Host Entry | | `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate 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 .custom_footer import CustomFooter
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
from .keyboard_driver import HostsKeyboardDriver
from .table_handler import TableHandler from .table_handler import TableHandler
from .details_handler import DetailsHandler from .details_handler import DetailsHandler
from .edit_handler import EditHandler from .edit_handler import EditHandler
@ -75,7 +76,7 @@ class HostsManagerApp(App):
search_term: reactive[str] = reactive("") search_term: reactive[str] = reactive("")
def __init__(self): def __init__(self):
super().__init__() super().__init__(driver_class=HostsKeyboardDriver)
self.title = "/etc/hosts Manager" self.title = "/etc/hosts Manager"
self.register_theme(HOSTS_DARK_THEME) self.register_theme(HOSTS_DARK_THEME)
self.theme = "hosts-dark" self.theme = "hosts-dark"

View file

@ -51,7 +51,7 @@ class HelpModal(ModalScreen[None]):
yield Static("Privileged Mode", classes="help-section") yield Static("Privileged Mode", classes="help-section")
yield Static( yield Static(
"Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n" "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", classes="help-copy",
) )
yield Button("Close", id="help-close", variant="primary") 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 import pytest
from rich.cells import cell_len from rich.cells import cell_len
from textual.app import SuspendNotSupported from textual.app import SuspendNotSupported
from textual.events import Key
from textual.widgets import Button, Input, RadioButton, Static from textual.widgets import Button, Input, RadioButton, Static
from src.hosts.core.filters import FilterOptions 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.filter_modal import FilterModal
from src.hosts.tui.help_modal import HelpModal from src.hosts.tui.help_modal import HelpModal
from src.hosts.tui.add_entry_modal import AddEntryModal 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 ( from src.hosts.tui.privilege_prompt import (
render_sudo_authentication_notice, render_sudo_authentication_notice,
sudo_authentication_screen, sudo_authentication_screen,
@ -49,6 +52,11 @@ def app_with_filterable_entries() -> HostsManagerApp:
return app 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 @pytest.mark.asyncio
async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter(): 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.""" """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 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 @pytest.mark.asyncio
async def test_new_from_selected_requires_a_visible_host_entry(): async def test_new_from_selected_requires_a_visible_host_entry():
"""Shift+N does not reuse a stale selection when filters hide every entry.""" """Shift+N does not reuse a stale selection when filters hide every entry."""