mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
366 lines
10 KiB
Python
366 lines
10 KiB
Python
import itertools
|
|
|
|
import pytest
|
|
|
|
from textual._xterm_parser import XTermParser
|
|
from textual.events import (
|
|
Key,
|
|
MouseDown,
|
|
MouseMove,
|
|
MouseScrollDown,
|
|
MouseScrollLeft,
|
|
MouseScrollRight,
|
|
MouseScrollUp,
|
|
MouseUp,
|
|
Paste,
|
|
)
|
|
from textual.messages import TerminalSupportsSynchronizedOutput
|
|
|
|
|
|
def chunks(data, size):
|
|
if size == 0:
|
|
yield data
|
|
return
|
|
|
|
chunk_start = 0
|
|
chunk_end = size
|
|
while True:
|
|
yield data[chunk_start:chunk_end]
|
|
chunk_start = chunk_end
|
|
chunk_end += size
|
|
if chunk_end >= len(data):
|
|
yield data[chunk_start:chunk_end]
|
|
break
|
|
|
|
|
|
@pytest.fixture
|
|
def parser():
|
|
return XTermParser()
|
|
|
|
|
|
@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5, 6])
|
|
def test_varying_parser_chunk_sizes_no_missing_data(parser, chunk_size):
|
|
end = "\x1b[8~"
|
|
text = "ABCDEFGH"
|
|
|
|
data = end + text
|
|
events = []
|
|
for chunk in chunks(data, chunk_size):
|
|
events.append(parser.feed(chunk))
|
|
|
|
events = list(itertools.chain.from_iterable(list(event) for event in events))
|
|
|
|
assert events[0].key == "end"
|
|
assert [event.key for event in events[1:]] == list(text)
|
|
|
|
|
|
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
|
|
|
|
|
|
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_bracketed_paste_amongst_other_codes(parser):
|
|
pasted_text = "PASTED"
|
|
events = list(parser.feed(f"\x1b[8~\x1b[200~{pasted_text}\x1b[201~\x1b[8~"))
|
|
assert len(events) == 3 # Key.End -> Paste -> Key.End
|
|
assert events[0].key == "end"
|
|
assert events[1].text == pasted_text
|
|
assert events[2].key == "end"
|
|
|
|
|
|
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[123456789123456789123123456789123456789123"
|
|
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)
|
|
|
|
# When we backtrack '\x1b' is translated to '^'
|
|
assert events[0].key == "circumflex_accent"
|
|
|
|
# The rest of the characters correspond to the expected key presses
|
|
events = events[1:]
|
|
for index, character in enumerate(sequence[1:]):
|
|
assert events[index].character == character
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"chunk_size",
|
|
[
|
|
2,
|
|
3,
|
|
4,
|
|
5,
|
|
6,
|
|
],
|
|
)
|
|
def test_unknown_sequence_followed_by_known_sequence(parser, chunk_size):
|
|
"""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 = []
|
|
|
|
for chunk in chunks(sequence, chunk_size):
|
|
events.append(parser.feed(chunk))
|
|
|
|
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",
|
|
"left_square_bracket",
|
|
"question_mark",
|
|
"end",
|
|
]
|
|
|
|
|
|
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_simple_keypress_non_character_key(parser):
|
|
sequence = "\x09"
|
|
events = list(parser.feed(sequence))
|
|
assert len(events) == 1
|
|
assert events[0].key == "tab"
|
|
|
|
|
|
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 = list(parser.feed("\x1b"))
|
|
events.extend(parser.feed(""))
|
|
assert [event.key for event in events] == ["escape"]
|
|
|
|
|
|
def test_double_escape(parser):
|
|
"""Test double escape."""
|
|
events = list(parser.feed("\x1b\x1b"))
|
|
events.extend(parser.feed(""))
|
|
print(events)
|
|
assert [event.key for event in events] == ["escape", "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),
|
|
("\x1b[<12;50;25M", MouseDown, True, 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),
|
|
("\x1b[<12;50;25m", MouseUp, True, 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
|
|
("\x1b[<3;15;38M", False, False, 0),
|
|
],
|
|
)
|
|
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
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sequence, shift, meta",
|
|
[
|
|
("\x1b[<64;18;25M", False, False),
|
|
("\x1b[<68;18;25M", True, False),
|
|
("\x1b[<72;18;25M", False, True),
|
|
],
|
|
)
|
|
def test_mouse_scroll_up(parser, sequence, shift, meta):
|
|
"""Scrolling the mouse with and without modifiers held down.
|
|
We don't currently capture modifier keys in scroll events.
|
|
"""
|
|
events = list(parser.feed(sequence))
|
|
|
|
assert len(events) == 1
|
|
|
|
event = events[0]
|
|
|
|
assert isinstance(event, MouseScrollUp)
|
|
assert event.x == 17
|
|
assert event.y == 24
|
|
assert event.shift is shift
|
|
assert event.meta is meta
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sequence, shift, meta",
|
|
[
|
|
("\x1b[<65;18;25M", False, False),
|
|
("\x1b[<69;18;25M", True, False),
|
|
("\x1b[<73;18;25M", False, True),
|
|
],
|
|
)
|
|
def test_mouse_scroll_down(parser, sequence, shift, meta):
|
|
events = list(parser.feed(sequence))
|
|
|
|
assert len(events) == 1
|
|
|
|
event = events[0]
|
|
|
|
assert isinstance(event, MouseScrollDown)
|
|
assert event.x == 17
|
|
assert event.y == 24
|
|
assert event.shift is shift
|
|
assert event.meta is meta
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sequence, shift, meta",
|
|
[
|
|
("\x1b[<66;18;25M", False, False),
|
|
("\x1b[<70;18;25M", True, False),
|
|
("\x1b[<74;18;25M", False, True),
|
|
],
|
|
)
|
|
def test_mouse_scroll_left(parser, sequence, shift, meta):
|
|
"""Scrolling the mouse with and without modifiers held down.
|
|
We don't currently capture modifier keys in scroll events.
|
|
"""
|
|
events = list(parser.feed(sequence))
|
|
|
|
assert len(events) == 1
|
|
|
|
event = events[0]
|
|
|
|
assert isinstance(event, MouseScrollLeft)
|
|
assert event.x == 17
|
|
assert event.y == 24
|
|
assert event.shift is shift
|
|
assert event.meta is meta
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sequence, shift, meta",
|
|
[
|
|
("\x1b[<67;18;25M", False, False),
|
|
("\x1b[<71;18;25M", True, False),
|
|
("\x1b[<75;18;25M", False, True),
|
|
],
|
|
)
|
|
def test_mouse_scroll_right(parser, sequence, shift, meta):
|
|
"""Scrolling the mouse with and without modifiers held down.
|
|
We don't currently capture modifier keys in scroll events.
|
|
"""
|
|
events = list(parser.feed(sequence))
|
|
|
|
assert len(events) == 1
|
|
|
|
event = events[0]
|
|
|
|
assert isinstance(event, MouseScrollRight)
|
|
assert event.x == 17
|
|
assert event.y == 24
|
|
assert event.shift is shift
|
|
assert event.meta is meta
|
|
|
|
|
|
def test_mouse_event_detected_but_info_not_parsed(parser):
|
|
# I don't know if this can actually happen in reality, but
|
|
# there's a branch in the code that allows for the possibility.
|
|
events = list(parser.feed("\x1b[<65;18;20;25M"))
|
|
assert len(events) == 0
|
|
|
|
|
|
@pytest.mark.xfail()
|
|
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"
|
|
|
|
|
|
@pytest.mark.parametrize("parameter", range(1, 5))
|
|
def test_terminal_mode_reporting_synchronized_output_supported(parser, parameter):
|
|
sequence = f"\x1b[?2026;{parameter}$y"
|
|
events = list(parser.feed(sequence))
|
|
assert len(events) == 1
|
|
assert isinstance(events[0], TerminalSupportsSynchronizedOutput)
|
|
|
|
|
|
def test_terminal_mode_reporting_synchronized_output_not_supported(parser):
|
|
sequence = "\x1b[?2026;0$y"
|
|
events = list(parser.feed(sequence))
|
|
assert events == []
|