codehs-utils 1.0.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.
- codehs_utils/__init__.py +78 -0
- codehs_utils/_buffer.py +51 -0
- codehs_utils/_pixelbuf.py +10 -0
- codehs_utils/_platform.py +156 -0
- codehs_utils/colors.py +510 -0
- codehs_utils/drawing.py +337 -0
- codehs_utils/geometry.py +70 -0
- codehs_utils/terminal.py +573 -0
- codehs_utils/text.py +199 -0
- codehs_utils-1.0.0.dist-info/METADATA +510 -0
- codehs_utils-1.0.0.dist-info/RECORD +13 -0
- codehs_utils-1.0.0.dist-info/WHEEL +4 -0
- codehs_utils-1.0.0.dist-info/licenses/LICENSE +21 -0
codehs_utils/terminal.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Low-level terminal control: cursor movement, screen clearing, the
|
|
2
|
+
alternate screen, keyboard/mouse input, terminal size, and `print_at`/`app`.
|
|
3
|
+
|
|
4
|
+
Raw terminal mode and signal decoding are handled here; Windows-specific
|
|
5
|
+
console API calls live in `._platform` and are used internally.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import shutil
|
|
13
|
+
import select
|
|
14
|
+
import atexit
|
|
15
|
+
import itertools
|
|
16
|
+
import contextlib
|
|
17
|
+
from collections import deque
|
|
18
|
+
from typing import Callable, Iterator, List, Optional, Tuple, Union
|
|
19
|
+
|
|
20
|
+
from ._platform import (
|
|
21
|
+
_IS_WINDOWS,
|
|
22
|
+
_win_get_cursor_position,
|
|
23
|
+
_win_get_terminal_size,
|
|
24
|
+
_win_read_bytes,
|
|
25
|
+
_win_enable_vt_output,
|
|
26
|
+
_win_enable_vt_input,
|
|
27
|
+
_win_restore_input_mode,
|
|
28
|
+
)
|
|
29
|
+
from ._buffer import _write, _flush_pending_frame, frame
|
|
30
|
+
from ._pixelbuf import _pixel_buf
|
|
31
|
+
from .text import _visible_len
|
|
32
|
+
from .geometry import Rect
|
|
33
|
+
|
|
34
|
+
if not _IS_WINDOWS:
|
|
35
|
+
import termios
|
|
36
|
+
import tty
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_cleanup_registered = False
|
|
40
|
+
_mouse_on = False
|
|
41
|
+
_cursor_hidden = False
|
|
42
|
+
_alt_screen_on = False
|
|
43
|
+
_cbreak_old = None
|
|
44
|
+
_cbreak_failed = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _register_cleanup():
|
|
48
|
+
global _cleanup_registered
|
|
49
|
+
if not _cleanup_registered:
|
|
50
|
+
_cleanup_registered = True
|
|
51
|
+
atexit.register(restore_terminal)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ensure_cbreak_mode():
|
|
55
|
+
global _cbreak_old, _cbreak_failed
|
|
56
|
+
if _IS_WINDOWS or _cbreak_old is not None or _cbreak_failed:
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
fd = sys.stdin.fileno()
|
|
60
|
+
old = termios.tcgetattr(fd)
|
|
61
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
62
|
+
except (termios.error, OSError, ValueError):
|
|
63
|
+
_cbreak_failed = True
|
|
64
|
+
return
|
|
65
|
+
_cbreak_old = old
|
|
66
|
+
_register_cleanup()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _restore_cbreak_mode():
|
|
70
|
+
global _cbreak_old
|
|
71
|
+
if _cbreak_old is not None:
|
|
72
|
+
try:
|
|
73
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _cbreak_old)
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
_cbreak_old = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@contextlib.contextmanager
|
|
80
|
+
def _scoped_cbreak():
|
|
81
|
+
owned = not _IS_WINDOWS and _cbreak_old is None and not _cbreak_failed
|
|
82
|
+
_ensure_cbreak_mode()
|
|
83
|
+
try:
|
|
84
|
+
yield
|
|
85
|
+
finally:
|
|
86
|
+
if owned:
|
|
87
|
+
_restore_cbreak_mode()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def restore_terminal():
|
|
91
|
+
global _mouse_on, _cursor_hidden, _alt_screen_on
|
|
92
|
+
try:
|
|
93
|
+
if _mouse_on:
|
|
94
|
+
_mouse_on = False
|
|
95
|
+
_write("\033[?1000l\033[?1003l\033[?1006l")
|
|
96
|
+
_drain_input()
|
|
97
|
+
if _cursor_hidden:
|
|
98
|
+
_cursor_hidden = False
|
|
99
|
+
_write("\033[?25h")
|
|
100
|
+
if _alt_screen_on:
|
|
101
|
+
_alt_screen_on = False
|
|
102
|
+
_write("\033[?1049l")
|
|
103
|
+
_restore_cbreak_mode()
|
|
104
|
+
if _IS_WINDOWS:
|
|
105
|
+
_win_restore_input_mode()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _drain_input(settle: float = 0.03):
|
|
111
|
+
try:
|
|
112
|
+
if sys.stdin.isatty():
|
|
113
|
+
while _pump(settle):
|
|
114
|
+
pass
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
_mouse_queue.clear()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_MOUSE_RE = re.compile(rb"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
|
|
121
|
+
_CSI_RE = re.compile(rb"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
122
|
+
_SS3_RE = re.compile(rb"\x1bO[@-~]")
|
|
123
|
+
_ESC_PARTIAL_RE = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*|O)?")
|
|
124
|
+
_CPR_RE = re.compile(rb"\x1b\[(\d+);(\d+)R")
|
|
125
|
+
_ESC_TIMEOUT = 0.05
|
|
126
|
+
|
|
127
|
+
_CSI_KEYS = {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT", "H": "HOME", "F": "END",
|
|
128
|
+
"P": "F1", "Q": "F2", "R": "F3", "S": "F4"}
|
|
129
|
+
_SS3_KEYS = dict(_CSI_KEYS)
|
|
130
|
+
_TILDE_KEYS = {"1": "HOME", "2": "INSERT", "3": "DELETE", "4": "END", "5": "PAGEUP",
|
|
131
|
+
"6": "PAGEDOWN", "7": "HOME", "8": "END", "11": "F1", "12": "F2", "13": "F3",
|
|
132
|
+
"14": "F4", "15": "F5", "17": "F6", "18": "F7", "19": "F8", "20": "F9",
|
|
133
|
+
"21": "F10", "23": "F11", "24": "F12"}
|
|
134
|
+
_MOUSE_BUTTON_NAMES = {0: "left", 1: "middle", 2: "right", 3: None}
|
|
135
|
+
|
|
136
|
+
_pending = b""
|
|
137
|
+
_incomplete_since = None
|
|
138
|
+
_seq = itertools.count()
|
|
139
|
+
_key_queue: deque = deque(maxlen=512)
|
|
140
|
+
_mouse_queue: deque = deque(maxlen=512)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class MouseEvent:
|
|
144
|
+
__slots__ = ("type", "button", "x", "y", "shift", "alt", "ctrl")
|
|
145
|
+
kind = "mouse"
|
|
146
|
+
|
|
147
|
+
def __init__(self, type_: str, button: Optional[str], x: int, y: int,
|
|
148
|
+
shift: bool = False, alt: bool = False, ctrl: bool = False):
|
|
149
|
+
self.type = type_
|
|
150
|
+
self.button = button
|
|
151
|
+
self.x = x
|
|
152
|
+
self.y = y
|
|
153
|
+
self.shift = shift
|
|
154
|
+
self.alt = alt
|
|
155
|
+
self.ctrl = ctrl
|
|
156
|
+
|
|
157
|
+
def inside(self, rect) -> bool:
|
|
158
|
+
return rect.contains(self.x, self.y)
|
|
159
|
+
|
|
160
|
+
def __repr__(self):
|
|
161
|
+
mods = "".join(f", {m}=True" for m in ("shift", "alt", "ctrl") if getattr(self, m))
|
|
162
|
+
return f"MouseEvent(type={self.type!r}, button={self.button!r}, x={self.x}, y={self.y}{mods})"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class KeyEvent:
|
|
166
|
+
__slots__ = ("key",)
|
|
167
|
+
kind = "key"
|
|
168
|
+
type = "key"
|
|
169
|
+
|
|
170
|
+
def __init__(self, key: str):
|
|
171
|
+
self.key = key
|
|
172
|
+
|
|
173
|
+
def __eq__(self, other):
|
|
174
|
+
if isinstance(other, KeyEvent):
|
|
175
|
+
return self.key == other.key
|
|
176
|
+
if isinstance(other, str):
|
|
177
|
+
return self.key == other
|
|
178
|
+
return NotImplemented
|
|
179
|
+
|
|
180
|
+
def __hash__(self):
|
|
181
|
+
return hash(self.key)
|
|
182
|
+
|
|
183
|
+
def __str__(self):
|
|
184
|
+
return self.key
|
|
185
|
+
|
|
186
|
+
def __repr__(self):
|
|
187
|
+
return f"KeyEvent({self.key!r})"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_mouse_match(m) -> MouseEvent:
|
|
191
|
+
code, x, y, final = int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4)
|
|
192
|
+
mods = dict(shift=bool(code & 4), alt=bool(code & 8), ctrl=bool(code & 16))
|
|
193
|
+
if code & 64:
|
|
194
|
+
return MouseEvent(("wheel_up", "wheel_down", "wheel_left", "wheel_right")[code & 3],
|
|
195
|
+
None, x, y, **mods)
|
|
196
|
+
if code & 128:
|
|
197
|
+
button = ("back", "forward", "button10", "button11")[code & 3]
|
|
198
|
+
else:
|
|
199
|
+
button = _MOUSE_BUTTON_NAMES.get(code & 3)
|
|
200
|
+
if final == b"m":
|
|
201
|
+
return MouseEvent("release", button, x, y, **mods)
|
|
202
|
+
if code & 32:
|
|
203
|
+
return MouseEvent("move", button, x, y, **mods)
|
|
204
|
+
return MouseEvent("press", button, x, y, **mods)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _decode_csi_key(seq: bytes) -> Optional[str]:
|
|
208
|
+
final = chr(seq[-1])
|
|
209
|
+
params = seq[2:-1].decode("ascii", "ignore").split(";")
|
|
210
|
+
first = params[0]
|
|
211
|
+
if final == "~":
|
|
212
|
+
name = _TILDE_KEYS.get(first)
|
|
213
|
+
elif final == "Z":
|
|
214
|
+
return "SHIFT+TAB"
|
|
215
|
+
elif final in "PQRS":
|
|
216
|
+
name = _CSI_KEYS.get(final) if first in ("", "1") else None
|
|
217
|
+
else:
|
|
218
|
+
name = _CSI_KEYS.get(final)
|
|
219
|
+
if name is None:
|
|
220
|
+
return None
|
|
221
|
+
if len(params) > 1 and params[1].isdigit():
|
|
222
|
+
bits = int(params[1]) - 1
|
|
223
|
+
name = (("SHIFT+" if bits & 1 else "") + ("ALT+" if bits & 2 else "")
|
|
224
|
+
+ ("CTRL+" if bits & 4 else "") + name)
|
|
225
|
+
return name
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_one(buf: bytes, flush: bool):
|
|
229
|
+
b0 = buf[0]
|
|
230
|
+
if b0 == 0x1B:
|
|
231
|
+
m = _MOUSE_RE.match(buf)
|
|
232
|
+
if m:
|
|
233
|
+
return "mouse", _parse_mouse_match(m), m.end()
|
|
234
|
+
m = _CSI_RE.match(buf)
|
|
235
|
+
if m:
|
|
236
|
+
name = _decode_csi_key(m.group())
|
|
237
|
+
return ("key", name, m.end()) if name else ("skip", None, m.end())
|
|
238
|
+
m = _SS3_RE.match(buf)
|
|
239
|
+
if m:
|
|
240
|
+
name = _SS3_KEYS.get(chr(m.group()[2]))
|
|
241
|
+
return ("key", name, m.end()) if name else ("skip", None, m.end())
|
|
242
|
+
if not flush and _ESC_PARTIAL_RE.fullmatch(buf):
|
|
243
|
+
return None
|
|
244
|
+
return "key", "ESC", 1
|
|
245
|
+
if b0 < 0x80:
|
|
246
|
+
if b0 in (0x0D, 0x0A):
|
|
247
|
+
return "key", "ENTER", 1
|
|
248
|
+
if b0 in (0x7F, 0x08):
|
|
249
|
+
return "key", "BACKSPACE", 1
|
|
250
|
+
if b0 == 0x09:
|
|
251
|
+
return "key", "TAB", 1
|
|
252
|
+
if 1 <= b0 <= 26:
|
|
253
|
+
return "key", "CTRL+" + chr(b0 + 64), 1
|
|
254
|
+
if b0 < 0x20:
|
|
255
|
+
return "skip", None, 1
|
|
256
|
+
return "key", chr(b0), 1
|
|
257
|
+
if 0xC0 <= b0 <= 0xF7:
|
|
258
|
+
need = 2 if b0 < 0xE0 else 3 if b0 < 0xF0 else 4
|
|
259
|
+
if len(buf) < need:
|
|
260
|
+
return ("skip", None, len(buf)) if flush else None
|
|
261
|
+
try:
|
|
262
|
+
return "key", buf[:need].decode("utf-8"), need
|
|
263
|
+
except UnicodeDecodeError:
|
|
264
|
+
return "skip", None, 1
|
|
265
|
+
return "skip", None, 1
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_pending(flush: bool = False):
|
|
269
|
+
global _pending, _incomplete_since
|
|
270
|
+
while _pending:
|
|
271
|
+
item = _parse_one(_pending, flush)
|
|
272
|
+
if item is None:
|
|
273
|
+
if _incomplete_since is None:
|
|
274
|
+
_incomplete_since = time.monotonic()
|
|
275
|
+
return
|
|
276
|
+
kind, value, used = item
|
|
277
|
+
_pending = _pending[used:]
|
|
278
|
+
if kind == "key":
|
|
279
|
+
_key_queue.append((next(_seq), value))
|
|
280
|
+
elif kind == "mouse":
|
|
281
|
+
_mouse_queue.append((next(_seq), value))
|
|
282
|
+
_incomplete_since = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _read_raw(timeout: Optional[float]) -> bytes:
|
|
286
|
+
if _IS_WINDOWS:
|
|
287
|
+
return _win_read_bytes(timeout)
|
|
288
|
+
_ensure_cbreak_mode()
|
|
289
|
+
fd = sys.stdin.fileno()
|
|
290
|
+
if not select.select([fd], [], [], timeout)[0]:
|
|
291
|
+
return b""
|
|
292
|
+
data = os.read(fd, 1024)
|
|
293
|
+
if not data:
|
|
294
|
+
raise EOFError("Standard input was closed.")
|
|
295
|
+
return data
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _pump(timeout: Optional[float]) -> bool:
|
|
299
|
+
global _pending, _incomplete_since
|
|
300
|
+
deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
|
|
301
|
+
while True:
|
|
302
|
+
now = time.monotonic()
|
|
303
|
+
wait = None if deadline is None else max(0.0, deadline - now)
|
|
304
|
+
if _incomplete_since is not None:
|
|
305
|
+
left = _incomplete_since + _ESC_TIMEOUT - now
|
|
306
|
+
if left <= 0:
|
|
307
|
+
_incomplete_since = None
|
|
308
|
+
_parse_pending(flush=True)
|
|
309
|
+
return True
|
|
310
|
+
wait = left if wait is None else min(wait, left)
|
|
311
|
+
data = _read_raw(wait)
|
|
312
|
+
if data:
|
|
313
|
+
_pending += data
|
|
314
|
+
_incomplete_since = None
|
|
315
|
+
_parse_pending()
|
|
316
|
+
return True
|
|
317
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _wait_for(pop: Callable, timeout: Optional[float]):
|
|
322
|
+
end = None if timeout is None else time.monotonic() + timeout
|
|
323
|
+
while True:
|
|
324
|
+
item = pop()
|
|
325
|
+
if item is not None:
|
|
326
|
+
return item
|
|
327
|
+
remaining = None if end is None else max(0.0, end - time.monotonic())
|
|
328
|
+
_pump(remaining)
|
|
329
|
+
item = pop()
|
|
330
|
+
if item is not None:
|
|
331
|
+
return item
|
|
332
|
+
if end is not None and time.monotonic() >= end:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _pop_key() -> Optional[str]:
|
|
337
|
+
return _key_queue.popleft()[1] if _key_queue else None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _pop_mouse() -> Optional[MouseEvent]:
|
|
341
|
+
return _mouse_queue.popleft()[1] if _mouse_queue else None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _pop_event():
|
|
345
|
+
if _key_queue and (not _mouse_queue or _key_queue[0][0] < _mouse_queue[0][0]):
|
|
346
|
+
return KeyEvent(_key_queue.popleft()[1])
|
|
347
|
+
return _pop_mouse()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_key(timeout: Optional[float] = None) -> str:
|
|
351
|
+
with _scoped_cbreak():
|
|
352
|
+
key = _wait_for(_pop_key, timeout)
|
|
353
|
+
return "" if key is None else key
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_keys() -> List[str]:
|
|
357
|
+
while _pump(0):
|
|
358
|
+
pass
|
|
359
|
+
keys = [k for _, k in _key_queue]
|
|
360
|
+
_key_queue.clear()
|
|
361
|
+
return keys
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_key_nonblocking() -> List[str]:
|
|
365
|
+
return get_keys()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def get_mouse_event(timeout: Optional[float] = None) -> Optional[MouseEvent]:
|
|
369
|
+
return _wait_for(_pop_mouse, timeout)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def get_mouse_event_nonblocking() -> Optional[MouseEvent]:
|
|
373
|
+
return get_mouse_event(timeout=0)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_event(timeout: Optional[float] = None) -> Optional[Union[KeyEvent, MouseEvent]]:
|
|
377
|
+
return _wait_for(_pop_event, timeout)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def events(timeout: Optional[float] = None) -> Iterator[Optional[Union[KeyEvent, MouseEvent]]]:
|
|
381
|
+
while True:
|
|
382
|
+
yield get_event(timeout)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _query_cursor_position() -> Tuple[int, int]:
|
|
386
|
+
global _pending
|
|
387
|
+
if _IS_WINDOWS:
|
|
388
|
+
return _win_get_cursor_position()
|
|
389
|
+
_flush_pending_frame()
|
|
390
|
+
fd = sys.stdin.fileno()
|
|
391
|
+
sys.stdout.write("\033[6n")
|
|
392
|
+
sys.stdout.flush()
|
|
393
|
+
deadline = time.monotonic() + 2.0
|
|
394
|
+
buf = b""
|
|
395
|
+
while True:
|
|
396
|
+
left = deadline - time.monotonic()
|
|
397
|
+
if left <= 0 or not select.select([fd], [], [], left)[0]:
|
|
398
|
+
raise TimeoutError("The terminal never answered the cursor-position query.")
|
|
399
|
+
data = os.read(fd, 64)
|
|
400
|
+
if not data:
|
|
401
|
+
raise EOFError("Standard input was closed.")
|
|
402
|
+
buf += data
|
|
403
|
+
m = _CPR_RE.search(buf)
|
|
404
|
+
if m:
|
|
405
|
+
rest = buf[:m.start()] + buf[m.end():]
|
|
406
|
+
if rest:
|
|
407
|
+
_pending += rest
|
|
408
|
+
_parse_pending()
|
|
409
|
+
return int(m.group(1)), int(m.group(2))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
gcp = _query_cursor_position
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def get_cursor_position() -> Tuple[int, int]:
|
|
416
|
+
if _IS_WINDOWS:
|
|
417
|
+
return _win_get_cursor_position()
|
|
418
|
+
fd = sys.stdin.fileno()
|
|
419
|
+
old = termios.tcgetattr(fd)
|
|
420
|
+
try:
|
|
421
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
422
|
+
return _query_cursor_position()
|
|
423
|
+
finally:
|
|
424
|
+
termios.tcsetattr(fd, termios.TCSANOW, old)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_terminal_size() -> Tuple[int, int]:
|
|
428
|
+
if _IS_WINDOWS:
|
|
429
|
+
return _win_get_terminal_size()
|
|
430
|
+
try:
|
|
431
|
+
fd = sys.stdin.fileno()
|
|
432
|
+
old = termios.tcgetattr(fd)
|
|
433
|
+
except (OSError, ValueError, termios.error):
|
|
434
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
435
|
+
return size.columns, size.lines
|
|
436
|
+
try:
|
|
437
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
438
|
+
_write("\033[s\033[999;999H")
|
|
439
|
+
height, width = _query_cursor_position()
|
|
440
|
+
_write("\033[u")
|
|
441
|
+
return width, height
|
|
442
|
+
finally:
|
|
443
|
+
termios.tcsetattr(fd, termios.TCSANOW, old)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def clear_screen(scrollback: bool = True):
|
|
447
|
+
_pixel_buf.clear()
|
|
448
|
+
_write("\033[2J\033[H" + ("\033[3J" if scrollback else ""))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def set_cursor_col(col: int):
|
|
452
|
+
_write(f"\033[{col}G")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def set_cursor_row(row: int):
|
|
456
|
+
_write(f"\033[{row}d")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def set_cursor_pos(row: int, col: int):
|
|
460
|
+
_write(f"\033[{row};{col}H")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def move_cursor(dx: int = 0, dy: int = 0):
|
|
464
|
+
out = ""
|
|
465
|
+
if dy < 0:
|
|
466
|
+
out += f"\033[{-dy}A"
|
|
467
|
+
elif dy > 0:
|
|
468
|
+
out += f"\033[{dy}B"
|
|
469
|
+
if dx > 0:
|
|
470
|
+
out += f"\033[{dx}C"
|
|
471
|
+
elif dx < 0:
|
|
472
|
+
out += f"\033[{-dx}D"
|
|
473
|
+
if out:
|
|
474
|
+
_write(out)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def save_cursor_position():
|
|
478
|
+
_write("\0337")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def restore_cursor_position():
|
|
482
|
+
_write("\0338")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def hide_cursor():
|
|
486
|
+
global _cursor_hidden
|
|
487
|
+
_cursor_hidden = True
|
|
488
|
+
_register_cleanup()
|
|
489
|
+
_write("\033[?25l")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def show_cursor():
|
|
493
|
+
global _cursor_hidden
|
|
494
|
+
_cursor_hidden = False
|
|
495
|
+
_write("\033[?25h")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def enter_alt_screen():
|
|
499
|
+
global _alt_screen_on
|
|
500
|
+
_alt_screen_on = True
|
|
501
|
+
_pixel_buf.clear()
|
|
502
|
+
_register_cleanup()
|
|
503
|
+
_write("\033[?1049h")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def leave_alt_screen():
|
|
507
|
+
global _alt_screen_on
|
|
508
|
+
_alt_screen_on = False
|
|
509
|
+
_pixel_buf.clear()
|
|
510
|
+
_write("\033[?1049l")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def clear_line(mode: str = "full"):
|
|
514
|
+
codes = {"full": "2", "to_end": "0", "to_start": "1"}
|
|
515
|
+
if mode not in codes:
|
|
516
|
+
raise ValueError(f"Unknown clear_line mode: '{mode}'. Use 'full', 'to_end', or 'to_start'.")
|
|
517
|
+
_write(f"\033[{codes[mode]}K")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def enable_mouse_tracking():
|
|
521
|
+
global _mouse_on
|
|
522
|
+
if _IS_WINDOWS:
|
|
523
|
+
try:
|
|
524
|
+
_win_enable_vt_output()
|
|
525
|
+
_win_enable_vt_input()
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
_mouse_on = True
|
|
529
|
+
_register_cleanup()
|
|
530
|
+
_write("\033[?1000h\033[?1003h\033[?1006h")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def disable_mouse_tracking():
|
|
534
|
+
global _mouse_on
|
|
535
|
+
_mouse_on = False
|
|
536
|
+
_write("\033[?1000l\033[?1003l\033[?1006l")
|
|
537
|
+
_drain_input()
|
|
538
|
+
if _IS_WINDOWS:
|
|
539
|
+
_win_restore_input_mode()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def print_at(row: int, col: int, text, clear_to_end: bool = False) -> Rect:
|
|
543
|
+
lines = str(text).split("\n")
|
|
544
|
+
widest = 0
|
|
545
|
+
with frame():
|
|
546
|
+
for i, line in enumerate(lines):
|
|
547
|
+
_write(f"\033[{row + i};{col}H{line}" + ("\033[K" if clear_to_end else ""))
|
|
548
|
+
widest = max(widest, _visible_len(line))
|
|
549
|
+
return Rect(row, col, widest, len(lines))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@contextlib.contextmanager
|
|
553
|
+
def app(mouse: bool = False, cursor: bool = False, clear: bool = True,
|
|
554
|
+
alt_screen: bool = True, catch_interrupt: bool = True):
|
|
555
|
+
try:
|
|
556
|
+
if alt_screen:
|
|
557
|
+
enter_alt_screen()
|
|
558
|
+
if clear:
|
|
559
|
+
clear_screen(scrollback=False)
|
|
560
|
+
if not cursor:
|
|
561
|
+
hide_cursor()
|
|
562
|
+
if mouse:
|
|
563
|
+
enable_mouse_tracking()
|
|
564
|
+
yield
|
|
565
|
+
except KeyboardInterrupt:
|
|
566
|
+
if not catch_interrupt:
|
|
567
|
+
raise
|
|
568
|
+
finally:
|
|
569
|
+
restore_terminal()
|
|
570
|
+
_key_queue.clear()
|
|
571
|
+
_mouse_queue.clear()
|
|
572
|
+
if not alt_screen:
|
|
573
|
+
_write("\033[999;1H\r\n")
|