Fix #2: distinguish Shift+N from Caps Lock
This commit is contained in:
parent
f2e2d45480
commit
4615aed580
7 changed files with 176 additions and 5 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
54
src/hosts/tui/keyboard_driver.py
Normal file
54
src/hosts/tui/keyboard_driver.py
Normal 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
|
||||
66
src/hosts/tui/keyboard_protocol.py
Normal file
66
src/hosts/tui/keyboard_protocol.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue