diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f499be1e..f8d2133346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added support for Kitty key protocl "Report all keys as escape codes" which enabled alt+backspace on Warp - Added support for detecting separate modifier keys for terminals that support the Kitty key protocol +- Added `TEXTUAL_DISABLE_KITTY_KEY` env var to disable Kitty key protocol support (debug aid). ## [8.2.6] - 2026-04-13 diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 9d5cbc8544..eafa7414ec 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -2,6 +2,7 @@ import os import re +from functools import lru_cache from typing import Any, Generator, Iterable from typing_extensions import Final @@ -38,7 +39,7 @@ """Set of special sequences.""" _re_extended_key: Final[re.Pattern[str]] = re.compile( - r"\x1b\[(?:(\d+)(?:;(\d+))?)?([u~ABCDEFHPQRS])" + r"\x1b\[((?:\d*;?){2,3})([u~ABCDEFHPQRS])" ) _re_in_band_window_resize: Final[re.Pattern[str]] = re.compile( r"\x1b\[48;(\d+(?:\:.*?)?);(\d+(?:\:.*?)?);(\d+(?:\:.*?)?);(\d+(?:\:.*?)?)t" @@ -50,6 +51,13 @@ or os.environ.get("TERM_PROGRAM", "") == "iTerm.app" ) +SPECIAL_KEY_TO_CHARACTER: Final = { + "backspace": "\x7f", + "enter": "\r", + "tab": "\t", +} +"""Explcit characters for keys, used in Kitty protocol parsing""" + class XTermParser(Parser[Message]): _re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(-?\d+);(-?\d+)([Mm])") @@ -326,6 +334,53 @@ def send_sequence(process_alt: bool = True) -> None: self._debug_log_file.close() self._debug_log_file = None + @lru_cache(maxsize=1024) + def _parse_extended_key(self, sequence: str) -> events.Key | None: + """Parse a Kitty sequence. + + Args: + sequence: Input sequence + + Returns: + Key event, or `None` of none could be parsed. + """ + + 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") + text = chr(int(text_str)) if text_str else None + + if not (key := FUNCTIONAL_KEYS.get(f"{codepoint}{end}", "")): + key = _character_to_key(text if text else chr(codepoint)) + + key_tokens: list[str] = [] + # The modifier is redundant on a modifier key + if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS and text_str is not None: + modifier_bits = int(modifiers) - 1 + # Not convinced of the utility in reporting caps_lock and num_lock + MODIFIERS = ("alt", "ctrl", "super", "hyper", "meta") + # Ignore caps_lock and num_lock modifiers + if modifier_bits & 1 and (text is None or text.isspace()): + key_tokens.append("shift") + for bit, modifier in enumerate(MODIFIERS, 1): + if modifier == "alt" and text is not None: + continue + if modifier_bits & (1 << bit): + key_tokens.append(modifier) + + key_tokens.sort() + if key is not None: + key_tokens.append(key) + return events.Key( + "+".join(key_tokens), + text or (None if modifiers else SPECIAL_KEY_TO_CHARACTER.get(key, None)), + ) + def _sequence_to_key_events( self, sequence: str, alt: bool = False ) -> Iterable[events.Key]: @@ -338,36 +393,11 @@ def _sequence_to_key_events( Iterable of key events. """ - if (match := _re_extended_key.fullmatch(sequence)) is not None: - number, modifiers, end = match.groups(default="") - number = number or "1" - - character_sequence = sequence - if ( - not (key := FUNCTIONAL_KEYS.get(f"{number}{end}", "")) - and number.isalnum() - ): - ordinal = int(number) - character_sequence = chr(ordinal) - key = _character_to_key(character_sequence) - - key_tokens: list[str] = [] - # The modifier is redundant on a modifier key - if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS: - modifier_bits = int(modifiers) - 1 - # Not convinced of the utility in reporting caps_lock and num_lock - MODIFIERS = ("shift", "alt", "ctrl", "super", "hyper", "meta") - # Ignore caps_lock and num_lock modifiers - for bit, modifier in enumerate(MODIFIERS): - if modifier_bits & (1 << bit): - key_tokens.append(modifier) - - key_tokens.sort() - key_tokens.append(key.lower()) - yield events.Key( - "+".join(key_tokens), - character_sequence if len(character_sequence) == 1 else None, - ) + if ( + not constants.DISABLE_KITTY_KEY + and (key := self._parse_extended_key(sequence)) is not None + ): + yield key.copy() return keys = ANSI_SEQUENCES_KEYS.get(sequence) diff --git a/src/textual/constants.py b/src/textual/constants.py index feedbea6e9..a2702db11a 100644 --- a/src/textual/constants.py +++ b/src/textual/constants.py @@ -113,6 +113,9 @@ def _get_textual_animations() -> AnimationLevel: DRIVER: Final[str | None] = get_environ("TEXTUAL_DRIVER", None) """Import for replacement driver.""" +DISABLE_KITTY_KEY: Final[bool] = _get_environ_bool("TEXTUAL_DISABLE_KITTY_KEY") +"""Disable kitty key protocol.""" + FILTERS: Final[str] = get_environ("TEXTUAL_FILTERS", "") """A list of filters to apply to renderables.""" diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 8d414063a6..74ed463187 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -13,7 +13,7 @@ import rich.repr -from textual import events +from textual import constants, events from textual._loop import loop_last from textual._parser import ParseError from textual._xterm_parser import XTermParser @@ -282,9 +282,14 @@ def on_terminal_resize(signum, stack) -> None: self.write("\x1b[?25l") # Hide cursor self.write("\x1b[?1004h") # Enable FocusIn/FocusOut. - # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - KITTY_PROTOCOL_FLAG = KITTY_DISAMBIGUATE_ESCAPE_CODES | KITTY_REPORT_ALL_KEYS - self.write(f"\x1b[>{KITTY_PROTOCOL_FLAG}u") + if not constants.DISABLE_KITTY_KEY: + # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + KITTY_PROTOCOL_FLAG = ( + KITTY_DISAMBIGUATE_ESCAPE_CODES + | KITTY_REPORT_ALL_KEYS + | KITTY_REPORT_ASSOCIATED_TEXT + ) + self.write(f"\x1b[>{KITTY_PROTOCOL_FLAG}u") self.flush() self._key_thread = Thread(target=self._run_input_thread, name="textual-input") diff --git a/src/textual/events.py b/src/textual/events.py index 92e05a91a1..d3847f4ad4 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -269,7 +269,7 @@ class Key(InputEvent): character: A printable character or `None` if it is not printable. """ - __slots__ = ["key", "character", "aliases"] + __slots__ = ["key", "character"] def __init__(self, key: str, character: str | None) -> None: super().__init__() @@ -279,8 +279,6 @@ def __init__(self, key: str, character: str | None) -> None: (key if len(key) == 1 else None) if character is None else character ) """A printable character or ``None`` if it is not printable.""" - self.aliases: list[str] = _get_key_aliases(key) - """The aliases for the key, including the key itself.""" def __rich_repr__(self) -> rich.repr.Result: yield "key", self.key @@ -289,6 +287,10 @@ def __rich_repr__(self) -> rich.repr.Result: yield "is_printable", self.is_printable yield "aliases", self.aliases, [self.key] + def copy(self) -> Key: + """Get a copy of this key event.""" + return Key(self.key, self.character) + @property def name(self) -> str: """Name of a key suitable for use as a Python identifier.""" @@ -308,6 +310,11 @@ def is_printable(self) -> bool: """ return False if self.character is None else self.character.isprintable() + @property + def aliases(self) -> list[str]: + """The aliases for the key, including the key itself.""" + return _get_key_aliases(self.key) + def _key_to_identifier(key: str) -> str: """Convert the key string to a name suitable for use as a Python identifier.""" diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index fdeaf9c0c8..b4cba54956 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -186,14 +186,18 @@ def test_double_escape(parser): [ ("a", "a"), ("B", "B"), + ("\x1b[97;;97u", "a"), + ("\x1b[97;2;65u", "A"), ("\x1ba", "alt+a"), ("\x1b[97;3u", "alt+a"), - ("\x1b[65;4u", "alt+shift+a"), + ("\x1b[97;4u", "alt+shift+a"), ("\x1bA", "alt+shift+a"), ("\x1b[120;7u", "alt+ctrl+x"), ("\x1b[57443;3u", "left_alt"), ("\x1b[127;3u", "alt+backspace"), ("\x1b[27u", "escape"), + ("\x1b[32;;32u", "space"), + ("\x1b[32;2;32u", "shift+space"), ], ) def test_keys(parser, sequence: str, key: str) -> None: