Skip to content
Open
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 @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed

- Fixed `anchor` with `ScrollView` widgets https://github.com/Textualize/textual/pull/6228
- Fixed Alt-modified key presses on Windows terminals without Kitty keyboard protocol support

## [6.6.0] - 2025-11-10

Expand Down
15 changes: 14 additions & 1 deletion docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,19 @@ recommend picking keys and key combinations from the above.
Keys that aren't normally passed through by terminals include Cmd and Option
on macOS, and the Windows key on Windows.

!!! note "Windows and Alt-modified keys"

On Windows terminals that don't support the [Kitty keyboard
protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) (for example,
Windows Terminal), Textual emulates Alt-modified keys by watching for
``ESC`` followed by another key. This covers most ``alt+letter`` and
``alt+shift+letter`` combinations, but it can't disambiguate cases where
the terminal already turned the input into a composed character (e.g.
``Ctrl+Alt`` behaving as AltGr) or where the original modifiers are lost.
If you need every modifier combination (especially `ctrl+alt`, `ctrl+shift`
and AltGr), use a terminal with Kitty keyboard protocol support such as
WezTerm, kitty, or Alacritty.

If you need to test what [key
combinations](https://textual.textualize.io/guide/input/#keyboard-input)
work in different environments you can try them out with `textual keys`.
Expand Down Expand Up @@ -297,7 +310,7 @@ We recommend any of the following terminals:

Textual will not generate escape sequences for the 16 themeable *ANSI* colors.

This is an intentional design decision we took for for the following reasons:
This is an intentional design decision we took for the following reasons:

- Not everyone has a carefully chosen ANSI color theme. Color combinations which may look fine on your system, may be unreadable on another machine. There is very little an app author or Textual can do to resolve this. Asking users to simply pick a better theme is not a good solution, since not all users will know how.
- ANSI colors can't be manipulated in the way Textual can do with other colors. Textual can blend colors and produce light and dark shades from an original color, which is used to create more readable text and user interfaces. Color blending will also be used to power future accessibility features.
Expand Down
13 changes: 13 additions & 0 deletions questions/why-do-some-keys-not-make-it-to-my-app.question.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ recommend picking keys and key combinations from the above.
Keys that aren't normally passed through by terminals include Cmd and Option
on macOS, and the Windows key on Windows.

!!! note "Windows and Alt-modified keys"

On Windows terminals that don't support the [Kitty keyboard
protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) (for example,
Windows Terminal), Textual emulates Alt-modified keys by watching for
``ESC`` followed by another key. This covers most ``alt+letter`` and
``alt+shift+letter`` combinations, but it can't disambiguate cases where
the terminal already turned the input into a composed character (e.g.
``Ctrl+Alt`` behaving as AltGr) or where the original modifiers are lost.
If you need every modifier combination (especially `ctrl+alt`, `ctrl+shift`
and AltGr), use a terminal with Kitty keyboard protocol support such as
WezTerm, kitty, or Alacritty.

