Fix keyboard shortcuts for shifted punctuation and letters
- Preserve Shift+R semantics for DNS refresh - Resolve shifted punctuation by produced text across layouts - Expand keyboard binding coverage tests
This commit is contained in:
parent
9040cf72a8
commit
d4130c332a
4 changed files with 124 additions and 2 deletions
|
|
@ -67,7 +67,7 @@ HOSTS_MANAGER_BINDINGS = [
|
||||||
Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"),
|
Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"),
|
||||||
Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"),
|
Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"),
|
||||||
Binding(
|
Binding(
|
||||||
"R",
|
"shift+r,R",
|
||||||
"refresh_dns",
|
"refresh_dns",
|
||||||
"Update all DNS based Entries",
|
"Update all DNS based Entries",
|
||||||
show=False,
|
show=False,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,17 @@ class HostsXTermParser(XTermParser):
|
||||||
if key is None:
|
if key is None:
|
||||||
key = _character_to_key(chr(codepoint) if codepoint else text or "")
|
key = _character_to_key(chr(codepoint) if codepoint else text or "")
|
||||||
|
|
||||||
|
# Shifted punctuation is a semantic text key rather than a
|
||||||
|
# portable physical-key shortcut. For example, ``?`` is Shift+/
|
||||||
|
# on a US layout and Shift+ß on a German layout. Keep modifiers
|
||||||
|
# for letters, where Shift+N must remain distinct from Caps Lock
|
||||||
|
# N, but bind punctuation by the text it produced.
|
||||||
|
use_text_key = (
|
||||||
|
text is not None and not text.isalpha() and not text.isspace()
|
||||||
|
)
|
||||||
|
if use_text_key:
|
||||||
|
key = _character_to_key(text)
|
||||||
|
|
||||||
key_tokens: list[str] = []
|
key_tokens: list[str] = []
|
||||||
if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS:
|
if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS:
|
||||||
modifier_bits = modifiers - 1
|
modifier_bits = modifiers - 1
|
||||||
|
|
@ -52,6 +63,12 @@ class HostsXTermParser(XTermParser):
|
||||||
)
|
)
|
||||||
for bit, modifier in enumerate(modifiers_by_bit):
|
for bit, modifier in enumerate(modifiers_by_bit):
|
||||||
if modifier_bits & (1 << bit):
|
if modifier_bits & (1 << bit):
|
||||||
|
if use_text_key and modifier in {
|
||||||
|
"shift",
|
||||||
|
"caps_lock",
|
||||||
|
"num_lock",
|
||||||
|
}:
|
||||||
|
continue
|
||||||
key_tokens.append(modifier)
|
key_tokens.append(modifier)
|
||||||
|
|
||||||
key_tokens.append(key)
|
key_tokens.append(key)
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,74 @@ def test_enhanced_caps_lock_n_does_not_become_shift_n():
|
||||||
assert event.character == "N"
|
assert event.character == "N"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sequence",
|
||||||
|
(
|
||||||
|
"\x1b[47;2;63u", # US: Shift+/
|
||||||
|
"\x1b[223;2;63u", # German: Shift+ß
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_enhanced_question_mark_uses_the_produced_text_key(sequence):
|
||||||
|
"""Shifted punctuation binds by its text across keyboard layouts."""
|
||||||
|
event = list(HostsXTermParser().feed(sequence))[0]
|
||||||
|
|
||||||
|
assert isinstance(event, Key)
|
||||||
|
assert event.key == "question_mark"
|
||||||
|
assert event.character == "?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_protocol_events_match_all_character_and_modifier_bindings():
|
||||||
|
"""Terminal key events resolve to every app-level shortcut action."""
|
||||||
|
sequences_and_actions = {
|
||||||
|
"\x1b[110u": "add_entry",
|
||||||
|
"\x1b[110;2;78u": "new_from_selected",
|
||||||
|
"N": "new_from_selected",
|
||||||
|
"\x1b[100u": "delete_entry",
|
||||||
|
"\x1b[101u": "edit_entry",
|
||||||
|
"\x1b[32u": "toggle_entry",
|
||||||
|
"\x1b[101;5u": "toggle_edit_mode",
|
||||||
|
"\x1b[99u": "config",
|
||||||
|
"\x1b[120;5u": "export_entries",
|
||||||
|
"\x1b[111;5u": "import_entries",
|
||||||
|
"\x1b[102;5u": "show_filters",
|
||||||
|
"\x1b[47;2;63u": "help",
|
||||||
|
"?": "help",
|
||||||
|
"\x1b[113u": "quit",
|
||||||
|
"\x1b[114;5u": "reload",
|
||||||
|
"\x1b[105u": "sort_by_ip",
|
||||||
|
"\x1b[104u": "sort_by_hostname",
|
||||||
|
"\x1b[115;5u": "save_file",
|
||||||
|
"\x1b[98u": "restore_backup",
|
||||||
|
"\x1b[1;2A": "move_entry_up",
|
||||||
|
"\x1b[1;2B": "move_entry_down",
|
||||||
|
"\x1b[122;5u": "undo",
|
||||||
|
"\x1b[121;5u": "redo",
|
||||||
|
"\x1b[114;2;82u": "refresh_dns",
|
||||||
|
"R": "refresh_dns",
|
||||||
|
"\x1b[114u": "update_single_dns",
|
||||||
|
"\x1b[27u": "exit_edit_entry",
|
||||||
|
"\t": "next_field",
|
||||||
|
"\x1b[Z": "prev_field",
|
||||||
|
"\x1b[9;2;9u": "prev_field",
|
||||||
|
"\x1b[99;5u": "quit",
|
||||||
|
}
|
||||||
|
actions_by_key = {
|
||||||
|
key: binding.action if hasattr(binding, "action") else binding[1]
|
||||||
|
for binding in HostsManagerApp.BINDINGS
|
||||||
|
for key in str(binding.key if hasattr(binding, "key") else binding[0]).split(
|
||||||
|
","
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for sequence, expected_action in sequences_and_actions.items():
|
||||||
|
event = next(
|
||||||
|
event
|
||||||
|
for event in HostsXTermParser().feed(sequence)
|
||||||
|
if isinstance(event, Key)
|
||||||
|
)
|
||||||
|
assert actions_by_key[event.key] == expected_action
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_enhanced_shift_n_opens_new_from_selected_but_caps_lock_n_does_not():
|
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."""
|
"""The real enhanced-key events distinguish Shift from Caps Lock at the app."""
|
||||||
|
|
@ -234,6 +302,43 @@ async def test_new_from_selected_is_available_at_the_minimum_viewport():
|
||||||
assert not isinstance(app.screen, AddEntryModal)
|
assert not isinstance(app.screen, AddEntryModal)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sequence", ("\x1b[47;2;63u", "\x1b[223;2;63u"))
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enhanced_question_mark_shortcut_opens_and_closes_help_overlay(sequence):
|
||||||
|
"""Kitty Shift+? controls the dedicated help overlay."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
app.query_one("#entries-table").focus()
|
||||||
|
question_mark = list(HostsXTermParser().feed(sequence))[0]
|
||||||
|
app.post_message(question_mark)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert isinstance(app.screen, HelpModal)
|
||||||
|
|
||||||
|
question_mark = list(HostsXTermParser().feed(sequence))[0]
|
||||||
|
app.post_message(question_mark)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert not isinstance(app.screen, HelpModal)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enhanced_shift_r_shortcut_refreshes_all_dns_entries():
|
||||||
|
"""Kitty Shift+R invokes the documented refresh-all action."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
app.query_one("#entries-table").focus()
|
||||||
|
shift_r = list(HostsXTermParser().feed("\x1b[114;2;82u"))[0]
|
||||||
|
app.post_message(shift_r)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert "Cannot resolve DNS names" in str(
|
||||||
|
app.query_one("#message-rail", Static).render()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_help_overlay_closes_to_the_control_that_opened_it():
|
async def test_help_overlay_closes_to_the_control_that_opened_it():
|
||||||
"""Help is an overlay and returns keyboard focus to its opener."""
|
"""Help is an overlay and returns keyboard focus to its opener."""
|
||||||
|
|
|
||||||
|
|
@ -704,7 +704,7 @@ class TestHostsManagerApp:
|
||||||
for binding in app.BINDINGS:
|
for binding in app.BINDINGS:
|
||||||
if hasattr(binding, "key"):
|
if hasattr(binding, "key"):
|
||||||
# Binding object
|
# Binding object
|
||||||
binding_keys.append(binding.key)
|
binding_keys.extend(str(binding.key).split(","))
|
||||||
elif isinstance(binding, tuple) and len(binding) >= 1:
|
elif isinstance(binding, tuple) and len(binding) >= 1:
|
||||||
# Tuple format (key, action, description)
|
# Tuple format (key, action, description)
|
||||||
binding_keys.append(binding[0])
|
binding_keys.append(binding[0])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue