diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 937b5df6ff7d4c..0c8ef4f6c7b564 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -70,6 +70,8 @@ def __init__(self, message: str) -> None: FIONREAD = getattr(termios, "FIONREAD", None) TIOCGWINSZ = getattr(termios, "TIOCGWINSZ", None) +KITTY_KEYBOARD_FLAGS = 0b1 | 0b10 | 0b100 | 0b1000 | 0b10000 + # ------------ start of baudrate definitions ------------ # Add (possibly) missing baudrates (check termios man page) to termios @@ -377,12 +379,14 @@ def prepare(self): pass self.__enable_bracketed_paste() + self.__enable_kitty_keyboard() def restore(self): """ Restore the console to the default state """ self.__disable_bracketed_paste() + self.__disable_kitty_keyboard() self.__maybe_write_code(self._rmkx) self.flushoutput() self.__input_fd_set(self.__svtermstate) @@ -598,6 +602,12 @@ def __enable_bracketed_paste(self) -> None: def __disable_bracketed_paste(self) -> None: os.write(self.output_fd, b"\x1b[?2004l") + def __enable_kitty_keyboard(self) -> None: + os.write(self.output_fd, f"\x1b[={KITTY_KEYBOARD_FLAGS};1u".encode("ascii")) + + def __disable_kitty_keyboard(self) -> None: + os.write(self.output_fd, b"\x1b[ dict[bytes, str]: """ Generates a dictionary mapping terminal keycodes to human-readable names. @@ -75,3 +167,199 @@ def __init__(self, fd: int, encoding: str, ti: TermInfo) -> None: backspace = tcgetattr(fd)[6][VERASE] keycodes[backspace] = "backspace" BaseEventQueue.__init__(self, encoding, keycodes) + self._escape_buffer = bytearray() + + def push(self, char: int | bytes) -> None: + assert isinstance(char, (int, bytes)) + ord_char = char if isinstance(char, int) else ord(char) + + if self.keymap is not self.compiled_keymap: + super().push(ord_char) + return + + if self._escape_buffer: + self._escape_buffer.append(ord_char) + self._process_escape_buffer() + return + + if ord_char == 0x1b: + self._escape_buffer.append(ord_char) + return + + super().push(ord_char) + + def _process_escape_buffer(self) -> None: + if len(self._escape_buffer) < 2: + return + + if self._escape_buffer[1] != ord("["): + self._flush_escape_buffer() + return + + final = self._escape_buffer[-1] + if len(self._escape_buffer) > 2 and 0x40 <= final <= 0x7E: + seq = bytes(self._escape_buffer) + self._escape_buffer.clear() + if final in KITTY_CSI_FINAL_BYTES and self._handle_kitty_sequence(seq): + return + self._push_bytes(seq) + + def _flush_escape_buffer(self) -> None: + if self._escape_buffer: + self._push_bytes(bytes(self._escape_buffer)) + self._escape_buffer.clear() + + def _push_bytes(self, data: bytes) -> None: + for byte in data: + super().push(byte) + + def _handle_kitty_sequence(self, seq: bytes) -> bool: + params = seq[2:-1].decode("ascii", "strict") + final = chr(seq[-1]) + + try: + if final == "u": + event = self._parse_kitty_u(params) + elif final == "~": + event = self._parse_kitty_tilde(params) + else: + event = self._parse_kitty_letter(params, final) + except ValueError: + return False + + if event is None: + return False + return self._emit_kitty_event(event, seq) + + def _parse_kitty_u(self, params: str) -> _KittyKeyEvent | None: + fields = params.split(";") + if not fields or not fields[0]: + raise ValueError + + key_fields = fields[0].split(":") + key_code = int(key_fields[0]) + shifted_key = int(key_fields[1]) if len(key_fields) > 1 and key_fields[1] else None + base_layout_key = int(key_fields[2]) if len(key_fields) > 2 and key_fields[2] else None + + modifiers = 0 + event_type = KITTY_EVENT_PRESS + if len(fields) > 1 and fields[1]: + modifier_fields = fields[1].split(":") + modifiers = self._decode_kitty_modifiers(modifier_fields[0]) + if len(modifier_fields) > 1 and modifier_fields[1]: + event_type = int(modifier_fields[1]) + + text = "" + if len(fields) > 2 and fields[2]: + text = "".join(chr(int(cp)) for cp in fields[2].split(":") if cp) + + key_name = KITTY_FUNCTIONAL_U_KEYS.get(key_code) + if key_name is None and key_code in KITTY_IGNORED_FUNCTIONAL_U_KEYS: + key_name = "" + + return _KittyKeyEvent( + key_code=key_code, + shifted_key=shifted_key, + base_layout_key=base_layout_key, + text=text, + modifiers=modifiers, + event_type=event_type, + key_name=key_name, + ) + + def _parse_kitty_tilde(self, params: str) -> _KittyKeyEvent | None: + fields = params.split(";") + if not fields or not fields[0]: + raise ValueError + key_code = int(fields[0]) + key_name = KITTY_FUNCTIONAL_TILDE_KEYS.get(key_code) + if key_name is None: + return None + modifiers = self._decode_kitty_modifiers(fields[1]) if len(fields) > 1 and fields[1] else 0 + return _KittyKeyEvent(key_code=key_code, modifiers=modifiers, key_name=key_name) + + def _parse_kitty_letter(self, params: str, final: str) -> _KittyKeyEvent | None: + key_name = KITTY_FUNCTIONAL_LETTER_KEYS.get(final) + if key_name is None: + return None + modifiers = 0 + if params: + fields = params.split(";") + if fields[0] not in ("", "1"): + return None + if len(fields) > 1 and fields[1]: + modifiers = self._decode_kitty_modifiers(fields[1]) + return _KittyKeyEvent(key_code=1, modifiers=modifiers, key_name=key_name) + + def _decode_kitty_modifiers(self, value: str) -> int: + modifier_value = int(value) + return max(modifier_value - 1, 0) + + def _emit_kitty_event(self, event: _KittyKeyEvent, raw: bytes) -> bool: + if event.event_type == KITTY_EVENT_RELEASE: + return True + + keys = self._translate_kitty_event(event) + for key in keys: + self.insert(Event("key", key, raw)) + return True + + def _translate_kitty_event(self, event: _KittyKeyEvent) -> list[str]: + if event.key_name == "": + return [] + + modifiers = event.modifiers + key_name = event.key_name + + if key_name in {"caps_lock", "scroll_lock", "num_lock"}: + return [] + + if key_name is not None: + if modifiers & KITTY_MOD_ALT: + return self._prefix_alt([key_name]) + ctrl_key = self._maybe_ctrl_special_key(key_name, modifiers) + return [ctrl_key] if ctrl_key is not None else [key_name] + + text = event.text + if not text: + text = self._kitty_key_text(event) + if not text: + return [] + + if modifiers & KITTY_MOD_CTRL: + text = self._apply_ctrl_to_text(text, event) + if modifiers & KITTY_MOD_ALT: + return self._prefix_alt(list(text)) + return list(text) + + def _maybe_ctrl_special_key(self, key_name: str, modifiers: int) -> str | None: + if not modifiers & KITTY_MOD_CTRL: + return None + if key_name in {"left", "right"}: + return f"ctrl {key_name}" + return None + + def _kitty_key_text(self, event: _KittyKeyEvent) -> str: + codepoint = event.base_layout_key or event.key_code + if event.modifiers & KITTY_MOD_SHIFT and event.shifted_key is not None: + codepoint = event.shifted_key + try: + return chr(codepoint) + except ValueError: + return "" + + def _apply_ctrl_to_text(self, text: str, event: _KittyKeyEvent) -> str: + codepoint = event.base_layout_key or event.key_code + if codepoint in KITTY_CTRL_KEY_OVERRIDES: + return chr(KITTY_CTRL_KEY_OVERRIDES[codepoint]) + + if not text: + return "" + + char = text[0] + if "a" <= char <= "z" or "A" <= char <= "Z": + return chr(ord(char.upper()) & 0x1F) + return char + + def _prefix_alt(self, keys: list[str]) -> list[str]: + return ["\033", *keys] diff --git a/Lib/test/test_pyrepl/test_eventqueue.py b/Lib/test/test_pyrepl/test_eventqueue.py index 69d9612b70dc77..55f26b5d228567 100644 --- a/Lib/test/test_pyrepl/test_eventqueue.py +++ b/Lib/test/test_pyrepl/test_eventqueue.py @@ -196,3 +196,72 @@ def make_eventqueue(self) -> base_eventqueue.BaseEventQueue: class TestWindowsEventQueue(EventQueueTestBase, unittest.TestCase): def make_eventqueue(self) -> base_eventqueue.BaseEventQueue: return windows_eventqueue.EventQueue("utf-8") + + +@unittest.skipIf(support.MS_WINDOWS, "Unix-only kitty parser tests") +class TestUnixKittyEventQueue(unittest.TestCase): + def make_eventqueue(self) -> base_eventqueue.BaseEventQueue: + ti = EmptyTermInfo("ansi") + return unix_eventqueue.EventQueue(self.file.fileno(), "utf-8", ti) + + def setUp(self): + self.file = tempfile.TemporaryFile() + + def tearDown(self) -> None: + self.file.close() + + def push_sequence(self, sequence: bytes) -> list[Event]: + eq = self.make_eventqueue() + for byte in sequence: + eq.push(byte) + events = [] + while not eq.empty(): + events.append(eq.get()) + return events + + def assert_events(self, sequence: bytes, expected: list[str]) -> None: + events = self.push_sequence(sequence) + self.assertEqual([event.data for event in events], expected) + + def test_kitty_unicode_key(self): + self.assert_events(b"\x1b[97u", ["a"]) + + def test_kitty_shifted_text(self): + self.assert_events(b"\x1b[97;2;65u", ["A"]) + + def test_kitty_ctrl_c(self): + self.assert_events(b"\x1b[99;5u", ["\x03"]) + + def test_kitty_ctrl_h(self): + self.assert_events(b"\x1b[104;5u", ["\x08"]) + + def test_kitty_alt_x(self): + self.assert_events(b"\x1b[120;3u", ["\033", "x"]) + + def test_kitty_alt_backspace(self): + self.assert_events(b"\x1b[127;3u", ["\033", "backspace"]) + + def test_kitty_ctrl_left(self): + self.assert_events(b"\x1b[1;5D", ["ctrl left"]) + + def test_kitty_functional_tilde(self): + self.assert_events(b"\x1b[15~", ["f5"]) + + def test_kitty_functional_unicode(self): + self.assert_events(b"\x1b[57383u", ["f20"]) + + def test_kitty_alternate_base_layout_for_ctrl(self): + self.assert_events(b"\x1b[1091::99;5u", ["\x03"]) + + def test_kitty_repeat_is_press(self): + self.assert_events(b"\x1b[99;5:2u", ["\x03"]) + + def test_kitty_release_is_ignored(self): + self.assert_events(b"\x1b[99;5:3u", []) + + def test_kitty_malformed_sequence_falls_back(self): + self.assert_events(b"\x1b[xyz", ["\033", "[", "x", "y", "z"]) + + def test_kitty_sequence_then_legacy_sequence(self): + events = self.push_sequence(b"\x1b[99;5u\x1b[A") + self.assertEqual([event.data for event in events], ["\x03", "up"]) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 35a1733787e7a2..b06463d956106e 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -2004,6 +2004,7 @@ def test_no_newline(self): # Modern extensions not in standard terminfo - always use patterns safe_patterns.append(r'\x1b\[\?2004[hl]') # bracketed paste mode + safe_patterns.append(r'\x1b\[[=<](?:\d+(?:;\d+)*)?u') # kitty keyboard protocol safe_patterns.append(r'\x1b\[\?12[hl]') # cursor blinking (may be separate) safe_patterns.append(r'\x1b\[\?[01]c') # device attributes diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 680adbc2d968f0..ac5bffa1658a20 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -102,6 +102,18 @@ def unix_console(events, **kwargs): @patch("os.write") @force_not_colorized_test_class class TestConsole(TestCase): + def test_prepare_enables_kitty_keyboard(self, _os_write): + console = UnixConsole(term="xterm") + console.prepare() + _os_write.assert_any_call(ANY, b"\x1b[=31;1u") + console.restore() + + def test_restore_disables_kitty_keyboard(self, _os_write): + console = UnixConsole(term="xterm") + console.prepare() + console.restore() + _os_write.assert_any_call(ANY, b"\x1b[