ttyengine 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.
ttyengine/terminal.py ADDED
@@ -0,0 +1,283 @@
1
+ import os, sys, re, time, codecs, contextlib
2
+ from . import ansi
3
+
4
+ _WINDOWS = sys.platform == 'win32'
5
+ if _WINDOWS:
6
+ import msvcrt
7
+ else:
8
+ import select, termios, tty
9
+
10
+ QUERY_TIMEOUT = 5.0
11
+
12
+ MODE_SYNC = 2026
13
+ MODE_MOUSE_CLICK = 1000
14
+ MODE_MOUSE_DRAG = 1002
15
+ MODE_MOUSE_MOTION = 1003
16
+ MODE_MOUSE_SGR = 1006
17
+
18
+ _RGB = r'rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)(?:' + ansi.BEL_RE + '|' + ansi.ST_RE + ')'
19
+ _CPR = re.compile(ansi.CSI_RE + r'[0-9]+;[0-9]+R')
20
+ _CPR_GROUPS = re.compile(ansi.CSI_RE + r'([0-9]+);([0-9]+)R')
21
+ _XTVERSION = re.compile(ansi.DCS_RE + r'>\|([^' + ansi.ESC_RE + r']*)' + ansi.ST_RE)
22
+ _BGCOLOR = re.compile(ansi.OSC_RE + '11;' + _RGB)
23
+ _FGCOLOR = re.compile(ansi.OSC_RE + '10;' + _RGB)
24
+
25
+
26
+ def xparse_color(component, bits=16):
27
+ if bits not in (8, 16):
28
+ raise ValueError(f"bits must be 8 or 16, got {bits}")
29
+ size = len(component)
30
+ value = int(component, 16)
31
+ if size == 4:
32
+ wide = value
33
+ elif size == 3:
34
+ wide = (value << 4) | (value >> 8)
35
+ elif size == 2:
36
+ wide = value * 0x0101
37
+ elif size == 1:
38
+ wide = value * 0x1111
39
+ else:
40
+ raise ValueError(f"hex component must be 1-4 characters, got {size}")
41
+ return wide >> 8 if bits == 8 else wide
42
+
43
+
44
+ class Terminal:
45
+ def __init__(self, stream=None):
46
+ self.stream = stream if stream is not None else sys.__stdout__
47
+ self._pending = ''
48
+ self._decoder = codecs.getincrementaldecoder('utf-8')('replace')
49
+ self._cbreak = False
50
+ self._modes = {}
51
+ self._modes_ok = False
52
+ self._modes_dead = False
53
+ self._apple_found = None
54
+ try:
55
+ self._fd = sys.__stdin__.fileno() if sys.__stdin__.isatty() else None
56
+ except Exception:
57
+ self._fd = None
58
+
59
+ @property
60
+ def is_a_tty(self):
61
+ try:
62
+ return bool(self.stream.isatty())
63
+ except Exception:
64
+ return False
65
+
66
+ def _size(self):
67
+ for stream in (self.stream, sys.__stdout__):
68
+ try:
69
+ size = os.get_terminal_size(stream.fileno())
70
+ return size.lines, size.columns
71
+ except (OSError, ValueError, AttributeError, TypeError):
72
+ pass
73
+ return int(os.getenv('LINES', '25')), int(os.getenv('COLUMNS', '80'))
74
+
75
+ @property
76
+ def height(self):
77
+ return self._size()[0]
78
+
79
+ @property
80
+ def width(self):
81
+ return self._size()[1]
82
+
83
+ def _write(self, text):
84
+ try:
85
+ self.stream.write(ansi.wrap_all(text))
86
+ self.stream.flush()
87
+ except Exception:
88
+ pass
89
+
90
+ def take(self):
91
+ text, self._pending = self._pending, ''
92
+ return text
93
+
94
+ def ungetch(self, text):
95
+ self._pending = text + self._pending
96
+
97
+ def kbhit(self, timeout=None):
98
+ if self._fd is None:
99
+ return False
100
+ if _WINDOWS:
101
+ end = None if timeout is None else time.monotonic() + timeout
102
+ while not msvcrt.kbhit():
103
+ if end is not None and time.monotonic() >= end:
104
+ return False
105
+ time.sleep(0.005)
106
+ return True
107
+ try:
108
+ return bool(select.select([self._fd], [], [], timeout)[0])
109
+ except (OSError, ValueError):
110
+ return False
111
+
112
+ def _read_available(self):
113
+ if self._fd is None:
114
+ return ''
115
+ if _WINDOWS:
116
+ chars = []
117
+ while msvcrt.kbhit():
118
+ chars.append(msvcrt.getwch())
119
+ return ''.join(chars)
120
+ chunks = []
121
+ while True:
122
+ try:
123
+ ready, _, _ = select.select([self._fd], [], [], 0)
124
+ if not ready:
125
+ break
126
+ data = os.read(self._fd, 4096)
127
+ except (OSError, ValueError):
128
+ break
129
+ if not data:
130
+ break
131
+ chunks.append(data)
132
+ if len(data) < 4096:
133
+ break
134
+ return self._decoder.decode(b''.join(chunks)) if chunks else ''
135
+
136
+ def _read_until(self, pattern, timeout):
137
+ start = time.monotonic()
138
+ buf = self.take()
139
+ while True:
140
+ buf += self._read_available()
141
+ match = pattern.search(buf)
142
+ if match is not None or len(buf) > 65536:
143
+ break
144
+ left = timeout - (time.monotonic() - start)
145
+ if left <= 0 or not self.kbhit(left):
146
+ break
147
+ return match, buf
148
+
149
+ def _query(self, query, pattern, timeout):
150
+ if not self.is_a_tty or self._fd is None:
151
+ return None
152
+ with self._rare():
153
+ self._write(query + ansi.csi('6', 'n'))
154
+ boundary, data = self._read_until(_CPR, timeout)
155
+ if boundary is not None:
156
+ data = data[:boundary.start()] + data[boundary.end():]
157
+ found = pattern.search(data)
158
+ if found is not None:
159
+ data = data[:found.start()] + data[found.end():]
160
+ self.ungetch(data)
161
+ return found
162
+
163
+ @contextlib.contextmanager
164
+ def _rare(self):
165
+ if self._cbreak:
166
+ yield
167
+ else:
168
+ with self.cbreak():
169
+ yield
170
+
171
+ @contextlib.contextmanager
172
+ def cbreak(self):
173
+ if _WINDOWS or self._fd is None:
174
+ yield
175
+ return
176
+ saved = termios.tcgetattr(self._fd)
177
+ was = self._cbreak
178
+ tty.setcbreak(self._fd, termios.TCSANOW)
179
+ self._cbreak = True
180
+ try:
181
+ yield
182
+ finally:
183
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, saved)
184
+ self._cbreak = was
185
+
186
+ @contextlib.contextmanager
187
+ def _sequence(self, enter, leave):
188
+ styled = self.is_a_tty
189
+ if styled:
190
+ self._write(enter)
191
+ try:
192
+ yield
193
+ finally:
194
+ if styled:
195
+ self._write(leave)
196
+
197
+ def fullscreen(self):
198
+ return self._sequence(ansi.mode(1049, True) + ansi.csi('22;0;0', 't'),
199
+ ansi.mode(1049, False) + ansi.csi('23;0;0', 't'))
200
+
201
+ def hidden_cursor(self):
202
+ return self._sequence(ansi.mode(25, False), ansi.mode(12, False) + ansi.mode(25, True))
203
+
204
+ def no_line_wrap(self):
205
+ return self._sequence(ansi.mode(7, False), ansi.mode(7, True))
206
+
207
+ def _apple(self, timeout):
208
+ if os.environ.get('TERM_PROGRAM') == 'Apple_Terminal':
209
+ return True
210
+ if self._apple_found is None:
211
+ match = self._query(ansi.csi('>', 'q'), _XTVERSION, timeout)
212
+ self._apple_found = bool(match) and match.group(1).startswith('Apple_Terminal')
213
+ return self._apple_found
214
+
215
+ def get_cursor(self, timeout=QUERY_TIMEOUT):
216
+ if not self.is_a_tty or self._fd is None:
217
+ return None
218
+ with self._rare():
219
+ self._write(ansi.csi('6', 'n'))
220
+ found, data = self._read_until(_CPR_GROUPS, timeout)
221
+ if found is not None:
222
+ data = data[:found.start()] + data[found.end():]
223
+ self.ungetch(data)
224
+ if found is None:
225
+ return None
226
+ return int(found.group(1)) - 1, int(found.group(2)) - 1
227
+
228
+ def get_dec_mode(self, mode, timeout=QUERY_TIMEOUT):
229
+ if mode in self._modes:
230
+ return self._modes[mode]
231
+ if self._modes_dead or self._apple(timeout):
232
+ return -1
233
+ pattern = re.compile(ansi.CSI_RE + r'\?%d;([0-4])\$y' % mode)
234
+ match = self._query(ansi.csi('?%d$' % mode, 'p'), pattern, timeout)
235
+ if match is None:
236
+ if self.is_a_tty and self._fd is not None and not self._modes_ok:
237
+ self._modes_dead = True
238
+ return -1
239
+ self._modes_ok = True
240
+ self._modes[mode] = int(match.group(1))
241
+ return self._modes[mode]
242
+
243
+ @contextlib.contextmanager
244
+ def dec_modes_enabled(self, *modes, timeout=QUERY_TIMEOUT):
245
+ turned = [mode for mode in modes if self.get_dec_mode(mode, timeout) == 2]
246
+ if turned and self.is_a_tty:
247
+ self._write(ansi.modes(turned, True))
248
+ for mode in turned:
249
+ self._modes[mode] = 1
250
+ try:
251
+ yield
252
+ finally:
253
+ if turned and self.is_a_tty:
254
+ self._write(ansi.modes(turned, False))
255
+ for mode in turned:
256
+ self._modes[mode] = 2
257
+
258
+ def mouse_enabled(self, *, clicks=True, report_drag=False, report_motion=False, timeout=QUERY_TIMEOUT):
259
+ modes = [MODE_MOUSE_SGR]
260
+ if report_motion:
261
+ modes.append(MODE_MOUSE_MOTION)
262
+ elif report_drag:
263
+ modes.append(MODE_MOUSE_DRAG)
264
+ elif clicks:
265
+ modes.append(MODE_MOUSE_CLICK)
266
+ return self.dec_modes_enabled(*modes, timeout=timeout)
267
+
268
+ def synchronized_output(self, timeout=QUERY_TIMEOUT):
269
+ return self.dec_modes_enabled(MODE_SYNC, timeout=timeout)
270
+
271
+ def _color(self, query, pattern, timeout, bits):
272
+ if bits not in (8, 16):
273
+ raise ValueError(f"bits must be 8 or 16, got {bits}")
274
+ match = self._query(query, pattern, timeout)
275
+ if match is None:
276
+ return (-1, -1, -1)
277
+ return tuple(xparse_color(value, bits) for value in match.groups())
278
+
279
+ def get_bgcolor(self, timeout=QUERY_TIMEOUT, bits=16):
280
+ return self._color(ansi.osc('11;?'), _BGCOLOR, timeout, bits)
281
+
282
+ def get_fgcolor(self, timeout=QUERY_TIMEOUT, bits=16):
283
+ return self._color(ansi.osc('10;?'), _FGCOLOR, timeout, bits)