Skip to content

Commit

Permalink
Merge pull request #4631 from Textualize/keys
Browse files Browse the repository at this point in the history
Keys
  • Loading branch information
willmcgugan authored Jun 9, 2024
2 parents 91c7fc7 + 889c064 commit aa988e6
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 8 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added support for Kitty's key protocol https://github.com/Textualize/textual/pull/4631

## [0.66.0] - 2024-06-08

### Changed
Expand Down
121 changes: 121 additions & 0 deletions src/textual/_keyboard_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
FUNCTIONAL_KEYS = {
"27u": "escape",
"13u": "enter",
"9u": "tab",
"127u": "backspace",
"2~": "insert",
"3~": "delete",
"1D": "left",
"1C": "right",
"1A": "up",
"1B": "down",
"5~": "page_up",
"6~": "page_down",
"1H": "home",
"7~": "home",
"1F": "end",
"8~": "end",
"57358u": "caps_lock",
"57359u": "scroll_lock",
"57360u": "num_lock",
"57361u": "print_screen",
"57362u": "pause",
"57363u": "menu",
"1P": "f1",
"11~": "f1",
"1Q": "f2",
"12~": "f2",
"13~": "f3",
"1R": "f3",
"1S": "f4",
"14~": "f4",
"15~": "f5",
"17~": "f6",
"18~": "f7",
"19~": "f8",
"20~": "f9",
"21~": "f10",
"23~": "f11",
"24~": "f12",
"57376u": "f13",
"57377u": "f14",
"57378u": "f15",
"57379u": "f16",
"57380u": "f17",
"57381u": "f18",
"57382u": "f19",
"57383u": "f20",
"57384u": "f21",
"57385u": "f22",
"57386u": "f23",
"57387u": "f24",
"57388u": "f25",
"57389u": "f26",
"57390u": "f27",
"57391u": "f28",
"57392u": "f29",
"57393u": "f30",
"57394u": "f31",
"57395u": "f32",
"57396u": "f33",
"57397u": "f34",
"57398u": "f35",
"57399u": "kp_0",
"57400u": "kp_1",
"57401u": "kp_2",
"57402u": "kp_3",
"57403u": "kp_4",
"57404u": "kp_5",
"57405u": "kp_6",
"57406u": "kp_7",
"57407u": "kp_8",
"57408u": "kp_9",
"57409u": "kp_decimal",
"57410u": "kp_divide",
"57411u": "kp_multiply",
"57412u": "kp_subtract",
"57413u": "kp_add",
"57414u": "kp_enter",
"57415u": "kp_equal",
"57416u": "kp_separator",
"57417u": "kp_left",
"57418u": "kp_right",
"57419u": "kp_up",
"57420u": "kp_down",
"57421u": "kp_page_up",
"57422u": "kp_page_down",
"57423u": "kp_home",
"57424u": "kp_end",
"57425u": "kp_insert",
"57426u": "kp_delete",
"1E": "kp_begin",
"57427~": "kp_begin",
"57428u": "media_play",
"57429u": "media_pause",
"57430u": "media_play_pause",
"57431u": "media_reverse",
"57432u": "media_stop",
"57433u": "media_fast_forward",
"57434u": "media_rewind",
"57435u": "media_track_next",
"57436u": "media_track_previous",
"57437u": "media_record",
"57438u": "lower_volume",
"57439u": "raise_volume",
"57440u": "mute_volume",
"57441u": "left_shift",
"57442u": "left_control",
"57443u": "left_alt",
"57444u": "left_super",
"57445u": "left_hyper",
"57446u": "left_meta",
"57447u": "right_shift",
"57448u": "right_control",
"57449u": "right_alt",
"57450u": "right_super",
"57451u": "right_hyper",
"57452u": "right_meta",
"57453u": "iso_level3_shift",
"57454u": "iso_level5_shift",
}
46 changes: 41 additions & 5 deletions src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from __future__ import annotations

import re
import unicodedata
from typing import Any, Callable, Generator, Iterable

from typing_extensions import Final

from . import events, messages
from ._ansi_sequences import ANSI_SEQUENCES_KEYS, IGNORE_SEQUENCE
from ._keyboard_protocol import FUNCTIONAL_KEYS
from ._parser import Awaitable, Parser, TokenCallback
from .keys import KEY_NAME_REPLACEMENTS, Keys, _character_to_key

