Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
92 changes: 61 additions & 31 deletions src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import re
from functools import lru_cache
from typing import Any, Generator, Iterable

from typing_extensions import Final
Expand Down Expand Up @@ -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"
Expand All @@ -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])")
Expand Down Expand Up @@ -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]:
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/textual/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
13 changes: 9 additions & 4 deletions src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 10 additions & 3 deletions src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand All @@ -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
Expand All @@ -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."""
Expand All @@ -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."""
Expand Down
6 changes: 5 additions & 1 deletion tests/test_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading