mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Backtracking unknown escape sequences, various tests for XTermParser
This commit is contained in:
@@ -166,7 +166,6 @@ if __name__ == "__main__":
|
|||||||
def parse(
|
def parse(
|
||||||
self, on_token: Callable[[str], None]
|
self, on_token: Callable[[str], None]
|
||||||
) -> Generator[Awaitable, str, None]:
|
) -> Generator[Awaitable, str, None]:
|
||||||
data = yield self.read1()
|
|
||||||
while True:
|
while True:
|
||||||
data = yield self.read1()
|
data = yield self.read1()
|
||||||
if not data:
|
if not data:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from typing import Any, Callable, Generator, Iterable
|
from typing import Any, Callable, Generator, Iterable
|
||||||
|
|
||||||
from . import messages
|
from . import messages
|
||||||
@@ -10,6 +11,11 @@ from ._types import MessageTarget
|
|||||||
from ._parser import Awaitable, Parser, TokenCallback
|
from ._parser import Awaitable, Parser, TokenCallback
|
||||||
from ._ansi_sequences import ANSI_SEQUENCES_KEYS
|
from ._ansi_sequences import ANSI_SEQUENCES_KEYS
|
||||||
|
|
||||||
|
# When trying to determine whether the current sequence is a supported/valid
|
||||||
|
# escape sequence, at which length should we give up and consider our search
|
||||||
|
# to be unsuccessful?
|
||||||
|
_MAX_SEQUENCE_SEARCH_THRESHOLD = 20
|
||||||
|
|
||||||
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
|
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
|
||||||
_re_terminal_mode_response = re.compile(
|
_re_terminal_mode_response = re.compile(
|
||||||
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
|
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
|
||||||
@@ -30,7 +36,7 @@ class XTermParser(Parser[events.Event]):
|
|||||||
self.last_x = 0
|
self.last_x = 0
|
||||||
self.last_y = 0
|
self.last_y = 0
|
||||||
|
|
||||||
self._debug_log_file = open("keys.log", "wt") if debug else None
|
self._debug_log_file = open("keys.log", "wt")
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
@@ -105,7 +111,6 @@ class XTermParser(Parser[events.Event]):
|
|||||||
|
|
||||||
character = yield read1()
|
character = yield read1()
|
||||||
|
|
||||||
# If we're currently performing a bracketed paste,
|
|
||||||
if bracketed_paste:
|
if bracketed_paste:
|
||||||
paste_buffer.append(character)
|
paste_buffer.append(character)
|
||||||
self.debug_log(f"paste_buffer={paste_buffer!r}")
|
self.debug_log(f"paste_buffer={paste_buffer!r}")
|
||||||
@@ -130,7 +135,28 @@ class XTermParser(Parser[events.Event]):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
sequence += yield read1()
|
# If we look ahead and see an escape, then we've failed
|
||||||
|
# to find an escape sequence and should reissue the characters
|
||||||
|
# up till this point.
|
||||||
|
buffer = yield self.peek_buffer()
|
||||||
|
|
||||||
|
if (
|
||||||
|
buffer
|
||||||
|
and buffer[0] == ESC
|
||||||
|
or len(sequence) > _MAX_SEQUENCE_SEARCH_THRESHOLD
|
||||||
|
):
|
||||||
|
for character in sequence:
|
||||||
|
keys = get_key_ansi_sequence(character, None)
|
||||||
|
if keys is not None:
|
||||||
|
for key in keys:
|
||||||
|
on_token(events.Key(self.sender, key=key))
|
||||||
|
else:
|
||||||
|
on_token(events.Key(self.sender, key=character))
|
||||||
|
break
|
||||||
|
|
||||||
|
sequence_character = yield read1()
|
||||||
|
sequence += sequence_character
|
||||||
|
|
||||||
self.debug_log(f"sequence={sequence!r}")
|
self.debug_log(f"sequence={sequence!r}")
|
||||||
|
|
||||||
# Firstly, check if it's a bracketed paste escape code
|
# Firstly, check if it's a bracketed paste escape code
|
||||||
@@ -161,6 +187,7 @@ class XTermParser(Parser[events.Event]):
|
|||||||
mouse_match = _re_mouse_event.match(sequence)
|
mouse_match = _re_mouse_event.match(sequence)
|
||||||
if mouse_match is not None:
|
if mouse_match is not None:
|
||||||
mouse_code = mouse_match.group(0)
|
mouse_code = mouse_match.group(0)
|
||||||
|
print(mouse_code)
|
||||||
event = self.parse_mouse_code(mouse_code, self.sender)
|
event = self.parse_mouse_code(mouse_code, self.sender)
|
||||||
if event:
|
if event:
|
||||||
on_token(event)
|
on_token(event)
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ def test_read1():
|
|||||||
on_token(data)
|
on_token(data)
|
||||||
|
|
||||||
test_parser = TestParser()
|
test_parser = TestParser()
|
||||||
|
|
||||||
test_data = "Where there is a Will there is a way!"
|
test_data = "Where there is a Will there is a way!"
|
||||||
|
|
||||||
for size in range(1, len(test_data) + 1):
|
for size in range(1, len(test_data) + 1):
|
||||||
# Feed the parser in pieces, first 1 character at a time, then 2, etc
|
# Feed the parser in pieces, first 1 character at a time, then 2, etc
|
||||||
test_parser = TestParser()
|
|
||||||
data = []
|
data = []
|
||||||
for offset in range(0, len(test_data), size):
|
for offset in range(0, len(test_data), size):
|
||||||
for chunk in test_parser.feed(test_data[offset : offset + size]):
|
for chunk in test_parser.feed(test_data[offset : offset + size]):
|
||||||
|
|||||||
160
tests/test_xterm_parser.py
Normal file
160
tests/test_xterm_parser.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from textual._xterm_parser import XTermParser
|
||||||
|
from textual.events import Paste, Key, MouseDown, MouseUp, MouseMove
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parser():
|
||||||
|
return XTermParser(sender=mock.sentinel, more_data=lambda: False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bracketed_paste(parser):
|
||||||
|
""" When bracketed paste mode is enabled in the terminal emulator and
|
||||||
|
the user pastes in some text, it will surround the pasted input
|
||||||
|
with the escape codes "\x1b[200~" and "\x1b[201~". The text between
|
||||||
|
these codes corresponds to a single `Paste` event in Textual.
|
||||||
|
"""
|
||||||
|
pasted_text = "PASTED"
|
||||||
|
events = list(parser.feed(f"\x1b[200~{pasted_text}\x1b[201~"))
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert isinstance(events[0], Paste)
|
||||||
|
assert events[0].text == pasted_text
|
||||||
|
assert events[0].sender == mock.sentinel
|
||||||
|
|
||||||
|
|
||||||
|
def test_bracketed_paste_content_contains_escape_codes(parser):
|
||||||
|
"""When performing a bracketed paste, if the pasted content contains
|
||||||
|
supported ANSI escape sequences, it should not interfere with the paste,
|
||||||
|
and no escape sequences within the bracketed paste should be converted
|
||||||
|
into Textual events.
|
||||||
|
"""
|
||||||
|
pasted_text = "PAS\x0fTED"
|
||||||
|
events = list(parser.feed(f"\x1b[200~{pasted_text}\x1b[201~"))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].text == pasted_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_cant_match_escape_sequence_too_long(parser):
|
||||||
|
""" The sequence did not match, and we hit the maximum sequence search
|
||||||
|
length threshold, so each character should be issued as a key-press instead.
|
||||||
|
"""
|
||||||
|
sequence = "\x1b[123456789123456789123"
|
||||||
|
events = list(parser.feed(sequence))
|
||||||
|
|
||||||
|
# Every character in the sequence is converted to a key press
|
||||||
|
assert len(events) == len(sequence)
|
||||||
|
assert all(isinstance(event, Key) for event in events)
|
||||||
|
|
||||||
|
# '\x1b' is translated to 'escape'
|
||||||
|
assert events[0].key == "escape"
|
||||||
|
|
||||||
|
# The rest of the characters correspond to the expected key presses
|
||||||
|
events = events[1:]
|
||||||
|
for index, character in enumerate(sequence[1:]):
|
||||||
|
assert events[index].key == character
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_sequence_followed_by_known_sequence(parser):
|
||||||
|
""" When we feed the parser an unknown sequence followed by a known
|
||||||
|
sequence. The characters in the unknown sequence are delivered as keys,
|
||||||
|
and the known escape sequence that follows is delivered as expected.
|
||||||
|
"""
|
||||||
|
unknown_sequence = "\x1b[?"
|
||||||
|
known_sequence = "\x1b[8~" # key = 'end'
|
||||||
|
|
||||||
|
sequence = unknown_sequence + known_sequence
|
||||||
|
events = parser.feed(sequence)
|
||||||
|
|
||||||
|
assert next(events).key == "escape"
|
||||||
|
assert next(events).key == "["
|
||||||
|
assert next(events).key == "?"
|
||||||
|
assert next(events).key == "end"
|
||||||
|
|
||||||
|
with pytest.raises(StopIteration):
|
||||||
|
next(events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_key_presses_all_delivered_correct_order(parser):
|
||||||
|
sequence = "123abc"
|
||||||
|
events = parser.feed(sequence)
|
||||||
|
assert "".join(event.key for event in events) == sequence
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_presses_and_escape_sequence_mixed(parser):
|
||||||
|
sequence = "abc\x1b[13~123"
|
||||||
|
events = list(parser.feed(sequence))
|
||||||
|
|
||||||
|
assert len(events) == 7
|
||||||
|
assert "".join(event.key for event in events) == "abcf3123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_escape(parser):
|
||||||
|
"""A single \x1b should be interpreted as a single press of the Escape key"""
|
||||||
|
events = parser.feed("\x1b")
|
||||||
|
assert [event.key for event in events] == ["escape"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_escape(parser):
|
||||||
|
"""Windows Terminal writes double ESC when the user presses the Escape key once."""
|
||||||
|
events = parser.feed("\x1b\x1b")
|
||||||
|
assert [event.key for event in events] == ["escape"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sequence, event_type, shift, meta", [
|
||||||
|
# Mouse down, with and without modifiers
|
||||||
|
("\x1b[<0;50;25M", MouseDown, False, False),
|
||||||
|
("\x1b[<4;50;25M", MouseDown, True, False),
|
||||||
|
("\x1b[<8;50;25M", MouseDown, False, True),
|
||||||
|
# Mouse up, with and without modifiers
|
||||||
|
("\x1b[<0;50;25m", MouseUp, False, False),
|
||||||
|
("\x1b[<4;50;25m", MouseUp, True, False),
|
||||||
|
("\x1b[<8;50;25m", MouseUp, False, True),
|
||||||
|
])
|
||||||
|
def test_mouse_click(parser, sequence, event_type, shift, meta):
|
||||||
|
"""ANSI codes for mouse should be converted to Textual events"""
|
||||||
|
events = list(parser.feed(sequence))
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
event = events[0]
|
||||||
|
|
||||||
|
assert isinstance(event, event_type)
|
||||||
|
assert event.x == 49
|
||||||
|
assert event.y == 24
|
||||||
|
assert event.screen_x == 49
|
||||||
|
assert event.screen_y == 24
|
||||||
|
assert event.meta is meta
|
||||||
|
assert event.shift is shift
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sequence, shift, meta, button", [
|
||||||
|
("\x1b[<32;15;38M", False, False, 1), # Click and drag
|
||||||
|
("\x1b[<35;15;38M", False, False, 0), # Basic cursor movement
|
||||||
|
("\x1b[<39;15;38M", True, False, 0), # Shift held down
|
||||||
|
("\x1b[<43;15;38M", False, True, 0), # Meta held down
|
||||||
|
])
|
||||||
|
def test_mouse_move(parser, sequence, shift, meta, button):
|
||||||
|
events = list(parser.feed(sequence))
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
event = events[0]
|
||||||
|
|
||||||
|
assert isinstance(event, MouseMove)
|
||||||
|
assert event.x == 14
|
||||||
|
assert event.y == 37
|
||||||
|
assert event.shift is shift
|
||||||
|
assert event.meta is meta
|
||||||
|
assert event.button == button
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_sequence_resulting_in_multiple_keypresses(parser):
|
||||||
|
"""Some sequences are interpreted as more than 1 keypress"""
|
||||||
|
events = list(parser.feed("\x1b[2;4~"))
|
||||||
|
assert len(events) == 2
|
||||||
|
assert events[0].key == "escape"
|
||||||
|
assert events[1].key == "shift+insert"
|
||||||
Reference in New Issue
Block a user