Expand All @@ -32,6 +32,8 @@
FOCUSOUT: Final[str] = "\x1b[O"
"""Sequence received when focus is lost from the terminal."""

_re_extended_key: Final = re.compile(r"\x1b\[(?:(\d+)(?:;(\d+))?)?([u~ABCDEFHPQRS])")


class XTermParser(Parser[events.Event]):
_re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
Expand Down Expand Up @@ -280,9 +282,11 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None:
for event in sequence_to_key_events(character):
on_key_token(event)

def _sequence_to_key_events(
self, sequence: str, _unicode_name=unicodedata.name
) -> Iterable[events.Key]:
if self._debug_log_file is not None:
self._debug_log_file.close()
self._debug_log_file = None

def _sequence_to_key_events(self, sequence: str) -> Iterable[events.Key]:
"""Map a sequence of code points on to a sequence of keys.
Args:
Expand All @@ -291,6 +295,37 @@ def _sequence_to_key_events(
Returns:
Keys
"""

if (match := _re_extended_key.match(sequence)) is not None:
number, modifiers, end = match.groups()
number = number or 1
if not (key := FUNCTIONAL_KEYS.get(f"{number}{end}", "")):
try:
key = _character_to_key(chr(int(number)))
except Exception:
key = chr(int(number))
key_tokens: list[str] = []
if modifiers:
modifier_bits = int(modifiers) - 1
MODIFIERS = (
"shift",
"alt",
"ctrl",
"hyper",
"meta",
"caps_lock",
"num_lock",
)
for bit, modifier in zip(range(8), MODIFIERS):
if modifier_bits & (1 << bit):
key_tokens.append(modifier)
key_tokens.sort()
key_tokens.append(key)
yield events.Key(
f'{"+".join(key_tokens)}', sequence if len(sequence) == 1 else None
)
return

keys = ANSI_SEQUENCES_KEYS.get(sequence)
# If we're being asked to ignore the key...
if keys is IGNORE_SEQUENCE:
Expand All @@ -299,6 +334,7 @@ def _sequence_to_key_events(
# to is the ignore key) and the sequence that was ignored as
# the character.
yield events.Key(Keys.Ignore, sequence)
return
if isinstance(keys, tuple):
# If the sequence mapped to a tuple, then it's values from the
# `Keys` enum. Raise key events from what we find in the tuple.
Expand All @@ -320,5 +356,5 @@ def _sequence_to_key_events(
name = sequence
name = KEY_NAME_REPLACEMENTS.get(name, name)
yield events.Key(name, sequence)
except:
except Exception:
yield events.Key(sequence, sequence)
12 changes: 9 additions & 3 deletions src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ def on_terminal_resize(signum, stack) -> None:
termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)

self.write("\x1b[?25l") # Hide cursor
self.write("\033[?1004h") # Enable FocusIn/FocusOut.
self.write("\x1b[?1004h") # Enable FocusIn/FocusOut.
self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/
self.flush()
self._key_thread = Thread(target=self._run_input_thread)
send_size_event()
Expand Down Expand Up @@ -327,6 +328,9 @@ def stop_application_mode(self) -> None:
# Alt screen false, show cursor
self.write("\x1b[?1049l" + "\x1b[?25h")
self.write("\033[?1004l") # Disable FocusIn/FocusOut.
self.write(
"\x1b[<u"
) # Disable https://sw.kovidgoyal.net/kitty/keyboard-protocol/
self.flush()

def close(self) -> None:
Expand Down Expand Up @@ -360,8 +364,8 @@ def run_input_thread(self) -> None:
def more_data() -> bool:
"""Check if there is more data to parse."""

for _key, events in selector.select(0.01):
if events & EVENT_READ:
for _key, selector_events in selector.select(0.1):
if selector_events & EVENT_READ:
return True
return False

Expand All @@ -384,3 +388,5 @@ def more_data() -> bool:
self.process_event(event)
finally:
selector.close()
for event in feed(""):
pass
1 change: 1 addition & 0 deletions tests/test_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ def test_mouse_event_detected_but_info_not_parsed(parser):
assert len(events) == 0


@pytest.mark.xfail()
def test_escape_sequence_resulting_in_multiple_keypresses(parser):
"""Some sequences are interpreted as more than 1 keypress"""
events = list(parser.feed("\x1b[2;4~"))
Expand Down

0 comments on commit aa988e6

Please sign in to comment.