From 320ccb16bbd12bde49297e7f6b7f908d49f04440 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 16 May 2026 16:38:45 +0700 Subject: [PATCH 1/8] enhanced kitty --- src/textual/_xterm_parser.py | 43 +++++++++++++++++------------ src/textual/drivers/linux_driver.py | 6 +++- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 9d5cbc8544..e6d31da678 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -37,8 +37,11 @@ SPECIAL_SEQUENCES = {BRACKETED_PASTE_START, BRACKETED_PASTE_END, FOCUSIN, FOCUSOUT} """Set of special sequences.""" +# _re_extended_key: Final[re.Pattern[str]] = re.compile( +# r"\x1b\[(?:(\d+)(?:;(\d+))?)?([u~ABCDEFHPQRS])" +# ) _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" @@ -339,35 +342,39 @@ def _sequence_to_key_events( """ if (match := _re_extended_key.fullmatch(sequence)) is not None: - number, modifiers, end = match.groups(default="") - number = number or "1" + codes, end = match.groups(default="") + number_ordinal, modifiers_ordinal, text_ordinal = ( + codes.split(";") + [""] * 3 + )[:3] - 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) + number = int(number_ordinal or "1") + modifiers = int(modifiers_ordinal or "0") + text = chr(int(text_ordinal or "0")) if text_ordinal else None + + if key := FUNCTIONAL_KEYS.get(f"{number}{end}", ""): + text = None + else: + key = ( + _character_to_key(text) if text else _character_to_key(chr(number)) + ) 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") + MODIFIERS = ("alt", "ctrl", "super", "hyper", "meta") # Ignore caps_lock and num_lock modifiers - for bit, modifier in enumerate(MODIFIERS): + if modifier_bits & 1: + key_tokens.append("shift") + for bit, modifier in enumerate(MODIFIERS, 1): 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 key is not None: + key_tokens.append(key.lower()) + yield events.Key("+".join(key_tokens), text) return keys = ANSI_SEQUENCES_KEYS.get(sequence) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 8d414063a6..de01998987 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -283,7 +283,11 @@ def on_terminal_resize(signum, stack) -> None: 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 + 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() From 85f22e4628db4f1d98118ae5b3b9792111954188 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 16 May 2026 18:55:07 +0700 Subject: [PATCH 2/8] check with casefold --- src/textual/_xterm_parser.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index e6d31da678..6f6d608c85 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -343,20 +343,14 @@ def _sequence_to_key_events( if (match := _re_extended_key.fullmatch(sequence)) is not None: codes, end = match.groups(default="") - number_ordinal, modifiers_ordinal, text_ordinal = ( - codes.split(";") + [""] * 3 - )[:3] + codepoint_str, modifiers_str, text_str, *_ = codes.split(";") + ["", "", ""] - number = int(number_ordinal or "1") - modifiers = int(modifiers_ordinal or "0") - text = chr(int(text_ordinal or "0")) if text_ordinal else None + codepoint = int(codepoint_str or "1") + modifiers = int(modifiers_str or "0") + text = chr(int(text_str)) if text_str else None - if key := FUNCTIONAL_KEYS.get(f"{number}{end}", ""): - text = None - else: - key = ( - _character_to_key(text) if text else _character_to_key(chr(number)) - ) + 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 @@ -365,7 +359,11 @@ def _sequence_to_key_events( # 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: + # If the shift changes the case, then we want "shift+" in the key + # If the key does not simply change the case (i.e. shift+1) we do *not* want the modifier + if modifier_bits & 1 and ( + text is None or chr(codepoint).casefold() == text.casefold() + ): key_tokens.append("shift") for bit, modifier in enumerate(MODIFIERS, 1): if modifier_bits & (1 << bit): From 66e6ee0c04b1e0aa6b1c0ba40dddf9fbcfc79ae1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 May 2026 10:36:41 +0700 Subject: [PATCH 3/8] tidy --- src/textual/_xterm_parser.py | 31 ++++++++++++++++++++----------- tests/test_xterm_parser.py | 4 ++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 6f6d608c85..52314a8c68 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -37,9 +37,6 @@ SPECIAL_SEQUENCES = {BRACKETED_PASTE_START, BRACKETED_PASTE_END, FOCUSIN, FOCUSOUT} """Set of special sequences.""" -# _re_extended_key: Final[re.Pattern[str]] = re.compile( -# r"\x1b\[(?:(\d+)(?:;(\d+))?)?([u~ABCDEFHPQRS])" -# ) _re_extended_key: Final[re.Pattern[str]] = re.compile( r"\x1b\[((?:\d*;?){2,3})([u~ABCDEFHPQRS])" ) @@ -53,6 +50,12 @@ or os.environ.get("TERM_PROGRAM", "") == "iTerm.app" ) +SPECIAL_KEY_TO_CHARACTER: Final = { + "backspace": "\x7f", + "enter": "\r", +} +"""Explcit characters for keys, used in Kitty protocol parsing""" + class XTermParser(Parser[Message]): _re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(-?\d+);(-?\d+)([Mm])") @@ -354,25 +357,31 @@ def _sequence_to_key_events( key_tokens: list[str] = [] # The modifier is redundant on a modifier key - if modifiers and key not in MODIFIER_FUNCTIONAL_KEYS: + 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 the shift changes the case, then we want "shift+" in the key - # If the key does not simply change the case (i.e. shift+1) we do *not* want the modifier - if modifier_bits & 1 and ( - text is None or chr(codepoint).casefold() == text.casefold() - ): + 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.lower()) - yield events.Key("+".join(key_tokens), text) + key_tokens.append( + key if (text is not None and len(text) == 1) else key.lower() + ) + yield events.Key( + "+".join(key_tokens), text or SPECIAL_KEY_TO_CHARACTER.get(key, None) + ) return keys = ANSI_SEQUENCES_KEYS.get(sequence) diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index fdeaf9c0c8..9cedb3c594 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -186,6 +186,8 @@ 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"), @@ -194,6 +196,8 @@ def test_double_escape(parser): ("\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: From 20fc8894fc0506614b94614118a846c55036a433 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 May 2026 10:41:01 +0700 Subject: [PATCH 4/8] logic not not required --- src/textual/_xterm_parser.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 52314a8c68..16dc08de03 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -376,9 +376,7 @@ def _sequence_to_key_events( key_tokens.sort() if key is not None: - key_tokens.append( - key if (text is not None and len(text) == 1) else key.lower() - ) + key_tokens.append(key) yield events.Key( "+".join(key_tokens), text or SPECIAL_KEY_TO_CHARACTER.get(key, None) ) From f5453fbec7fe27be8acb072da29be6664379aebb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 May 2026 10:43:33 +0700 Subject: [PATCH 5/8] another edge case --- src/textual/_xterm_parser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 16dc08de03..92cd3dc90c 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -378,7 +378,9 @@ def _sequence_to_key_events( if key is not None: key_tokens.append(key) yield events.Key( - "+".join(key_tokens), text or SPECIAL_KEY_TO_CHARACTER.get(key, None) + "+".join(key_tokens), + text + or (None if modifiers else SPECIAL_KEY_TO_CHARACTER.get(key, None)), ) return From 79526f7a3aa7ac4c74ef341184077de032f7d8d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 May 2026 11:00:28 +0700 Subject: [PATCH 6/8] cache key processing --- CHANGELOG.md | 1 + src/textual/_xterm_parser.py | 92 +++++++++++++++++------------ src/textual/constants.py | 3 + src/textual/drivers/linux_driver.py | 17 +++--- 4 files changed, 67 insertions(+), 46 deletions(-) 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 92cd3dc90c..4f4d8cde1d 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 @@ -53,6 +54,7 @@ SPECIAL_KEY_TO_CHARACTER: Final = { "backspace": "\x7f", "enter": "\r", + "tab": "\t", } """Explcit characters for keys, used in Kitty protocol parsing""" @@ -332,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]: @@ -344,44 +393,11 @@ def _sequence_to_key_events( Iterable of key events. """ - if (match := _re_extended_key.fullmatch(sequence)) is not 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) - yield events.Key( - "+".join(key_tokens), - text - or (None if modifiers else SPECIAL_KEY_TO_CHARACTER.get(key, None)), - ) + if ( + not constants.DISABLE_KITTY_KEY + and (key := self._parse_extended_key(sequence)) is not None + ): + yield key 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 de01998987..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,13 +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 - | KITTY_REPORT_ASSOCIATED_TEXT - ) - 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") From 4b16257105421fea11a97c17d1147839f0be1188 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 May 2026 11:04:40 +0700 Subject: [PATCH 7/8] test fix --- tests/test_xterm_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index 9cedb3c594..b4cba54956 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -190,7 +190,7 @@ def test_double_escape(parser): ("\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"), From 71ae5f5fb5de118aa36d00a47667ceb5ff3878c8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 17 May 2026 11:21:21 +0700 Subject: [PATCH 8/8] Return copy of cache, move aliases to property --- src/textual/_xterm_parser.py | 2 +- src/textual/events.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 4f4d8cde1d..eafa7414ec 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -397,7 +397,7 @@ def _sequence_to_key_events( not constants.DISABLE_KITTY_KEY and (key := self._parse_extended_key(sequence)) is not None ): - yield key + yield key.copy() return keys = ANSI_SEQUENCES_KEYS.get(sequence) 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."""