tuiwright 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tuiwright/__init__.py +31 -0
- tuiwright/_emulator.py +146 -0
- tuiwright/_input.py +330 -0
- tuiwright/_pty.py +223 -0
- tuiwright/_snapshot/__init__.py +6 -0
- tuiwright/_snapshot/cells.py +103 -0
- tuiwright/_snapshot/png.py +77 -0
- tuiwright/_trace/__init__.py +1 -0
- tuiwright/_trace/recorder.py +106 -0
- tuiwright/py.typed +0 -0
- tuiwright/pytest_plugin.py +204 -0
- tuiwright/screen.py +321 -0
- tuiwright/session.py +575 -0
- tuiwright-0.1.0.dist-info/METADATA +246 -0
- tuiwright-0.1.0.dist-info/RECORD +18 -0
- tuiwright-0.1.0.dist-info/WHEEL +4 -0
- tuiwright-0.1.0.dist-info/entry_points.txt +3 -0
- tuiwright-0.1.0.dist-info/licenses/LICENSE +21 -0
tuiwright/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""tuiwright — Playwright-style end-to-end testing for TUI applications.
|
|
2
|
+
|
|
3
|
+
Drives any TUI binary under a real PTY + terminal emulator, with cell-grid
|
|
4
|
+
and PNG snapshot regression.
|
|
5
|
+
|
|
6
|
+
Quick start:
|
|
7
|
+
|
|
8
|
+
async def test_app(tui, snapshot):
|
|
9
|
+
await tui.start("myapp")
|
|
10
|
+
await tui.wait_for_text("Ready")
|
|
11
|
+
await tui.type("hello")
|
|
12
|
+
await tui.press("enter")
|
|
13
|
+
assert tui.screen == snapshot
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from tuiwright.screen import Cell, Color, Cursor, Position, Region, Screen
|
|
17
|
+
from tuiwright.session import TuiSession, TuiTimeoutError
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Cell",
|
|
21
|
+
"Color",
|
|
22
|
+
"Cursor",
|
|
23
|
+
"Position",
|
|
24
|
+
"Region",
|
|
25
|
+
"Screen",
|
|
26
|
+
"TuiSession",
|
|
27
|
+
"TuiTimeoutError",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
tuiwright/_emulator.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Layer 2: terminal emulator wrapping pyte with DEC mode tracking.
|
|
2
|
+
|
|
3
|
+
Parses bytes emitted by the child process into a 2D ``Screen``, and
|
|
4
|
+
exposes which private DEC modes (mouse, bracketed paste, focus) the app
|
|
5
|
+
has enabled. The session layer uses these flags to (1) decide whether
|
|
6
|
+
mouse input is meaningful and (2) wrap pasted text in the right brackets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
import pyte
|
|
15
|
+
from pyte.screens import Char as PyteChar
|
|
16
|
+
|
|
17
|
+
# DEC private modes we care about. See xterm ctlseqs for the full set.
|
|
18
|
+
MODE_MOUSE_X10: Final[int] = 1000
|
|
19
|
+
MODE_MOUSE_BUTTON: Final[int] = 1002
|
|
20
|
+
MODE_MOUSE_ANY: Final[int] = 1003
|
|
21
|
+
MODE_MOUSE_SGR: Final[int] = 1006
|
|
22
|
+
MODE_FOCUS: Final[int] = 1004
|
|
23
|
+
MODE_BRACKETED_PASTE: Final[int] = 2004
|
|
24
|
+
MODE_ALT_SCREEN: Final[int] = 1049
|
|
25
|
+
|
|
26
|
+
MOUSE_TRACKING_MODES: Final[frozenset[int]] = frozenset(
|
|
27
|
+
{MODE_MOUSE_X10, MODE_MOUSE_BUTTON, MODE_MOUSE_ANY}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TrackedScreen(pyte.Screen):
|
|
32
|
+
"""pyte.Screen that exposes the set of private DEC modes currently on.
|
|
33
|
+
|
|
34
|
+
pyte handles a few private modes internally (origin mode, autowrap,
|
|
35
|
+
etc.) but doesn't expose mouse / bracketed-paste / focus modes in a
|
|
36
|
+
queryable way. We override ``set_mode`` / ``reset_mode`` to keep a
|
|
37
|
+
side-table.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
private_modes: set[int]
|
|
41
|
+
|
|
42
|
+
def __init__(self, columns: int, lines: int) -> None:
|
|
43
|
+
super().__init__(columns, lines)
|
|
44
|
+
# Don't rely on subclass __dict__ ordering; set after super init.
|
|
45
|
+
self.private_modes = set()
|
|
46
|
+
|
|
47
|
+
def set_mode(self, *modes: int, **kwargs: bool) -> None: # type: ignore[override]
|
|
48
|
+
super().set_mode(*modes, **kwargs)
|
|
49
|
+
if kwargs.get("private"):
|
|
50
|
+
self.private_modes.update(modes)
|
|
51
|
+
|
|
52
|
+
def reset_mode(self, *modes: int, **kwargs: bool) -> None: # type: ignore[override]
|
|
53
|
+
super().reset_mode(*modes, **kwargs)
|
|
54
|
+
if kwargs.get("private"):
|
|
55
|
+
self.private_modes.difference_update(modes)
|
|
56
|
+
|
|
57
|
+
def reset(self) -> None: # type: ignore[override]
|
|
58
|
+
super().reset()
|
|
59
|
+
self.private_modes = set()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Emulator:
|
|
63
|
+
"""Holds the screen, feeds bytes into it, and exposes mode state.
|
|
64
|
+
|
|
65
|
+
Resize is *active* — call :meth:`resize` to change the simulated
|
|
66
|
+
terminal dimensions; the wrapped pyte screen and stream are reset
|
|
67
|
+
consistently.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, *, cols: int, rows: int) -> None:
|
|
71
|
+
self._cols = cols
|
|
72
|
+
self._rows = rows
|
|
73
|
+
self._screen = TrackedScreen(cols, rows)
|
|
74
|
+
self._stream = pyte.ByteStream(self._screen)
|
|
75
|
+
self._revision = 0 # bumped on every feed; used for stable-screen waits
|
|
76
|
+
|
|
77
|
+
# -- feeding --------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def feed(self, chunk: bytes) -> None:
|
|
80
|
+
if not chunk:
|
|
81
|
+
return
|
|
82
|
+
self._stream.feed(chunk)
|
|
83
|
+
self._revision += 1
|
|
84
|
+
|
|
85
|
+
# -- query ----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def screen(self) -> TrackedScreen:
|
|
89
|
+
return self._screen
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def cols(self) -> int:
|
|
93
|
+
return self._cols
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def rows(self) -> int:
|
|
97
|
+
return self._rows
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def revision(self) -> int:
|
|
101
|
+
return self._revision
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def private_modes(self) -> frozenset[int]:
|
|
105
|
+
return frozenset(self._screen.private_modes)
|
|
106
|
+
|
|
107
|
+
def is_mouse_tracking(self) -> bool:
|
|
108
|
+
return bool(self._screen.private_modes & MOUSE_TRACKING_MODES)
|
|
109
|
+
|
|
110
|
+
def is_bracketed_paste(self) -> bool:
|
|
111
|
+
return MODE_BRACKETED_PASTE in self._screen.private_modes
|
|
112
|
+
|
|
113
|
+
def is_focus_events(self) -> bool:
|
|
114
|
+
return MODE_FOCUS in self._screen.private_modes
|
|
115
|
+
|
|
116
|
+
# -- mutation -------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
119
|
+
self._cols = cols
|
|
120
|
+
self._rows = rows
|
|
121
|
+
self._screen.resize(rows, cols)
|
|
122
|
+
self._revision += 1
|
|
123
|
+
|
|
124
|
+
# -- cell access ---------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def cells(self) -> list[list[PyteChar]]:
|
|
127
|
+
"""Materialise the full grid as a 2D list, including empty cells."""
|
|
128
|
+
buf = self._screen.buffer
|
|
129
|
+
default = self._screen.default_char
|
|
130
|
+
out: list[list[PyteChar]] = []
|
|
131
|
+
for y in range(self._rows):
|
|
132
|
+
row_buf = buf.get(y, {})
|
|
133
|
+
out.append([row_buf.get(x, default) for x in range(self._cols)])
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
def cell_rows_text(self) -> Iterable[str]:
|
|
137
|
+
"""Yield each row as a string, padded to the column count."""
|
|
138
|
+
buf = self._screen.buffer
|
|
139
|
+
default_char = self._screen.default_char.data
|
|
140
|
+
for y in range(self._rows):
|
|
141
|
+
row_buf = buf.get(y, {})
|
|
142
|
+
chars: list[str] = []
|
|
143
|
+
for x in range(self._cols):
|
|
144
|
+
ch = row_buf.get(x)
|
|
145
|
+
chars.append(ch.data if ch is not None else default_char)
|
|
146
|
+
yield "".join(chars).rstrip()
|
tuiwright/_input.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Layer 3: encoders that turn high-level intent into PTY bytes.
|
|
2
|
+
|
|
3
|
+
The goal is parity with what a real terminal emulator (alacritty,
|
|
4
|
+
ghostty, xterm in xterm-262color mode) sends when the user presses a
|
|
5
|
+
key, clicks the mouse, pastes, or changes focus. This is what apps
|
|
6
|
+
written against crossterm / termion / ncurses expect.
|
|
7
|
+
|
|
8
|
+
References:
|
|
9
|
+
- xterm ctlseqs: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
|
|
10
|
+
- vt sequences: https://vt100.net/docs/vt510-rm/chapter4.html
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import IntFlag
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
ESC: Final[bytes] = b"\x1b"
|
|
20
|
+
CSI: Final[bytes] = b"\x1b["
|
|
21
|
+
SS3: Final[bytes] = b"\x1bO"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Mod(IntFlag):
|
|
25
|
+
NONE = 0
|
|
26
|
+
SHIFT = 1
|
|
27
|
+
ALT = 2
|
|
28
|
+
CTRL = 4
|
|
29
|
+
META = 8 # Super / Cmd. Mapped only when explicitly requested.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_MOD_ALIASES: Final[dict[str, Mod]] = {
|
|
33
|
+
"shift": Mod.SHIFT,
|
|
34
|
+
"s": Mod.SHIFT,
|
|
35
|
+
"alt": Mod.ALT,
|
|
36
|
+
"a": Mod.ALT,
|
|
37
|
+
"opt": Mod.ALT,
|
|
38
|
+
"option": Mod.ALT,
|
|
39
|
+
"ctrl": Mod.CTRL,
|
|
40
|
+
"control": Mod.CTRL,
|
|
41
|
+
"c": Mod.CTRL,
|
|
42
|
+
"cmd": Mod.META,
|
|
43
|
+
"meta": Mod.META,
|
|
44
|
+
"super": Mod.META,
|
|
45
|
+
"win": Mod.META,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Canonical names → un-modified byte sequence.
|
|
50
|
+
# For named keys, we apply CSI modifier params when modifiers are present.
|
|
51
|
+
_NAMED_KEYS: Final[dict[str, bytes]] = {
|
|
52
|
+
# Whitespace / control
|
|
53
|
+
"enter": b"\r",
|
|
54
|
+
"return": b"\r",
|
|
55
|
+
"tab": b"\t",
|
|
56
|
+
"backtab": CSI + b"Z",
|
|
57
|
+
"space": b" ",
|
|
58
|
+
"escape": ESC,
|
|
59
|
+
"esc": ESC,
|
|
60
|
+
"backspace": b"\x7f",
|
|
61
|
+
"delete": CSI + b"3~",
|
|
62
|
+
"insert": CSI + b"2~",
|
|
63
|
+
# Arrows
|
|
64
|
+
"up": CSI + b"A",
|
|
65
|
+
"down": CSI + b"B",
|
|
66
|
+
"right": CSI + b"C",
|
|
67
|
+
"left": CSI + b"D",
|
|
68
|
+
# Navigation
|
|
69
|
+
"home": CSI + b"H",
|
|
70
|
+
"end": CSI + b"F",
|
|
71
|
+
"pageup": CSI + b"5~",
|
|
72
|
+
"pagedown": CSI + b"6~",
|
|
73
|
+
"pgup": CSI + b"5~",
|
|
74
|
+
"pgdn": CSI + b"6~",
|
|
75
|
+
# Function keys (xterm/vt-style - F1-F4 use SS3, F5+ use CSI ~)
|
|
76
|
+
"f1": SS3 + b"P",
|
|
77
|
+
"f2": SS3 + b"Q",
|
|
78
|
+
"f3": SS3 + b"R",
|
|
79
|
+
"f4": SS3 + b"S",
|
|
80
|
+
"f5": CSI + b"15~",
|
|
81
|
+
"f6": CSI + b"17~",
|
|
82
|
+
"f7": CSI + b"18~",
|
|
83
|
+
"f8": CSI + b"19~",
|
|
84
|
+
"f9": CSI + b"20~",
|
|
85
|
+
"f10": CSI + b"21~",
|
|
86
|
+
"f11": CSI + b"23~",
|
|
87
|
+
"f12": CSI + b"24~",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Keys whose unmodified form is a CSI sequence ending in a single final
|
|
91
|
+
# letter (arrows, home, end). With modifiers they take ``CSI 1 ; M X``.
|
|
92
|
+
_CSI_LETTER_FINALS: Final[dict[str, bytes]] = {
|
|
93
|
+
"up": b"A",
|
|
94
|
+
"down": b"B",
|
|
95
|
+
"right": b"C",
|
|
96
|
+
"left": b"D",
|
|
97
|
+
"home": b"H",
|
|
98
|
+
"end": b"F",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Keys whose unmodified form is ``CSI N ~``. With modifiers they become
|
|
102
|
+
# ``CSI N ; M ~``.
|
|
103
|
+
_CSI_TILDE_NUMS: Final[dict[str, int]] = {
|
|
104
|
+
"insert": 2,
|
|
105
|
+
"delete": 3,
|
|
106
|
+
"pageup": 5,
|
|
107
|
+
"pgup": 5,
|
|
108
|
+
"pagedown": 6,
|
|
109
|
+
"pgdn": 6,
|
|
110
|
+
"f5": 15,
|
|
111
|
+
"f6": 17,
|
|
112
|
+
"f7": 18,
|
|
113
|
+
"f8": 19,
|
|
114
|
+
"f9": 20,
|
|
115
|
+
"f10": 21,
|
|
116
|
+
"f11": 23,
|
|
117
|
+
"f12": 24,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# F1-F4 with modifiers use ``CSI 1 ; M X`` (X is P/Q/R/S).
|
|
121
|
+
_CSI_F1_F4_FINALS: Final[dict[str, bytes]] = {
|
|
122
|
+
"f1": b"P",
|
|
123
|
+
"f2": b"Q",
|
|
124
|
+
"f3": b"R",
|
|
125
|
+
"f4": b"S",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# -----------------------------------------------------------------------
|
|
130
|
+
# Key parser
|
|
131
|
+
# -----------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_combo(spec: str) -> tuple[Mod, str]:
|
|
135
|
+
parts = [p.strip() for p in spec.split("+") if p.strip()]
|
|
136
|
+
if not parts:
|
|
137
|
+
raise ValueError(f"empty key spec: {spec!r}")
|
|
138
|
+
mods = Mod.NONE
|
|
139
|
+
*mod_parts, key = parts
|
|
140
|
+
for mp in mod_parts:
|
|
141
|
+
m = _MOD_ALIASES.get(mp.lower())
|
|
142
|
+
if m is None:
|
|
143
|
+
raise ValueError(f"unknown modifier {mp!r} in {spec!r}")
|
|
144
|
+
mods |= m
|
|
145
|
+
# Named keys are case-insensitive ("Enter" == "enter"); single chars
|
|
146
|
+
# preserve case so "A" works as the shifted form of "a".
|
|
147
|
+
if len(key) != 1:
|
|
148
|
+
key = key.lower()
|
|
149
|
+
return mods, key
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def encode_key(spec: str) -> bytes:
|
|
153
|
+
"""Encode a single key spec like ``"ctrl+shift+f5"`` or ``"enter"``.
|
|
154
|
+
|
|
155
|
+
For single printable characters with no modifiers, returns the UTF-8
|
|
156
|
+
bytes of the character. With ``ctrl``, maps to the corresponding
|
|
157
|
+
control byte (``ctrl+a`` → ``\\x01``). With ``alt``, prefixes ESC.
|
|
158
|
+
"""
|
|
159
|
+
mods, key = _parse_combo(spec)
|
|
160
|
+
# Single printable character — common case for ctrl+letter, alt+letter.
|
|
161
|
+
if len(key) == 1:
|
|
162
|
+
ch = key
|
|
163
|
+
if mods == Mod.NONE:
|
|
164
|
+
return ch.encode("utf-8")
|
|
165
|
+
if mods == Mod.SHIFT:
|
|
166
|
+
return ch.upper().encode("utf-8")
|
|
167
|
+
if mods == Mod.CTRL and "a" <= ch <= "z":
|
|
168
|
+
return bytes([ord(ch) - ord("a") + 1])
|
|
169
|
+
if mods == (Mod.CTRL | Mod.SHIFT) and "a" <= ch <= "z":
|
|
170
|
+
# Most terminals collapse ctrl+shift+letter to ctrl+letter; we
|
|
171
|
+
# follow that convention since the disambiguating kitty
|
|
172
|
+
# protocol is out of scope for v0.1.
|
|
173
|
+
return bytes([ord(ch) - ord("a") + 1])
|
|
174
|
+
if mods == Mod.ALT:
|
|
175
|
+
return ESC + ch.encode("utf-8")
|
|
176
|
+
if mods == (Mod.ALT | Mod.SHIFT):
|
|
177
|
+
return ESC + ch.upper().encode("utf-8")
|
|
178
|
+
if mods == (Mod.ALT | Mod.CTRL) and "a" <= ch <= "z":
|
|
179
|
+
return ESC + bytes([ord(ch) - ord("a") + 1])
|
|
180
|
+
raise ValueError(f"unsupported modifier combo for char {ch!r}: {mods!r}")
|
|
181
|
+
|
|
182
|
+
if key not in _NAMED_KEYS:
|
|
183
|
+
raise ValueError(f"unknown key name: {key!r}")
|
|
184
|
+
|
|
185
|
+
base = _NAMED_KEYS[key]
|
|
186
|
+
if mods == Mod.NONE:
|
|
187
|
+
return base
|
|
188
|
+
|
|
189
|
+
# Apply xterm-style modifier encoding to named keys.
|
|
190
|
+
param = _xterm_modifier_param(mods)
|
|
191
|
+
if key in _CSI_LETTER_FINALS:
|
|
192
|
+
return CSI + b"1;" + str(param).encode() + _CSI_LETTER_FINALS[key]
|
|
193
|
+
if key in _CSI_TILDE_NUMS:
|
|
194
|
+
n = _CSI_TILDE_NUMS[key]
|
|
195
|
+
return CSI + str(n).encode() + b";" + str(param).encode() + b"~"
|
|
196
|
+
if key in _CSI_F1_F4_FINALS:
|
|
197
|
+
return CSI + b"1;" + str(param).encode() + _CSI_F1_F4_FINALS[key]
|
|
198
|
+
# Fallback for things like ctrl+enter, ctrl+tab, ctrl+space — terminals
|
|
199
|
+
# disagree. We mirror crossterm's defaults: ctrl+space → NUL, ctrl+enter
|
|
200
|
+
# is sent as plain CR by most terminals (so just emit base).
|
|
201
|
+
if key == "space" and mods == Mod.CTRL:
|
|
202
|
+
return b"\x00"
|
|
203
|
+
if key == "backspace" and mods == Mod.CTRL:
|
|
204
|
+
return b"\x08"
|
|
205
|
+
if key == "tab" and mods == Mod.SHIFT:
|
|
206
|
+
return CSI + b"Z"
|
|
207
|
+
return base
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _xterm_modifier_param(mods: Mod) -> int:
|
|
211
|
+
bits = 0
|
|
212
|
+
if Mod.SHIFT in mods:
|
|
213
|
+
bits |= 1
|
|
214
|
+
if Mod.ALT in mods:
|
|
215
|
+
bits |= 2
|
|
216
|
+
if Mod.CTRL in mods:
|
|
217
|
+
bits |= 4
|
|
218
|
+
if Mod.META in mods:
|
|
219
|
+
bits |= 8
|
|
220
|
+
return bits + 1
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -----------------------------------------------------------------------
|
|
224
|
+
# Mouse (SGR 1006)
|
|
225
|
+
# -----------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class MouseButton:
|
|
230
|
+
code: int
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
LEFT = MouseButton(0)
|
|
234
|
+
MIDDLE = MouseButton(1)
|
|
235
|
+
RIGHT = MouseButton(2)
|
|
236
|
+
WHEEL_UP = MouseButton(64)
|
|
237
|
+
WHEEL_DOWN = MouseButton(65)
|
|
238
|
+
WHEEL_LEFT = MouseButton(66)
|
|
239
|
+
WHEEL_RIGHT = MouseButton(67)
|
|
240
|
+
|
|
241
|
+
_BUTTON_ALIASES: Final[dict[str, MouseButton]] = {
|
|
242
|
+
"left": LEFT,
|
|
243
|
+
"middle": MIDDLE,
|
|
244
|
+
"right": RIGHT,
|
|
245
|
+
"wheel_up": WHEEL_UP,
|
|
246
|
+
"wheelup": WHEEL_UP,
|
|
247
|
+
"wheel_down": WHEEL_DOWN,
|
|
248
|
+
"wheeldown": WHEEL_DOWN,
|
|
249
|
+
"wheel_left": WHEEL_LEFT,
|
|
250
|
+
"wheel_right": WHEEL_RIGHT,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def resolve_button(button: str | MouseButton) -> MouseButton:
|
|
255
|
+
if isinstance(button, MouseButton):
|
|
256
|
+
return button
|
|
257
|
+
b = _BUTTON_ALIASES.get(button.lower())
|
|
258
|
+
if b is None:
|
|
259
|
+
raise ValueError(f"unknown mouse button: {button!r}")
|
|
260
|
+
return b
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _mouse_modifier_bits(mods: Mod) -> int:
|
|
264
|
+
bits = 0
|
|
265
|
+
if Mod.SHIFT in mods:
|
|
266
|
+
bits |= 4
|
|
267
|
+
if Mod.ALT in mods:
|
|
268
|
+
bits |= 8
|
|
269
|
+
if Mod.CTRL in mods:
|
|
270
|
+
bits |= 16
|
|
271
|
+
return bits
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def encode_mouse(
|
|
275
|
+
*,
|
|
276
|
+
button: str | MouseButton,
|
|
277
|
+
row: int,
|
|
278
|
+
col: int,
|
|
279
|
+
pressed: bool = True,
|
|
280
|
+
modifiers: Mod | tuple[str, ...] = Mod.NONE,
|
|
281
|
+
motion: bool = False,
|
|
282
|
+
) -> bytes:
|
|
283
|
+
"""Encode a single SGR 1006 mouse event.
|
|
284
|
+
|
|
285
|
+
Rows and columns are 1-based, matching the wire format. The session
|
|
286
|
+
layer accepts 0-based coordinates and adds 1 before calling here.
|
|
287
|
+
"""
|
|
288
|
+
btn = resolve_button(button)
|
|
289
|
+
if isinstance(modifiers, tuple):
|
|
290
|
+
mods = Mod.NONE
|
|
291
|
+
for m in modifiers:
|
|
292
|
+
mods |= _parse_combo(m)[0] if "+" in m else (_MOD_ALIASES.get(m.lower()) or Mod.NONE)
|
|
293
|
+
else:
|
|
294
|
+
mods = modifiers
|
|
295
|
+
cb = btn.code | _mouse_modifier_bits(mods)
|
|
296
|
+
if motion:
|
|
297
|
+
cb |= 32
|
|
298
|
+
final = b"M" if pressed else b"m"
|
|
299
|
+
return CSI + b"<" + str(cb).encode() + b";" + str(col).encode() + b";" + str(row).encode() + final
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# -----------------------------------------------------------------------
|
|
303
|
+
# Bracketed paste
|
|
304
|
+
# -----------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
PASTE_START: Final[bytes] = CSI + b"200~"
|
|
307
|
+
PASTE_END: Final[bytes] = CSI + b"201~"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def encode_paste(text: str) -> bytes:
|
|
311
|
+
payload = text.encode("utf-8")
|
|
312
|
+
if PASTE_END in payload:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
"paste payload contains the bracketed-paste end marker; "
|
|
315
|
+
"this would break out of the paste and be a security smell. "
|
|
316
|
+
"Use type() to send mixed content instead."
|
|
317
|
+
)
|
|
318
|
+
return PASTE_START + payload + PASTE_END
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# -----------------------------------------------------------------------
|
|
322
|
+
# Focus events
|
|
323
|
+
# -----------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
FOCUS_IN: Final[bytes] = CSI + b"I"
|
|
326
|
+
FOCUS_OUT: Final[bytes] = CSI + b"O"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def encode_focus(in_: bool) -> bytes:
|
|
330
|
+
return FOCUS_IN if in_ else FOCUS_OUT
|