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/__init__.py +45 -0
- ttyengine/ansi.py +201 -0
- ttyengine/ascii_styler.py +473 -0
- ttyengine/components.py +4718 -0
- ttyengine/compositor.py +780 -0
- ttyengine/errors.py +181 -0
- ttyengine/figlet-LICENSE.txt +27 -0
- ttyengine/figlet.py +430 -0
- ttyengine/fonts.zip +0 -0
- ttyengine/image_reader.py +813 -0
- ttyengine/input_handler.py +814 -0
- ttyengine/launcher.py +822 -0
- ttyengine/region.py +180 -0
- ttyengine/scene.py +614 -0
- ttyengine/sound.py +1439 -0
- ttyengine/terminal.py +283 -0
- ttyengine-0.1.0.dist-info/METADATA +903 -0
- ttyengine-0.1.0.dist-info/RECORD +21 -0
- ttyengine-0.1.0.dist-info/WHEEL +5 -0
- ttyengine-0.1.0.dist-info/licenses/LICENSE +373 -0
- ttyengine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import sys, re, os, threading, time, inspect, traceback, atexit, codecs, weakref
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import keyboard
|
|
6
|
+
except Exception:
|
|
7
|
+
keyboard = None
|
|
8
|
+
|
|
9
|
+
from . import ansi
|
|
10
|
+
from .terminal import Terminal
|
|
11
|
+
|
|
12
|
+
_WINDOWS = sys.platform == 'win32'
|
|
13
|
+
if _WINDOWS:
|
|
14
|
+
import msvcrt, ctypes
|
|
15
|
+
from ctypes import wintypes
|
|
16
|
+
_kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
17
|
+
_kernel32.GetStdHandle.restype = wintypes.HANDLE
|
|
18
|
+
_kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
|
|
19
|
+
_kernel32.GetConsoleMode.restype = wintypes.BOOL
|
|
20
|
+
_kernel32.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
21
|
+
_kernel32.SetConsoleMode.restype = wintypes.BOOL
|
|
22
|
+
_kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
23
|
+
_ENABLE_MOUSE_INPUT = 0x0010
|
|
24
|
+
_ENABLE_QUICK_EDIT_MODE = 0x0040
|
|
25
|
+
_ENABLE_EXTENDED_FLAGS = 0x0080
|
|
26
|
+
_ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
|
|
27
|
+
_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
28
|
+
_STD_INPUT_HANDLE = -10 & 0xFFFFFFFF
|
|
29
|
+
_STD_OUTPUT_HANDLE = -11 & 0xFFFFFFFF
|
|
30
|
+
_WIN_SCAN = {'H': ansi.csi('', 'A'), 'P': ansi.csi('', 'B'), 'M': ansi.csi('', 'C'), 'K': ansi.csi('', 'D'),
|
|
31
|
+
'G': ansi.csi('', 'H'), 'O': ansi.csi('', 'F'), 'I': ansi.csi('5', '~'), 'Q': ansi.csi('6', '~'),
|
|
32
|
+
'R': ansi.csi('2', '~'), 'S': ansi.csi('3', '~')}
|
|
33
|
+
else:
|
|
34
|
+
import select
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_tty():
|
|
38
|
+
try:
|
|
39
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
40
|
+
except Exception:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _accepts_arg(cb):
|
|
45
|
+
try:
|
|
46
|
+
sig = inspect.signature(cb)
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return True
|
|
49
|
+
for p in sig.parameters.values():
|
|
50
|
+
if p.kind == p.VAR_POSITIONAL:
|
|
51
|
+
return True
|
|
52
|
+
positional = [p for p in sig.parameters.values()
|
|
53
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
|
|
54
|
+
return len(positional) >= 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _positional_count(cb):
|
|
58
|
+
try:
|
|
59
|
+
sig = inspect.signature(cb)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return None
|
|
62
|
+
count = 0
|
|
63
|
+
for p in sig.parameters.values():
|
|
64
|
+
if p.kind == p.VAR_POSITIONAL:
|
|
65
|
+
return None
|
|
66
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
|
|
67
|
+
count += 1
|
|
68
|
+
return count
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _FakeKeyEvent:
|
|
72
|
+
__slots__ = ('event_type', 'name', 'ident')
|
|
73
|
+
def __init__(self, event_type, name, ident=None):
|
|
74
|
+
self.event_type = event_type
|
|
75
|
+
self.name = name
|
|
76
|
+
self.ident = ident
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _FallbackKeyboard:
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self._cb = None
|
|
82
|
+
|
|
83
|
+
def hook(self, callback, suppress=False):
|
|
84
|
+
self._cb = callback
|
|
85
|
+
return callback
|
|
86
|
+
|
|
87
|
+
def unhook_all(self):
|
|
88
|
+
self._cb = None
|
|
89
|
+
|
|
90
|
+
def feed(self, name):
|
|
91
|
+
if self._cb:
|
|
92
|
+
self._cb(_FakeKeyEvent('down', name, ('t', name)))
|
|
93
|
+
self._cb(_FakeKeyEvent('up', name, ('t', name)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_ARROW_LETTERS = {'A': 'up', 'B': 'down', 'C': 'right', 'D': 'left',
|
|
97
|
+
'H': 'home', 'F': 'end', 'P': 'f1', 'Q': 'f2', 'R': 'f3', 'S': 'f4'}
|
|
98
|
+
_SS3_KEYS = dict(_ARROW_LETTERS)
|
|
99
|
+
_TILDE_KEYS = {1: 'home', 2: 'insert', 3: 'delete', 4: 'end', 5: 'page up',
|
|
100
|
+
6: 'page down', 7: 'home', 8: 'end', 11: 'f1', 12: 'f2', 13: 'f3',
|
|
101
|
+
14: 'f4', 15: 'f5', 17: 'f6', 18: 'f7', 19: 'f8', 20: 'f9',
|
|
102
|
+
21: 'f10', 23: 'f11', 24: 'f12'}
|
|
103
|
+
_KITTY_NAMES = {27: 'esc', 13: 'enter', 9: 'tab', 127: 'backspace', 8: 'backspace',
|
|
104
|
+
57358: 'caps lock', 57359: 'scroll lock', 57360: 'num lock',
|
|
105
|
+
57361: 'print screen', 57362: 'pause', 57363: 'menu',
|
|
106
|
+
57409: '.', 57410: '/', 57411: '*', 57412: '-', 57413: '+',
|
|
107
|
+
57414: 'enter', 57415: '=', 57441: 'shift', 57447: 'shift',
|
|
108
|
+
57442: 'ctrl', 57448: 'ctrl', 57443: 'alt', 57449: 'alt',
|
|
109
|
+
57444: 'windows', 57450: 'windows', 57453: 'alt gr'}
|
|
110
|
+
|
|
111
|
+
def _kitty_name(code):
|
|
112
|
+
name = _KITTY_NAMES.get(code)
|
|
113
|
+
if name:
|
|
114
|
+
return name
|
|
115
|
+
if 57376 <= code <= 57398:
|
|
116
|
+
return 'f%d' % (code - 57376 + 13)
|
|
117
|
+
if 57399 <= code <= 57408:
|
|
118
|
+
return str(code - 57399)
|
|
119
|
+
if 0 < code < 0x110000 and chr(code).isprintable():
|
|
120
|
+
return 'space' if code == 32 else chr(code).lower()
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
_CSI_RE = re.compile(ansi.CSI_RE + r'([0-9:;<=>?]*)([ -/]*)([@-~])')
|
|
124
|
+
_CSI_PARTIAL_RE = re.compile(ansi.CSI_RE + r'[0-9:;<=>?]*[ -/]*')
|
|
125
|
+
_ESC_TIMEOUT = 0.05
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class inp():
|
|
129
|
+
def __init__(self, term=None, watchfocus=True, trackmotion=False,
|
|
130
|
+
legacy_hold=0.1, quiet=True):
|
|
131
|
+
self.lock = threading.RLock()
|
|
132
|
+
self.wf = watchfocus
|
|
133
|
+
self.track_motion = trackmotion
|
|
134
|
+
self.focused = True
|
|
135
|
+
self._term = None
|
|
136
|
+
self.legacy_hold = legacy_hold
|
|
137
|
+
self.quiet = quiet
|
|
138
|
+
|
|
139
|
+
# ── keyboard (library handles held/press/release natively) ────────
|
|
140
|
+
self._press_cbs = defaultdict(list)
|
|
141
|
+
self._release_cbs = defaultdict(list)
|
|
142
|
+
self._events = deque()
|
|
143
|
+
self._down = {}
|
|
144
|
+
self._pressed_keys = set()
|
|
145
|
+
self._release_at = {}
|
|
146
|
+
self._last_press = {} # name -> monotonic time of last terminal press (legacy tier)
|
|
147
|
+
self._delayed_rel = {} # (name, id(entry)) -> (name, entry, due) for onrelease(delay=...)
|
|
148
|
+
self._key_cbs = []
|
|
149
|
+
self._kitty_state = None
|
|
150
|
+
self._kitty_t = 0.0
|
|
151
|
+
self._kb_is_fallback = False
|
|
152
|
+
self._kb_hook = None
|
|
153
|
+
self.keyboard = None
|
|
154
|
+
|
|
155
|
+
# Tier 1: the `keyboard` library (needs root on Linux/macOS and a local
|
|
156
|
+
# keyboard, so it is skipped when either is missing).
|
|
157
|
+
if keyboard is not None and self._keyboard_usable():
|
|
158
|
+
try:
|
|
159
|
+
self._kb_hook = keyboard.hook(self._kb_event_raw, suppress=False)
|
|
160
|
+
self.keyboard = keyboard
|
|
161
|
+
except Exception:
|
|
162
|
+
self.keyboard = None
|
|
163
|
+
|
|
164
|
+
# Tier 2/3: terminal input. Kitty keyboard protocol (true press/release)
|
|
165
|
+
# if the terminal answers the probe, else raw stdin (press-only, held()
|
|
166
|
+
# is emulated and only stays True while key repeats keep arriving).
|
|
167
|
+
if self.keyboard is None:
|
|
168
|
+
self.keyboard = _FallbackKeyboard()
|
|
169
|
+
self._kb_is_fallback = True
|
|
170
|
+
self._kb_hook = self.keyboard.hook(self._kb_event_raw, suppress=False)
|
|
171
|
+
if not self.quiet:
|
|
172
|
+
sys.stderr.write(
|
|
173
|
+
"input_handler: low-level keyboard unavailable, using terminal "
|
|
174
|
+
"input: kitty keyboard protocol if supported, otherwise "
|
|
175
|
+
"raw stdin (press-only, held() needs key repeat).\n"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ── mouse (raw VT sequence parser) ────────────────────────────────
|
|
179
|
+
self._mouse_x = 0
|
|
180
|
+
self._mouse_y = 0
|
|
181
|
+
self._mouse_held = set() # 0=left, 1=middle, 2=right
|
|
182
|
+
self._mouse_wheel = 0
|
|
183
|
+
self._mouse_cbs = []
|
|
184
|
+
self._buf = '' # raw stdin accumulation buffer
|
|
185
|
+
self._buf_t = 0.0
|
|
186
|
+
self._dec = codecs.getincrementaldecoder('utf-8')('replace')
|
|
187
|
+
|
|
188
|
+
# ── Windows console saved modes ───────────────────────────────────
|
|
189
|
+
self._win_saved_in = None
|
|
190
|
+
self._win_saved_out = None
|
|
191
|
+
self._win_vt_in = False
|
|
192
|
+
self._cleaned = False
|
|
193
|
+
|
|
194
|
+
ref = weakref.ref(self)
|
|
195
|
+
atexit.register(lambda: ref() and ref().cleanup())
|
|
196
|
+
|
|
197
|
+
if term is None and _is_tty():
|
|
198
|
+
term = Terminal()
|
|
199
|
+
self.term = term # triggers setter
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _keyboard_usable():
|
|
203
|
+
if _WINDOWS:
|
|
204
|
+
return True
|
|
205
|
+
try:
|
|
206
|
+
if os.geteuid() != 0: # keyboard lib needs root on Linux/macOS
|
|
207
|
+
return False
|
|
208
|
+
except AttributeError:
|
|
209
|
+
return False
|
|
210
|
+
# a global hook sees the *machine's* keyboard, not an SSH client's
|
|
211
|
+
if os.environ.get('SSH_CONNECTION') or os.environ.get('SSH_TTY'):
|
|
212
|
+
return False
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def backend(self):
|
|
217
|
+
if not self._kb_is_fallback:
|
|
218
|
+
return 'keyboard'
|
|
219
|
+
if self._kitty_state == 'on':
|
|
220
|
+
return 'kitty'
|
|
221
|
+
return 'terminal'
|
|
222
|
+
|
|
223
|
+
#TERMINAL SETUP
|
|
224
|
+
@property
|
|
225
|
+
def term(self):
|
|
226
|
+
return self._term
|
|
227
|
+
|
|
228
|
+
@term.setter
|
|
229
|
+
def term(self, value):
|
|
230
|
+
self._term = value
|
|
231
|
+
if value:
|
|
232
|
+
self._cleaned = False
|
|
233
|
+
if _WINDOWS:
|
|
234
|
+
self._setup_windows_console()
|
|
235
|
+
if self.wf:
|
|
236
|
+
self._enable_focus_reporting()
|
|
237
|
+
self._enable_mouse()
|
|
238
|
+
if self._kb_is_fallback:
|
|
239
|
+
self._start_kitty_probe()
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _write(s):
|
|
243
|
+
try:
|
|
244
|
+
sys.stdout.write(ansi.wrap_all(s)); sys.stdout.flush()
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def _setup_windows_console(self):
|
|
249
|
+
mode = wintypes.DWORD()
|
|
250
|
+
hin = _kernel32.GetStdHandle(_STD_INPUT_HANDLE)
|
|
251
|
+
hout = _kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
|
|
252
|
+
if self._win_saved_in is None and _kernel32.GetConsoleMode(hin, ctypes.byref(mode)):
|
|
253
|
+
self._win_saved_in = mode.value
|
|
254
|
+
new_in = (
|
|
255
|
+
(mode.value | _ENABLE_VIRTUAL_TERMINAL_INPUT
|
|
256
|
+
| _ENABLE_MOUSE_INPUT
|
|
257
|
+
| _ENABLE_EXTENDED_FLAGS)
|
|
258
|
+
& ~_ENABLE_QUICK_EDIT_MODE
|
|
259
|
+
)
|
|
260
|
+
self._win_vt_in = bool(_kernel32.SetConsoleMode(hin, new_in))
|
|
261
|
+
if self._win_saved_out is None and _kernel32.GetConsoleMode(hout, ctypes.byref(mode)):
|
|
262
|
+
self._win_saved_out = mode.value
|
|
263
|
+
_kernel32.SetConsoleMode(hout, mode.value | _ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
|
264
|
+
|
|
265
|
+
def _enable_focus_reporting(self):
|
|
266
|
+
self._write(ansi.mode(1004, True))
|
|
267
|
+
|
|
268
|
+
def _disable_focus_reporting(self):
|
|
269
|
+
self._write(ansi.mode(1004, False))
|
|
270
|
+
|
|
271
|
+
def _enable_mouse(self):
|
|
272
|
+
codes = ansi.mode(1000, True) + ansi.mode(1002, True) + ansi.mode(1006, True)
|
|
273
|
+
if self.track_motion:
|
|
274
|
+
codes += ansi.mode(1003, True)
|
|
275
|
+
self._write(codes)
|
|
276
|
+
|
|
277
|
+
def _disable_mouse(self):
|
|
278
|
+
self._write(ansi.mode(1003, False) + ansi.mode(1002, False) + ansi.mode(1000, False) + ansi.mode(1006, False))
|
|
279
|
+
|
|
280
|
+
def _start_kitty_probe(self):
|
|
281
|
+
if self._kitty_state is not None:
|
|
282
|
+
return
|
|
283
|
+
try:
|
|
284
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
285
|
+
return
|
|
286
|
+
except Exception:
|
|
287
|
+
return
|
|
288
|
+
self._kitty_state, self._kitty_t = 'probing', time.monotonic()
|
|
289
|
+
self._write(ansi.csi('?', 'u') + ansi.csi('', 'c'))
|
|
290
|
+
|
|
291
|
+
#KEYBOARD
|
|
292
|
+
def _kb_event_raw(self, event):
|
|
293
|
+
ident = getattr(event, 'ident', None)
|
|
294
|
+
if ident is None:
|
|
295
|
+
ident = getattr(event, 'scan_code', None)
|
|
296
|
+
if ident is None:
|
|
297
|
+
ident = event.name
|
|
298
|
+
if event.event_type not in ('down', 'up'):
|
|
299
|
+
return
|
|
300
|
+
with self.lock:
|
|
301
|
+
self._events.append((ident, event))
|
|
302
|
+
|
|
303
|
+
def _release_all(self):
|
|
304
|
+
with self.lock:
|
|
305
|
+
pending = dict(self._down)
|
|
306
|
+
for ident, event in self._events:
|
|
307
|
+
if event.event_type == 'down':
|
|
308
|
+
pending[ident] = event.name
|
|
309
|
+
else:
|
|
310
|
+
pending.pop(ident, None)
|
|
311
|
+
for ident, name in pending.items():
|
|
312
|
+
self._events.append((ident, _FakeKeyEvent('up', name, ident)))
|
|
313
|
+
self._release_at.clear()
|
|
314
|
+
self._last_press.clear()
|
|
315
|
+
self._delayed_rel.clear()
|
|
316
|
+
|
|
317
|
+
def _legacy_mode(self):
|
|
318
|
+
# True only for the last tier: terminal input without kitty's real
|
|
319
|
+
# press/release events (raw stdin), where held() is emulated.
|
|
320
|
+
return self._kb_is_fallback and self._kitty_state != 'on'
|
|
321
|
+
|
|
322
|
+
def _term_press(self, name):
|
|
323
|
+
now = time.monotonic()
|
|
324
|
+
self._kb_event_raw(_FakeKeyEvent('down', name, ('t', name)))
|
|
325
|
+
self._release_at[name] = now + self.legacy_hold
|
|
326
|
+
self._last_press[name] = now
|
|
327
|
+
for entry in self._release_cbs.get(name, ()):
|
|
328
|
+
if entry[3] is not None: # per-callback release delay: (re)arm
|
|
329
|
+
self._delayed_rel[(name, id(entry))] = (name, entry, now + entry[3])
|
|
330
|
+
|
|
331
|
+
def _flush_releases(self, now):
|
|
332
|
+
if self._release_at:
|
|
333
|
+
for name, t in list(self._release_at.items()):
|
|
334
|
+
if now >= t:
|
|
335
|
+
del self._release_at[name]
|
|
336
|
+
self._kb_event_raw(_FakeKeyEvent('up', name, ('t', name)))
|
|
337
|
+
|
|
338
|
+
def _stdin_press(self, name):
|
|
339
|
+
if self._kb_is_fallback and self._kitty_state != 'on':
|
|
340
|
+
self._term_press(name)
|
|
341
|
+
|
|
342
|
+
def _term_event(self, name, ident, ev):
|
|
343
|
+
if not self._kb_is_fallback:
|
|
344
|
+
return
|
|
345
|
+
if self._kitty_state == 'on':
|
|
346
|
+
self._kb_event_raw(_FakeKeyEvent('up' if ev == 3 else 'down', name, ident))
|
|
347
|
+
elif ev != 3:
|
|
348
|
+
self._term_press(name)
|
|
349
|
+
|
|
350
|
+
#MOUSE
|
|
351
|
+
def _read_raw(self):
|
|
352
|
+
take = getattr(self._term, 'take', None)
|
|
353
|
+
return (take() if callable(take) else '') + self._read_stdin()
|
|
354
|
+
|
|
355
|
+
def _read_stdin(self):
|
|
356
|
+
if _WINDOWS:
|
|
357
|
+
chars = []
|
|
358
|
+
while msvcrt.kbhit():
|
|
359
|
+
ch = msvcrt.getwch()
|
|
360
|
+
if ch in ('\x00', '\xe0') and not self._win_vt_in:
|
|
361
|
+
chars.append(_WIN_SCAN.get(msvcrt.getwch(), ''))
|
|
362
|
+
else:
|
|
363
|
+
chars.append(ch)
|
|
364
|
+
return ''.join(chars)
|
|
365
|
+
try:
|
|
366
|
+
fd = sys.stdin.fileno()
|
|
367
|
+
except Exception:
|
|
368
|
+
return ''
|
|
369
|
+
chunks = []
|
|
370
|
+
while True:
|
|
371
|
+
try:
|
|
372
|
+
r, _, _ = select.select([fd], [], [], 0)
|
|
373
|
+
if not r:
|
|
374
|
+
break
|
|
375
|
+
data = os.read(fd, 4096)
|
|
376
|
+
except (OSError, ValueError):
|
|
377
|
+
break
|
|
378
|
+
if not data:
|
|
379
|
+
break
|
|
380
|
+
chunks.append(data)
|
|
381
|
+
if len(data) < 4096:
|
|
382
|
+
break
|
|
383
|
+
return self._dec.decode(b''.join(chunks)) if chunks else ''
|
|
384
|
+
|
|
385
|
+
def _parse_input(self, now):
|
|
386
|
+
buf = self._buf
|
|
387
|
+
n = len(buf)
|
|
388
|
+
if not n:
|
|
389
|
+
return
|
|
390
|
+
stale = bool(self._buf_t) and (now - self._buf_t) >= _ESC_TIMEOUT
|
|
391
|
+
i = 0
|
|
392
|
+
while i < n:
|
|
393
|
+
c = buf[i]
|
|
394
|
+
if c != ansi.ESC:
|
|
395
|
+
name = self._char_to_keyname(c)
|
|
396
|
+
if name:
|
|
397
|
+
self._stdin_press(name)
|
|
398
|
+
i += 1; continue
|
|
399
|
+
if i + 1 >= n:
|
|
400
|
+
if stale:
|
|
401
|
+
self._stdin_press('esc')
|
|
402
|
+
i += 1; continue
|
|
403
|
+
break
|
|
404
|
+
nxt = buf[i + 1]
|
|
405
|
+
if nxt == ansi.CSI_INTRO:
|
|
406
|
+
if buf.startswith(ansi.CSI + 'M', i):
|
|
407
|
+
if n - i < 6:
|
|
408
|
+
if stale: i += 3; continue
|
|
409
|
+
break
|
|
410
|
+
self._handle_x10(ord(buf[i+3])-32, ord(buf[i+4])-33, ord(buf[i+5])-33)
|
|
411
|
+
i += 6; continue
|
|
412
|
+
m = _CSI_RE.match(buf, i)
|
|
413
|
+
if m:
|
|
414
|
+
self._on_csi(m.group(1), m.group(3))
|
|
415
|
+
i = m.end(); continue
|
|
416
|
+
if n - i < 64 and not stale:
|
|
417
|
+
break
|
|
418
|
+
i = _CSI_PARTIAL_RE.match(buf, i).end(); continue
|
|
419
|
+
if nxt == ansi.SS3_INTRO:
|
|
420
|
+
if n - i < 3:
|
|
421
|
+
if stale:
|
|
422
|
+
self._stdin_press('esc')
|
|
423
|
+
i += 1; continue
|
|
424
|
+
break
|
|
425
|
+
name = _SS3_KEYS.get(buf[i + 2])
|
|
426
|
+
if name:
|
|
427
|
+
self._stdin_press(name)
|
|
428
|
+
i += 3; continue
|
|
429
|
+
self._stdin_press('esc')
|
|
430
|
+
i += 1
|
|
431
|
+
left = buf[i:]
|
|
432
|
+
if not left:
|
|
433
|
+
self._buf_t = 0.0
|
|
434
|
+
elif i > 0 or not self._buf_t:
|
|
435
|
+
self._buf_t = now
|
|
436
|
+
self._buf = left
|
|
437
|
+
|
|
438
|
+
def _on_csi(self, params, final):
|
|
439
|
+
if params[:1] == '<' and final in 'Mm':
|
|
440
|
+
p = params[1:].split(';')
|
|
441
|
+
if len(p) == 3 and all(x.isdigit() for x in p):
|
|
442
|
+
self._handle_sgr(int(p[0]), int(p[1]) - 1, int(p[2]) - 1, final)
|
|
443
|
+
return
|
|
444
|
+
if not params and final in 'IO':
|
|
445
|
+
self._set_focus(final == 'I')
|
|
446
|
+
return
|
|
447
|
+
if params[:1] == '?':
|
|
448
|
+
if final == 'u' and self._kitty_state == 'probing':
|
|
449
|
+
self._kitty_state = 'on'
|
|
450
|
+
self._write(ansi.csi('>11', 'u'))
|
|
451
|
+
elif final == 'c' and self._kitty_state == 'probing':
|
|
452
|
+
self._kitty_state = 'off'
|
|
453
|
+
return
|
|
454
|
+
if final == 'Z':
|
|
455
|
+
self._term_event('tab', ('t', 'tab'), 1)
|
|
456
|
+
return
|
|
457
|
+
if final not in 'u~' and final not in _ARROW_LETTERS:
|
|
458
|
+
return
|
|
459
|
+
f = params.split(';')
|
|
460
|
+
try:
|
|
461
|
+
code = int(f[0].split(':')[0]) if f[0] else 1
|
|
462
|
+
ev = 1
|
|
463
|
+
if len(f) > 1 and ':' in f[1]:
|
|
464
|
+
e = f[1].split(':')[1]
|
|
465
|
+
ev = int(e) if e else 1
|
|
466
|
+
except ValueError:
|
|
467
|
+
return
|
|
468
|
+
if final == 'u':
|
|
469
|
+
name, ident = _kitty_name(code), ('t', code)
|
|
470
|
+
elif final == '~':
|
|
471
|
+
name = _TILDE_KEYS.get(code); ident = ('t', name)
|
|
472
|
+
else:
|
|
473
|
+
name = _ARROW_LETTERS[final]; ident = ('t', name)
|
|
474
|
+
if name:
|
|
475
|
+
self._term_event(name, ident, ev)
|
|
476
|
+
|
|
477
|
+
def _set_focus(self, value):
|
|
478
|
+
with self.lock:
|
|
479
|
+
self.focused = value
|
|
480
|
+
if not value and self._kb_is_fallback:
|
|
481
|
+
self._release_all()
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _char_to_keyname(ch):
|
|
485
|
+
if ch == ' ':
|
|
486
|
+
return 'space'
|
|
487
|
+
if ch in ('\r', '\n'):
|
|
488
|
+
return 'enter'
|
|
489
|
+
if ch == '\t':
|
|
490
|
+
return 'tab'
|
|
491
|
+
if ch in ('\x7f', '\x08'):
|
|
492
|
+
return 'backspace'
|
|
493
|
+
if ch == ansi.ESC:
|
|
494
|
+
return 'esc'
|
|
495
|
+
if ch.isprintable():
|
|
496
|
+
return ch.lower()
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
def _handle_sgr(self, btn, x, y, action):
|
|
500
|
+
self._mouse_x, self._mouse_y = x, y
|
|
501
|
+
is_scroll = btn & 64
|
|
502
|
+
is_motion = btn & 32
|
|
503
|
+
button = btn & 3
|
|
504
|
+
if is_scroll:
|
|
505
|
+
if button > 1:
|
|
506
|
+
return
|
|
507
|
+
self._mouse_wheel += 1 if button == 0 else -1
|
|
508
|
+
self._fire_mouse('wheel', x, y, self._mouse_wheel)
|
|
509
|
+
elif action == 'M':
|
|
510
|
+
if is_motion:
|
|
511
|
+
self._fire_mouse('move', x, y, button)
|
|
512
|
+
else:
|
|
513
|
+
self._mouse_held.add(button)
|
|
514
|
+
self._fire_mouse('click', x, y, button)
|
|
515
|
+
elif action == 'm':
|
|
516
|
+
self._mouse_held.discard(button)
|
|
517
|
+
self._fire_mouse('release', x, y, button)
|
|
518
|
+
|
|
519
|
+
def _handle_x10(self, b, x, y):
|
|
520
|
+
self._mouse_x, self._mouse_y = x, y
|
|
521
|
+
btn = b & 3
|
|
522
|
+
if btn == 3:
|
|
523
|
+
self._mouse_held.clear()
|
|
524
|
+
self._fire_mouse('release', x, y, None)
|
|
525
|
+
else:
|
|
526
|
+
self._mouse_held.add(btn)
|
|
527
|
+
self._fire_mouse('click', x, y, btn)
|
|
528
|
+
|
|
529
|
+
def _fire_mouse(self, event, x, y, data):
|
|
530
|
+
for handle in list(self._mouse_cbs):
|
|
531
|
+
etype, cb, once, nargs = handle
|
|
532
|
+
if etype in (event, 'any'):
|
|
533
|
+
if once:
|
|
534
|
+
# skip if already removed (e.g. by stopmouse from another callback)
|
|
535
|
+
if not any(h is handle for h in self._mouse_cbs):
|
|
536
|
+
continue
|
|
537
|
+
self._mouse_cbs = [h for h in self._mouse_cbs if h is not handle]
|
|
538
|
+
args = (x, y, data, event)
|
|
539
|
+
if nargs is not None:
|
|
540
|
+
args = args[:nargs]
|
|
541
|
+
try:
|
|
542
|
+
cb(*args)
|
|
543
|
+
except Exception:
|
|
544
|
+
sys.stderr.write("input_handler: onmouse callback for %r raised:\n" % event)
|
|
545
|
+
traceback.print_exc()
|
|
546
|
+
|
|
547
|
+
#CENTRAL UPDATE
|
|
548
|
+
def update(self):
|
|
549
|
+
now = time.monotonic()
|
|
550
|
+
self._mouse_wheel = 0
|
|
551
|
+
self._buf += self._read_raw()
|
|
552
|
+
self._parse_input(now)
|
|
553
|
+
if self._kitty_state == 'probing' and now - self._kitty_t > 1.5:
|
|
554
|
+
self._kitty_state = 'off'
|
|
555
|
+
self._flush_releases(now)
|
|
556
|
+
|
|
557
|
+
with self.lock:
|
|
558
|
+
events = list(self._events)
|
|
559
|
+
self._events.clear()
|
|
560
|
+
focused = self.focused
|
|
561
|
+
|
|
562
|
+
if events:
|
|
563
|
+
for ident, event in events:
|
|
564
|
+
if event.event_type == 'down':
|
|
565
|
+
self._down[ident] = event.name
|
|
566
|
+
else:
|
|
567
|
+
self._down.pop(ident, None)
|
|
568
|
+
self._pressed_keys = set(self._down.values())
|
|
569
|
+
|
|
570
|
+
if focused:
|
|
571
|
+
legacy = self._legacy_mode()
|
|
572
|
+
for ident, event in events:
|
|
573
|
+
down = event.event_type == 'down'
|
|
574
|
+
self._fire_key(event.name, 'press' if down else 'release')
|
|
575
|
+
table = self._press_cbs if down else self._release_cbs
|
|
576
|
+
for entry in list(table.get(event.name, ())):
|
|
577
|
+
if not down and legacy and entry[3] is not None:
|
|
578
|
+
continue # has its own delay: fired by _fire_delayed_releases
|
|
579
|
+
self._dispatch(table, entry, event, down)
|
|
580
|
+
self._fire_delayed_releases(now)
|
|
581
|
+
else:
|
|
582
|
+
self._delayed_rel.clear()
|
|
583
|
+
|
|
584
|
+
def _dispatch(self, table, entry, event, down):
|
|
585
|
+
cb, accepts, token, _delay = entry
|
|
586
|
+
if token is not None and not self._consume(table, token):
|
|
587
|
+
return # one-shot already fired / removed
|
|
588
|
+
try:
|
|
589
|
+
cb(event) if accepts else cb()
|
|
590
|
+
except Exception:
|
|
591
|
+
sys.stderr.write("input_handler: %s callback for %r raised:\n"
|
|
592
|
+
% ('onpress' if down else 'onrelease', event.name))
|
|
593
|
+
traceback.print_exc()
|
|
594
|
+
|
|
595
|
+
def _fire_key(self, name, event):
|
|
596
|
+
for handle in list(self._key_cbs):
|
|
597
|
+
key, etype, cb, once, nargs, caps = handle
|
|
598
|
+
if etype not in (event, 'any'):
|
|
599
|
+
continue
|
|
600
|
+
if key is not None and key != 'any':
|
|
601
|
+
if caps:
|
|
602
|
+
if str(key).lower() != str(name).lower():
|
|
603
|
+
continue
|
|
604
|
+
elif key != name:
|
|
605
|
+
continue
|
|
606
|
+
if once:
|
|
607
|
+
if not any(h is handle for h in self._key_cbs):
|
|
608
|
+
continue
|
|
609
|
+
self._key_cbs = [h for h in self._key_cbs if h is not handle]
|
|
610
|
+
args = (name, event)
|
|
611
|
+
if nargs is not None:
|
|
612
|
+
args = args[:nargs]
|
|
613
|
+
try:
|
|
614
|
+
cb(*args)
|
|
615
|
+
except Exception:
|
|
616
|
+
sys.stderr.write("input_handler: onkey callback for %r raised:\n" % name)
|
|
617
|
+
traceback.print_exc()
|
|
618
|
+
|
|
619
|
+
def _fire_delayed_releases(self, now):
|
|
620
|
+
if not self._delayed_rel:
|
|
621
|
+
return
|
|
622
|
+
legacy = self._legacy_mode()
|
|
623
|
+
for k, (name, entry, due) in list(self._delayed_rel.items()):
|
|
624
|
+
if now < due:
|
|
625
|
+
continue
|
|
626
|
+
del self._delayed_rel[k]
|
|
627
|
+
if not legacy:
|
|
628
|
+
continue # kitty took over: real release events are used instead
|
|
629
|
+
if not any(e is entry for e in self._release_cbs.get(name, ())):
|
|
630
|
+
continue # unregistered meanwhile
|
|
631
|
+
self._dispatch(self._release_cbs, entry,
|
|
632
|
+
_FakeKeyEvent('up', name, ('t', name)), False)
|
|
633
|
+
|
|
634
|
+
#KEYBOARD API
|
|
635
|
+
def held(self, key=None, caps=False, delay=None):
|
|
636
|
+
# delay: seconds a key stays "held" after its last press. Only used in
|
|
637
|
+
# the last tier (terminal fallback without kitty), where there are no
|
|
638
|
+
# release events and held() is emulated from key-repeat. It replaces
|
|
639
|
+
# legacy_hold for this call, so a developer can bridge the OS
|
|
640
|
+
# repeat-start gap (often 250-600 ms) for actions like a persistent jump.
|
|
641
|
+
# Ignored by the `keyboard` and kitty tiers, which report real releases.
|
|
642
|
+
if not self.focused:
|
|
643
|
+
return False
|
|
644
|
+
pressed = self._pressed_keys
|
|
645
|
+
if delay is not None and self._legacy_mode():
|
|
646
|
+
now = time.monotonic()
|
|
647
|
+
pressed = {n for n, t in list(self._last_press.items())
|
|
648
|
+
if now - t <= delay}
|
|
649
|
+
if isinstance(key, str) and key:
|
|
650
|
+
if caps:
|
|
651
|
+
return key.upper() in pressed or key.lower() in pressed
|
|
652
|
+
return key in pressed
|
|
653
|
+
if isinstance(key, (list, tuple)) and key:
|
|
654
|
+
if caps:
|
|
655
|
+
return any(k.upper() in pressed or k.lower() in pressed for k in key)
|
|
656
|
+
return any(k in pressed for k in key)
|
|
657
|
+
return list(pressed)
|
|
658
|
+
|
|
659
|
+
@staticmethod
|
|
660
|
+
def _consume(table, token):
|
|
661
|
+
# Remove every entry carrying `token` (a once-group shared by the
|
|
662
|
+
# lower/upper registrations when caps=True). Returns True if any
|
|
663
|
+
# entry was still registered.
|
|
664
|
+
removed = False
|
|
665
|
+
for k, lst in list(table.items()):
|
|
666
|
+
kept = [e for e in lst if e[2] is not token]
|
|
667
|
+
if len(kept) != len(lst):
|
|
668
|
+
removed = True
|
|
669
|
+
table[k] = kept
|
|
670
|
+
return removed
|
|
671
|
+
|
|
672
|
+
def onpress(self, key, callback, caps=False, once=False):
|
|
673
|
+
accepts = _accepts_arg(callback)
|
|
674
|
+
token = object() if once else None
|
|
675
|
+
if caps:
|
|
676
|
+
self._press_cbs[key.lower()].append((callback, accepts, token, None))
|
|
677
|
+
self._press_cbs[key.upper()].append((callback, accepts, token, None))
|
|
678
|
+
return (key.lower(), callback, 'press'), (key.upper(), callback, 'press')
|
|
679
|
+
self._press_cbs[key].append((callback, accepts, token, None))
|
|
680
|
+
return (key, callback, 'press')
|
|
681
|
+
|
|
682
|
+
def onrelease(self, key, callback, caps=False, once=False, delay=None):
|
|
683
|
+
# delay: seconds after the key's last press before the callback fires.
|
|
684
|
+
# Only used in the last tier (terminal fallback without kitty), where the
|
|
685
|
+
# release is emulated; it replaces legacy_hold for this callback and
|
|
686
|
+
# matches held(key, delay=...). Ignored by the keyboard and kitty tiers.
|
|
687
|
+
accepts = _accepts_arg(callback)
|
|
688
|
+
token = object() if once else None
|
|
689
|
+
if caps:
|
|
690
|
+
self._release_cbs[key.lower()].append((callback, accepts, token, delay))
|
|
691
|
+
self._release_cbs[key.upper()].append((callback, accepts, token, delay))
|
|
692
|
+
return (key.lower(), callback, 'release'), (key.upper(), callback, 'release')
|
|
693
|
+
self._release_cbs[key].append((callback, accepts, token, delay))
|
|
694
|
+
return (key, callback, 'release')
|
|
695
|
+
|
|
696
|
+
def onkey(self, key, callback, event='any', once=False, caps=False):
|
|
697
|
+
# key: key name, or None / 'any' for every key
|
|
698
|
+
# event: 'press' | 'release' | 'any'
|
|
699
|
+
# callback: cb(key, event) — receives as many of these as its signature accepts
|
|
700
|
+
# once: if True, the callback is removed after it fires the first time
|
|
701
|
+
handle = (key, event, callback, once, _positional_count(callback), caps)
|
|
702
|
+
self._key_cbs.append(handle)
|
|
703
|
+
return handle
|
|
704
|
+
|
|
705
|
+
def stopkey(self, handle):
|
|
706
|
+
before = len(self._key_cbs)
|
|
707
|
+
self._key_cbs = [h for h in self._key_cbs if h is not handle]
|
|
708
|
+
return len(self._key_cbs) != before
|
|
709
|
+
|
|
710
|
+
def offkey(self, handle):
|
|
711
|
+
return self.stopkey(handle)
|
|
712
|
+
|
|
713
|
+
@staticmethod
|
|
714
|
+
def _clear_key(table, key, caps):
|
|
715
|
+
keys = {key.lower(), key.upper()} if caps else {key}
|
|
716
|
+
removed = 0
|
|
717
|
+
for k in keys:
|
|
718
|
+
removed += len(table.pop(k, ()))
|
|
719
|
+
return removed
|
|
720
|
+
|
|
721
|
+
def offpress(self, key, caps=False):
|
|
722
|
+
return self._clear_key(self._press_cbs, key, caps)
|
|
723
|
+
|
|
724
|
+
def offrelease(self, key, caps=False):
|
|
725
|
+
return self._clear_key(self._release_cbs, key, caps)
|
|
726
|
+
|
|
727
|
+
def stop(self, handle):
|
|
728
|
+
if handle and isinstance(handle[0], tuple):
|
|
729
|
+
for h in handle:
|
|
730
|
+
self.stop(h)
|
|
731
|
+
return
|
|
732
|
+
key, cb, kind = handle
|
|
733
|
+
target = self._press_cbs if kind == 'press' else self._release_cbs
|
|
734
|
+
target[key] = [entry for entry in target[key] if entry[0] is not cb]
|
|
735
|
+
|
|
736
|
+
def stop_all(self):
|
|
737
|
+
self._press_cbs.clear()
|
|
738
|
+
self._release_cbs.clear()
|
|
739
|
+
self._key_cbs.clear()
|
|
740
|
+
self._mouse_cbs.clear()
|
|
741
|
+
self.keyboard.unhook_all()
|
|
742
|
+
|
|
743
|
+
#MOUSE API
|
|
744
|
+
@property
|
|
745
|
+
def mouse(self): # actual mouse position
|
|
746
|
+
return (self._mouse_x, self._mouse_y)
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def wheel(self): # actual mouse wheel delta
|
|
750
|
+
# 1-up, -1-down, 0-none
|
|
751
|
+
return self._mouse_wheel
|
|
752
|
+
|
|
753
|
+
def mouseheld(self, button=0): # actual held mouse button
|
|
754
|
+
# 0-left, 1-middle, 2-right
|
|
755
|
+
return button in self._mouse_held
|
|
756
|
+
|
|
757
|
+
def onmouse(self, event, callback, once=False): # register a mouse event callback
|
|
758
|
+
# event: 'click' | 'release' | 'move' | 'wheel' | 'any'
|
|
759
|
+
# callback: cb(x, y, data, event) — receives as many of these as its signature accepts
|
|
760
|
+
# data is button index or scroll delta; event is the event name that fired
|
|
761
|
+
# once: if True, the callback is removed after it fires the first time
|
|
762
|
+
handle = (event, callback, once, _positional_count(callback))
|
|
763
|
+
self._mouse_cbs.append(handle)
|
|
764
|
+
return handle
|
|
765
|
+
|
|
766
|
+
def stopmouse(self, handle):
|
|
767
|
+
if isinstance(handle, (list, tuple)) and handle and isinstance(handle[0], tuple):
|
|
768
|
+
return any([self.stopmouse(h) for h in handle])
|
|
769
|
+
before = len(self._mouse_cbs)
|
|
770
|
+
self._mouse_cbs = [h for h in self._mouse_cbs if h is not handle]
|
|
771
|
+
return len(self._mouse_cbs) != before
|
|
772
|
+
|
|
773
|
+
def offmouse(self, handle):
|
|
774
|
+
return self.stopmouse(handle)
|
|
775
|
+
|
|
776
|
+
#FOCUS API
|
|
777
|
+
def pausefocus(self):
|
|
778
|
+
self._disable_focus_reporting()
|
|
779
|
+
|
|
780
|
+
def resumefocus(self):
|
|
781
|
+
self._enable_focus_reporting()
|
|
782
|
+
|
|
783
|
+
def __enter__(self):
|
|
784
|
+
return self
|
|
785
|
+
|
|
786
|
+
def __exit__(self, *exc):
|
|
787
|
+
self.cleanup()
|
|
788
|
+
|
|
789
|
+
def cleanup(self):
|
|
790
|
+
if self._cleaned:
|
|
791
|
+
return
|
|
792
|
+
self._cleaned = True
|
|
793
|
+
if self._term:
|
|
794
|
+
self._disable_mouse()
|
|
795
|
+
if self.wf:
|
|
796
|
+
self._disable_focus_reporting()
|
|
797
|
+
if self._kitty_state == 'on':
|
|
798
|
+
self._write(ansi.csi('<', 'u'))
|
|
799
|
+
self._kitty_state = None
|
|
800
|
+
try:
|
|
801
|
+
self.keyboard.unhook_all()
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
if _WINDOWS:
|
|
805
|
+
if self._win_saved_in is not None:
|
|
806
|
+
_kernel32.SetConsoleMode(
|
|
807
|
+
_kernel32.GetStdHandle(_STD_INPUT_HANDLE),
|
|
808
|
+
self._win_saved_in)
|
|
809
|
+
self._win_saved_in = None
|
|
810
|
+
if self._win_saved_out is not None:
|
|
811
|
+
_kernel32.SetConsoleMode(
|
|
812
|
+
_kernel32.GetStdHandle(_STD_OUTPUT_HANDLE),
|
|
813
|
+
self._win_saved_out)
|
|
814
|
+
self._win_saved_out = None
|