From d6762a5a8b8090cf2c2453c4bcb1d6c0832c0907 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Dec 2025 14:55:18 +0000 Subject: [PATCH 1/5] alt keys --- src/textual/_xterm_parser.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 6203163ccb..aae40b277e 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -169,14 +169,21 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: Args: reissue_sequence: Key sequence to report to the app. """ + alt:bool = False if reissue_sequence: self.debug_log("REISSUE", repr(reissue_sequence)) for character in reissue_sequence: + if character == ESC: + alt = True + continue key_events = sequence_to_key_events(character) for event in key_events: if event.key == "escape": event = events.Key("circumflex_accent", "^") + if alt and not event.key.startswith("alt+"): + event.key = f"alt+{event.key}" on_token(event) + alt = False while not self.is_eof: if not bracketed_paste and paste_buffer: @@ -211,23 +218,27 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: # # Could be the escape key was pressed OR the start of an escape sequence sequence: str = ESC - def send_escape() -> None: + + def send_sequence() -> None: """Send escape key and reissue sequence.""" - on_token(events.Key("escape", "\x1b")) - reissue_sequence_as_keys(sequence[1:]) + if sequence == ESC: + on_token(events.Key("escape", "\x1b")) + else: + reissue_sequence_as_keys(sequence) + while True: try: new_character = yield read1(constants.ESCAPE_DELAY) except ParseTimeout: - send_escape() + send_sequence() break except ParseEOF: - send_escape() + send_sequence() return if new_character == ESC: - send_escape() + send_sequence() sequence = character continue else: From f26eec8912fc47f85230ea83977667f5fe5be7cb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Dec 2025 15:31:40 +0000 Subject: [PATCH 2/5] key test --- src/textual/_xterm_parser.py | 4 +--- tests/test_xterm_parser.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index aae40b277e..2dab7e680d 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -169,7 +169,7 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: Args: reissue_sequence: Key sequence to report to the app. """ - alt:bool = False + alt: bool = False if reissue_sequence: self.debug_log("REISSUE", repr(reissue_sequence)) for character in reissue_sequence: @@ -218,7 +218,6 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: # # Could be the escape key was pressed OR the start of an escape sequence sequence: str = ESC - def send_sequence() -> None: """Send escape key and reissue sequence.""" if sequence == ESC: @@ -226,7 +225,6 @@ def send_sequence() -> None: else: reissue_sequence_as_keys(sequence) - while True: try: new_character = yield read1(constants.ESCAPE_DELAY) diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index 609313232c..cfb820bfa3 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -181,6 +181,23 @@ def test_double_escape(parser): assert [event.key for event in events] == ["escape", "escape"] +@pytest.mark.parametrize( + "sequence,key", + [ + ("\x1ba", "alt+a"), + ], +) +def test_keys(parser, sequence: str, key: str) -> None: + """Test rarer keys.""" + events = [] + for event in parser.feed(sequence): + events.append(event) + for event in parser.feed(""): + events.append(event) + event = events[0] + assert event.key == key + + @pytest.mark.parametrize( "sequence, event_type, shift, meta", [ From dc17fb9c6be5dba2bb212b956978a9c5142139f6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Dec 2025 15:50:49 +0000 Subject: [PATCH 3/5] alt --- src/textual/_xterm_parser.py | 15 ++++++++++----- tests/test_xterm_parser.py | 6 +++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 2dab7e680d..4eb44c63e1 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -176,12 +176,10 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: if character == ESC: alt = True continue - key_events = sequence_to_key_events(character) + key_events = sequence_to_key_events(character, alt=alt) for event in key_events: if event.key == "escape": event = events.Key("circumflex_accent", "^") - if alt and not event.key.startswith("alt+"): - event.key = f"alt+{event.key}" on_token(event) alt = False @@ -322,7 +320,9 @@ def send_sequence() -> None: self._debug_log_file.close() self._debug_log_file = None - def _sequence_to_key_events(self, sequence: str) -> Iterable[events.Key]: + def _sequence_to_key_events( + self, sequence: str, alt: bool = False + ) -> Iterable[events.Key]: """Map a sequence of code points on to a sequence of keys. Args: @@ -385,7 +385,12 @@ def _sequence_to_key_events(self, sequence: str) -> Iterable[events.Key]: name = _character_to_key(sequence) else: name = sequence - name = KEY_NAME_REPLACEMENTS.get(name, name) + if name.isupper(): + name = f"shift+{name.lower()}" + else: + name = KEY_NAME_REPLACEMENTS.get(name, name) + if alt: + name = f"alt+{name}" yield events.Key(name, sequence) except Exception: yield events.Key(sequence, sequence) diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index cfb820bfa3..8f2ae786d6 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -184,7 +184,11 @@ def test_double_escape(parser): @pytest.mark.parametrize( "sequence,key", [ - ("\x1ba", "alt+a"), + # ("\x1ba", "alt+a"), + # ("\x1b[97;3u", "alt+a"), + ("\x1b[65;4u", "alt+shift+a"), + ("\x1bA", "alt+shift+a"), + # ("\x1b[120;7u", "alt+ctrl+x"), ], ) def test_keys(parser, sequence: str, key: str) -> None: From 5786113e0ae5e136951343e103c8fb7cb4be4beb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Dec 2025 15:59:00 +0000 Subject: [PATCH 4/5] fix alt keys --- src/textual/_xterm_parser.py | 2 +- tests/test_xterm_parser.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 4eb44c63e1..d1e7613763 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -351,7 +351,7 @@ def _sequence_to_key_events( key_tokens.append(modifier) key_tokens.sort() - key_tokens.append(key) + key_tokens.append(key.lower()) yield events.Key( "+".join(key_tokens), sequence if len(sequence) == 1 else None ) diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index 8f2ae786d6..c33d9b412a 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -184,11 +184,13 @@ def test_double_escape(parser): @pytest.mark.parametrize( "sequence,key", [ - # ("\x1ba", "alt+a"), - # ("\x1b[97;3u", "alt+a"), + ("a", "a"), + ("B", "B"), + ("\x1ba", "alt+a"), + ("\x1b[97;3u", "alt+a"), ("\x1b[65;4u", "alt+shift+a"), ("\x1bA", "alt+shift+a"), - # ("\x1b[120;7u", "alt+ctrl+x"), + ("\x1b[120;7u", "alt+ctrl+x"), ], ) def test_keys(parser, sequence: str, key: str) -> None: From 1021964d374b8d97390a22eb3f06fb014e65f7a4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Dec 2025 16:28:37 +0000 Subject: [PATCH 5/5] alt processing --- src/textual/_xterm_parser.py | 28 ++++++++++++++++------------ tests/test_xterm_parser.py | 6 +++--- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index d1e7613763..1b7f3e57d9 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -163,22 +163,26 @@ def on_key_token(event: events.Key) -> None: else: on_token(event) - def reissue_sequence_as_keys(reissue_sequence: str) -> None: + def reissue_sequence_as_keys( + reissue_sequence: str, process_alt: bool = False + ) -> None: """Called when an escape sequence hasn't been understood. Args: reissue_sequence: Key sequence to report to the app. """ - alt: bool = False + + alt = False + if reissue_sequence: self.debug_log("REISSUE", repr(reissue_sequence)) for character in reissue_sequence: - if character == ESC: + if process_alt and character == ESC: alt = True continue key_events = sequence_to_key_events(character, alt=alt) for event in key_events: - if event.key == "escape": + if event.key == "escape" and not process_alt: event = events.Key("circumflex_accent", "^") on_token(event) alt = False @@ -216,12 +220,12 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: # # Could be the escape key was pressed OR the start of an escape sequence sequence: str = ESC - def send_sequence() -> None: + def send_sequence(process_alt: bool = True) -> None: """Send escape key and reissue sequence.""" if sequence == ESC: on_token(events.Key("escape", "\x1b")) else: - reissue_sequence_as_keys(sequence) + reissue_sequence_as_keys(sequence, process_alt=process_alt) while True: try: @@ -234,7 +238,7 @@ def send_sequence() -> None: return if new_character == ESC: - send_sequence() + send_sequence(process_alt=False) sequence = character continue else: @@ -385,11 +389,11 @@ def _sequence_to_key_events( name = _character_to_key(sequence) else: name = sequence - if name.isupper(): - name = f"shift+{name.lower()}" - else: - name = KEY_NAME_REPLACEMENTS.get(name, name) - if alt: + + name = KEY_NAME_REPLACEMENTS.get(name, name) + if len(name) == 1 and alt: + if name.isupper(): + name = f"shift+{name.lower()}" name = f"alt+{name}" yield events.Key(name, sequence) except Exception: diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index c33d9b412a..28a002f2e6 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -132,13 +132,13 @@ def test_unknown_sequence_followed_by_known_sequence(parser, chunk_size): events = [] for chunk in chunks(sequence, chunk_size): - events.append(parser.feed(chunk)) + events.extend(list(parser.feed(chunk))) - events = list(itertools.chain.from_iterable(list(event) for event in events)) + # events = list(itertools.chain.from_iterable(list(event) for event in events)) print(repr([event.key for event in events])) assert [event.key for event in events] == [ - "escape", + "circumflex_accent", "left_square_bracket", "question_mark", "end",