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.
@@ -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")