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