If you need to test what [key
combinations](https://textual.textualize.io/guide/input/#keyboard-input)
work in different environments you can try them out with `textual keys`.
125 changes: 125 additions & 0 deletions src/textual/_windows_key_sequences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
Helpers for detecting Alt-modified key presses on Windows consoles that do not
emit Kitty keyboard protocol sequences.

Windows terminals that only support VT input typically translate `Alt+<key>`
into the two byte sequence ``ESC`` + ``<key>``, which is indistinguishable from
typing Escape followed by the key. The common workaround (used by many TUIs) is
to interpret ``ESC`` followed closely by another printable character as an Alt+key combo.
We perform that translation in the Windows driver before handing the input to :mod:`textual._xterm_parser`.
"""

from __future__ import annotations

from typing import Iterable, List

# ESC-prefixed CSI / SS3 sequences start with these characters; we shouldn't convert them into
# Alt+key combinations because they are almost certainly control sequences from the terminal.
_ESCAPE_PREFIXES = {"[", "O", "P", "]"}


def _decode_control_character(char: str) -> tuple[str, bool]:
"""Convert control codes back to their printable counterparts.

Args:
char: Character extracted from the console stream.

Returns:
A tuple ``(character, is_ctrl)``.
"""

code = ord(char)
if 1 <= code <= 26:
return chr(code + 96), True
control_map = {
0: "@",
27: "[",
28: "\\",
29: "]",
30: "^",
31: "_",
127: "\x7f",
}
if code in control_map:
return control_map[code], True
return char, False


def _format_alt_sequence(char: str, shift: bool, ctrl: bool) -> str:
"""Build a Kitty keyboard protocol sequence for an Alt-modified character.

Args:
char: Base printable character.
shift: ``True`` if Shift should be reported.
ctrl: ``True`` if Ctrl should be reported.

Returns:
Kitty keyboard protocol sequence.
"""

modifiers = 1
if shift:
modifiers += 1
modifiers += 2 # Alt
if ctrl:
modifiers += 4

return f"\x1b[{ord(char)};{modifiers}u"


def _synthesize_alt_sequence(char: str, has_trailing_input: bool) -> str | None:
"""Build a fallback Kitty sequence for ESC-prefixed key presses.

Args:
char: Character following ``ESC``.
has_trailing_input: ``True`` if more data follows the pair.

Returns:
Kitty keyboard protocol sequence or ``None``.
"""

if len(char) != 1:
return None

base_char, ctrl = _decode_control_character(char)
if char == "\x1b":
return None
if base_char in _ESCAPE_PREFIXES:
if has_trailing_input:
return None
if ctrl:
return None

shift = base_char.isalpha() and base_char.isupper()
return _format_alt_sequence(base_char, shift, ctrl)


def coalesce_alt_sequences(chars: Iterable[str]) -> list[str]:
"""Replace ``ESC`` + ``char`` pairs with Kitty sequences.

Args:
chars: Individual characters read from the console.

Returns:
A new list of strings where recognized pairs have been replaced with
Kitty keyboard protocol sequences.
"""

result: list[str] = []
chars_list: List[str] = list(chars)
index = 0
length = len(chars_list)
while index < length:
char = chars_list[index]
if char == "\x1b" and index + 1 < length:
next_char = chars_list[index + 1]
kitty_sequence = _synthesize_alt_sequence(
next_char, has_trailing_input=index + 2 < length
)
if kitty_sequence is not None:
result.append(kitty_sequence)
index += 2
continue
result.append(char)
index += 1
return result
22 changes: 14 additions & 8 deletions src/textual/drivers/win32.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import IO, TYPE_CHECKING, Callable, List, Optional

from textual import constants
from textual._windows_key_sequences import coalesce_alt_sequences
from textual._xterm_parser import XTermParser
from textual.events import Event, Resize
from textual.geometry import Size
Expand Down Expand Up @@ -266,28 +267,33 @@ def run(self) -> None:
event_type = input_record.EventType

if event_type == KEY_EVENT:
# Key event, store unicode char in keys list
key_event = input_record.Event.KeyEvent
key = key_event.uChar.UnicodeChar
if key_event.bKeyDown:
if (
key_event.dwControlKeyState
and key_event.wVirtualKeyCode == 0
):
# Key event, store the character for later processing
# so we can coalesce Alt sequences before decoding.
char = key_event.uChar.UnicodeChar or ""
if char == "\x00":
char = ""
if not char:
continue
append_key(key)
append_key(char)
elif event_type == WINDOW_BUFFER_SIZE_EVENT:
# Window size changed, store size
size = input_record.Event.WindowBufferSizeEvent.dwSize
new_size = (size.X, size.Y)

if keys:
# Coalesce ESC-prefixed pairs into Kitty key sequences when the
# terminal doesn't support the Kitty keyboard protocol.
converted_keys = coalesce_alt_sequences(keys)
# Process keys
#
# https://github.com/Textualize/textual/issues/3178 has
# the context for the encode/decode here.
for event in parser.feed(
"".join(keys).encode("utf-16", "surrogatepass").decode("utf-16")
"".join(converted_keys)
.encode("utf-16", "surrogatepass")
.decode("utf-16")
):
self.process_event(event)
if new_size is not None:
Expand Down
67 changes: 67 additions & 0 deletions tests/test_windows_key_sequences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from textual._windows_key_sequences import coalesce_alt_sequences


def test_plain_esc_is_preserved() -> None:
assert coalesce_alt_sequences(["\x1b"]) == ["\x1b"]


def test_alt_followed_by_char_converts_to_kitty_sequence() -> None:
assert coalesce_alt_sequences(["\x1b", "a"]) == ["\x1b[97;3u"]


def test_alt_shift_sequence_includes_shift_modifier() -> None:
assert coalesce_alt_sequences(["\x1b", "A"]) == ["\x1b[65;4u"]


def test_escape_prefix_without_trailing_is_converted() -> None:
assert coalesce_alt_sequences(["\x1b", "["]) == ["\x1b[91;3u"]


def test_escape_prefix_coalesces_when_no_trailing_input() -> None:
assert coalesce_alt_sequences(["\x1b", "]"]) == ["\x1b[93;3u"]


def test_sequence_with_multiple_pairs() -> None:
chars = ["\x1b", "a", "b", "\x1b", "c"]
assert coalesce_alt_sequences(chars) == ["\x1b[97;3u", "b", "\x1b[99;3u"]


def test_escape_prefix_not_coalesced_when_followed_by_more_input() -> None:
chars = ["\x1b", "[", "A"]
assert coalesce_alt_sequences(chars) == ["\x1b", "[", "A"]


def test_mixed_escape_sequences_only_convert_valid_pairs() -> None:
chars = ["x", "\x1b", "[", "A", "\x1b", "d"]
assert coalesce_alt_sequences(chars) == ["x", "\x1b", "[", "A", "\x1b[100;3u"]


def test_control_characters_become_alt_ctrl() -> None:
assert coalesce_alt_sequences(["\x1b", "\x01"]) == ["\x1b[97;7u"]


def test_ctrl_space_is_identified() -> None:
assert coalesce_alt_sequences(["\x1b", "\x00"]) == ["\x1b[64;7u"]


def test_ctrl_delete_is_identified() -> None:
assert coalesce_alt_sequences(["\x1b", "\x7f"]) == ["\x1b[127;7u"]


def test_control_brackets_remain_literal() -> None:
assert coalesce_alt_sequences(["\x1b", "\x1b"]) == ["\x1b", "\x1b"]


def test_ctrl_newline_is_converted() -> None:
assert coalesce_alt_sequences(["\x1b", "\n"]) == ["\x1b[106;7u"]


def test_multi_byte_strings_are_passed_through() -> None:
assert coalesce_alt_sequences(["\x1b", "ab", "c"]) == ["\x1b", "ab", "c"]


def test_original_sequence_is_not_modified() -> None:
chars = ["\x1b", "a", "b"]
snapshot = list(chars)
coalesce_alt_sequences(chars)
assert chars == snapshot
14 changes: 14 additions & 0 deletions tests/test_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,20 @@ def test_key_presses_and_escape_sequence_mixed(parser):
assert "".join(event.key for event in events) == "abcf3123"


def test_kitty_alt_sequences(parser):
alt = list(parser.feed("\x1b[97;3u"))
assert len(alt) == 1
assert alt[0].key == "alt+a"

alt_shift = list(parser.feed("\x1b[65;4u"))
assert len(alt_shift) == 1
assert alt_shift[0].key == "alt+shift+A"

alt_ctrl = list(parser.feed("\x1b[120;7u"))
assert len(alt_ctrl) == 1
assert alt_ctrl[0].key == "alt+ctrl+x"


def test_single_escape(parser):
"""A single \x1b should be interpreted as a single press of the Escape key"""
events = list(parser.feed("\x1b"))
Expand Down