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 |
|
| `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 |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
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
|
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."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue