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 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