Last active
October 19, 2025 13:44
-
-
Save nurpax/57f2598f64f94254dddd1968bb2b1ed6 to your computer and use it in GitHub Desktop.
Fully standalone console mode keyboard event loop (Kitty protocol)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Standalone, no dependency Python code for modern keyboard and mouse handling for terminal applications. | |
Hardcoded for the Kitty key protocol. | |
Keyboard: key press, repeat, and release events. | |
Mouse: continuous mouse position (for hover) and click reporting. | |
""" | |
from dataclasses import dataclass | |
import sys | |
import os | |
import termios | |
import atexit | |
import select | |
import re | |
import fcntl | |
from enum import IntEnum, IntFlag | |
from typing import Literal | |
# Key codes from glfw.h | |
class Key(IntEnum): | |
KEY_SPACE = 32 | |
KEY_APOSTROPHE = 39 | |
KEY_COMMA = 44 | |
KEY_MINUS = 45 | |
KEY_PERIOD = 46 | |
KEY_SLASH = 47 | |
KEY_0 = 48 | |
KEY_1 = 49 | |
KEY_2 = 50 | |
KEY_3 = 51 | |
KEY_4 = 52 | |
KEY_5 = 53 | |
KEY_6 = 54 | |
KEY_7 = 55 | |
KEY_8 = 56 | |
KEY_9 = 57 | |
KEY_SEMICOLON = 59 | |
KEY_EQUAL = 61 | |
KEY_A = 65 | |
KEY_B = 66 | |
KEY_C = 67 | |
KEY_D = 68 | |
KEY_E = 69 | |
KEY_F = 70 | |
KEY_G = 71 | |
KEY_H = 72 | |
KEY_I = 73 | |
KEY_J = 74 | |
KEY_K = 75 | |
KEY_L = 76 | |
KEY_M = 77 | |
KEY_N = 78 | |
KEY_O = 79 | |
KEY_P = 80 | |
KEY_Q = 81 | |
KEY_R = 82 | |
KEY_S = 83 | |
KEY_T = 84 | |
KEY_U = 85 | |
KEY_V = 86 | |
KEY_W = 87 | |
KEY_X = 88 | |
KEY_Y = 89 | |
KEY_Z = 90 | |
KEY_LEFT_BRACKET = 91 | |
KEY_BACKSLASH = 92 | |
KEY_RIGHT_BRACKET = 93 | |
KEY_GRAVE_ACCENT = 96 | |
KEY_ESCAPE = 256 | |
KEY_ENTER = 257 | |
KEY_TAB = 258 | |
KEY_BACKSPACE = 259 | |
KEY_INSERT = 260 | |
KEY_DELETE = 261 | |
KEY_RIGHT = 262 | |
KEY_LEFT = 263 | |
KEY_DOWN = 264 | |
KEY_UP = 265 | |
KEY_PAGE_UP = 266 | |
KEY_PAGE_DOWN = 267 | |
KEY_HOME = 268 | |
KEY_END = 269 | |
KEY_CAPS_LOCK = 280 | |
KEY_SCROLL_LOCK = 281 | |
KEY_NUM_LOCK = 282 | |
KEY_PRINT_SCREEN = 283 | |
KEY_PAUSE = 284 | |
KEY_F1 = 290 | |
KEY_F2 = 291 | |
KEY_F3 = 292 | |
KEY_F4 = 293 | |
KEY_F5 = 294 | |
KEY_F6 = 295 | |
KEY_F7 = 296 | |
KEY_F8 = 297 | |
KEY_F9 = 298 | |
KEY_F10 = 299 | |
KEY_F11 = 300 | |
KEY_F12 = 301 | |
KEY_LEFT_SHIFT = 340 | |
KEY_LEFT_CONTROL = 341 | |
KEY_LEFT_ALT = 342 | |
KEY_LEFT_SUPER = 343 | |
KEY_RIGHT_SHIFT = 344 | |
KEY_RIGHT_CONTROL = 345 | |
KEY_RIGHT_ALT = 346 | |
KEY_RIGHT_SUPER = 347 | |
class ModKey(IntFlag): | |
MOD_NONE = 0 | |
MOD_SHIFT = 1 | |
MOD_CTRL = 2 | |
MOD_ALT = 4 | |
MOD_SUPER = 8 | |
MOD_CAPS_LOCK = 16 | |
MOD_NUM_LOCK = 32 | |
@dataclass | |
class KeyEvent: | |
key: Key | |
mod: ModKey | |
ev: Literal['press', 'repeat', 'release'] | |
text: str | None | |
class MouseButton(IntEnum): | |
LEFT = 0 | |
MIDDLE = 1 | |
RIGHT = 2 | |
@dataclass | |
class MouseMoveEvent: | |
pos: tuple[int, int] | |
@dataclass | |
class MouseClickEvent: | |
pos: tuple[int, int] | |
ev: Literal['up', 'down'] | |
button: MouseButton | |
@dataclass | |
class MouseWheelEvent: | |
pos: tuple[int, int] | |
scroll: int # -1, 1 | |
def kkp_push(out): out.write(b"\x1b[>1u"); out.flush() | |
def kkp_pop(out, n=1): out.write(b"\x1b[<%du" % n); out.flush() | |
def kkp_setflags(out, flags): out.write(b"\x1b[=%du" % flags); out.flush() | |
class CBreakMode: | |
def __init__(self, fd): | |
self.fd, self._orig = fd, None | |
def __enter__(self): | |
self._orig = termios.tcgetattr(self.fd) | |
new = termios.tcgetattr(self.fd) | |
lflag = new[3] | |
iflag = new[0] | |
oflag = new[1] | |
# non-canonical, no echo; KEEP ISIG so Ctrl-C/Z work | |
# TODO ctrl-c doesn't actually work? | |
lflag &= ~termios.ICANON | |
lflag &= ~termios.ECHO | |
# optional: keep IEXTEN on; keep ISIG on | |
new[3] = lflag | |
new[0] = iflag # keep input translations as-is | |
new[1] = oflag # keep output processing (so \n->\r\n stays) | |
new[6][termios.VMIN] = 1 # byte-granular | |
new[6][termios.VTIME] = 0 | |
termios.tcsetattr(self.fd, termios.TCSADRAIN, new) | |
# optional nonblocking | |
fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) | |
fcntl.fcntl(self.fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) | |
return self | |
def __exit__(self, *_): | |
if self._orig: | |
termios.tcsetattr(self.fd, termios.TCSADRAIN, self._orig) | |
# decode kitty-style modifier (1 + bitfield) | |
def _modkey_from_int(mods: int | None) -> ModKey: | |
if mods is None: | |
return ModKey.MOD_NONE | |
val = max(0, int(mods) - 1) | |
ret = ModKey.MOD_NONE | |
# hyper (16) & meta (32) silently ignored | |
for bit, mod in [(1, ModKey.MOD_SHIFT), (2, ModKey.MOD_ALT), (4, ModKey.MOD_CTRL), (8, ModKey.MOD_SUPER), (16, ModKey.MOD_NONE), (32, ModKey.MOD_NONE), (64, ModKey.MOD_CAPS_LOCK), (128, ModKey.MOD_NUM_LOCK)]: | |
if val & bit: | |
ret |= mod | |
return ret | |
def _parse_kitty_body(body: bytes): | |
# parser for: key ; mods[:event] ; text_codepoints(:-sep) | |
def ints_in(s: bytes): | |
return [int(x) for x in s.split(b":") if x] | |
parts = body.split(b";") # keep empties | |
key = int(parts[0]) if parts and parts[0] else None | |
mods = ev = None | |
codes: list[int] = [] | |
if len(parts) >= 2: | |
p1 = parts[1] | |
if p1: | |
fields = [x for x in p1.split(b":")] | |
nums = [int(x) for x in fields if x] | |
if nums: | |
mods = nums[0] | |
if len(nums) >= 2 and nums[1] in (1, 2, 3): | |
ev = nums[1] | |
rest = nums[2:] | |
else: | |
rest = nums[1:] | |
codes.extend(rest) | |
if p1.startswith(b":"): | |
codes.extend(ints_in(p1[1:])) | |
for p in parts[2:]: | |
if p: | |
codes.extend(ints_in(p)) | |
return key, mods, ev, codes | |
def _parse_tilde_body(body: bytes): | |
""" | |
body: b'num[;mods[:event]][;codes][:codes]...' | |
returns (num:int|None, mods:int|None, ev:int|None, codes:list[int]) | |
""" | |
parts = body.split(b';') # keep empties | |
if not parts or not parts[0]: | |
return None, None, None, [] | |
num = int(parts[0]) | |
mods = ev = None | |
codes: list[int] = [] | |
# second field may be 'mods[:event][:codes...]' | |
if len(parts) >= 2 and parts[1]: | |
sub = parts[1].split(b':') | |
if sub and sub[0]: | |
mods = int(sub[0]) | |
if len(sub) >= 2 and sub[1]: | |
# event (1 press, 2 repeat, 3 release) if present | |
try: | |
ev = int(sub[1]) | |
except ValueError: | |
pass | |
# any remaining colon parts are codepoints | |
for p in sub[2:]: | |
if p: | |
codes.append(int(p)) | |
# remaining ';' fields → more codepoints (colon-separated) | |
for seg in parts[2:]: | |
if seg: | |
for p in seg.split(b':'): | |
if p: | |
codes.append(int(p)) | |
return num, mods, ev, codes | |
def _text_from_codes(codes): | |
if not codes: | |
return None | |
try: | |
return "".join(chr(c) for c in codes) | |
except ValueError: | |
return None | |
def _evname_from_evcode(ev: int | None) -> Literal["press", "repeat", "release"] | None: | |
if ev is None: | |
return "press" | |
match ev: | |
case 1: return "press" | |
case 2: return "repeat" | |
case 3: return "release" | |
def _parse_sgr_mouse(buf: bytes): | |
# expects buf[i:i+3] == b'\x1b[<' | |
assert buf[0:3] == b'\x1b[<' | |
i = 0 | |
j = 3 | |
L = len(buf) | |
# find terminator 'M' or 'm' | |
while j < L and buf[j] not in (ord('M'), ord('m')): | |
j += 1 | |
if j >= L: | |
return None, buf[:0] # incomplete | |
try: | |
payload = buf[i+3:j].decode("ascii", "ignore") | |
b_str, x_str, y_str = payload.split(";") | |
b = int(b_str); x = int(x_str) - 1; y = int(y_str) - 1 # 1-based -> 0-based | |
kind = buf[j:j+1] | |
btn = b & 0b11 # 0=L,1=Middle,2=Right | |
is_motion = (b & 32) != 0 # bit 5 marks motion | |
is_wheel = (b & 0b1100000) in (64, 65, 96, 97) # up/down (w/ mods) | |
consumed = buf[(j + 1):] | |
if is_wheel: | |
return MouseWheelEvent(pos=(x, y), scroll=-1 if b == 64 or b == 96 else 1), consumed | |
if kind == b"M": | |
if is_motion: | |
return MouseMoveEvent(pos=(x, y)), consumed | |
else: | |
if 0 <= btn <= 2: | |
return MouseClickEvent(pos=(x, y), ev="down", button=MouseButton(btn)), consumed | |
return None, consumed | |
else: # 'm' | |
if 0 <= btn <= 2: | |
return MouseClickEvent(pos=(x, y), ev="up", button=MouseButton(btn)), consumed | |
return None, consumed | |
except Exception: | |
# fall through as unknown sequence | |
return None, buf | |
_CSI_ANY = re.compile(rb"\x1b\[(?P<body>[0-9:;]*)(?P<final>[u~ABCDEFHPQS])") | |
def parse_input_events(buf: bytes) -> tuple[bytes, list[KeyEvent | MouseMoveEvent | MouseClickEvent | MouseWheelEvent]]: | |
events = [] | |
while True: | |
# SGR mouse? | |
if len(buf) == 0: | |
return buf, events | |
b0 = buf[0] | |
if b0 == 0x1b and len(buf) >= 3 and buf[1] == ord('[') and buf[2] == ord('<'): | |
ev, buf = _parse_sgr_mouse(buf) | |
if ev is not None: | |
events.append(ev) | |
continue | |
m = _CSI_ANY.search(buf) | |
if not m: | |
break | |
body = m.group("body") | |
final = m.group("final") # b'u', b'~', or one of b'ABCDEFHPQS' | |
end = m.end() | |
# CSI-u form | |
if final == b'u': | |
key_num, mods_int, ev, codes = _parse_kitty_body(body) | |
evname = _evname_from_evcode(ev) | |
key = { | |
None: None, | |
9: Key.KEY_TAB, 13: Key.KEY_ENTER, 27: Key.KEY_ESCAPE, 32: Key.KEY_SPACE, | |
48: Key.KEY_0, 49: Key.KEY_1, 50: Key.KEY_2, 51: Key.KEY_3, | |
52: Key.KEY_4, 53: Key.KEY_5, 54: Key.KEY_6, 55: Key.KEY_7, | |
56: Key.KEY_8, 57: Key.KEY_9, | |
97: Key.KEY_A, 98: Key.KEY_B, 99: Key.KEY_C, 100: Key.KEY_D, | |
101: Key.KEY_E, 102: Key.KEY_F, 103: Key.KEY_G, 104: Key.KEY_H, | |
105: Key.KEY_I, 106: Key.KEY_J, 107: Key.KEY_K, 108: Key.KEY_L, | |
109: Key.KEY_M, 110: Key.KEY_N, 111: Key.KEY_O, 112: Key.KEY_P, | |
113: Key.KEY_Q, 114: Key.KEY_R, 115: Key.KEY_S, 116: Key.KEY_T, | |
117: Key.KEY_U, 118: Key.KEY_V, 119: Key.KEY_W, 120: Key.KEY_X, | |
121: Key.KEY_Y, 122: Key.KEY_Z, | |
127: Key.KEY_BACKSPACE, | |
57441: Key.KEY_LEFT_SHIFT, | |
57442: Key.KEY_LEFT_CONTROL, | |
57443: Key.KEY_LEFT_ALT, | |
57444: Key.KEY_LEFT_SUPER, | |
57447: Key.KEY_RIGHT_SHIFT, | |
57448: Key.KEY_RIGHT_CONTROL, | |
57449: Key.KEY_RIGHT_ALT, | |
57450: Key.KEY_RIGHT_SUPER, | |
}.get(key_num) | |
mods = _modkey_from_int(mods_int) | |
if key is not None and evname is not None: | |
events.append(KeyEvent(key=key, mod=mods, ev=evname, text=_text_from_codes(codes))) | |
# Tilde-form (PgUp/PgDn/Ins/Del/F5+) | |
elif final == b'~': | |
# Home can be either '1 H' or 7 ~', End can be eiter '1 F' or '8 ~' | |
# Similarly for F1-F4 | |
num, mods_int, ev, codes = _parse_tilde_body(body) | |
key = { | |
None: "", | |
1: Key.KEY_HOME, 2: Key.KEY_INSERT, 3: Key.KEY_DELETE, 4: Key.KEY_END, | |
5: Key.KEY_PAGE_UP, 6: Key.KEY_PAGE_DOWN, | |
11: Key.KEY_F1, 12: Key.KEY_F2, 13: Key.KEY_F3, 14: Key.KEY_F4, | |
15: Key.KEY_F5, 17: Key.KEY_F6, 18: Key.KEY_F7, 19: Key.KEY_F8, 20: Key.KEY_F9, | |
21: Key.KEY_F10, 23: Key.KEY_F11, 24: Key.KEY_F12, | |
}.get(num, f"~{num}") | |
evname = _evname_from_evcode(ev) | |
mods = _modkey_from_int(mods_int) | |
if key is not None and evname is not None: | |
events.append(KeyEvent(key=key, mod=mods, ev=evname, text=_text_from_codes(codes))) | |
# Letter-form (arrows/Home/End/F1–F4) — use final to identify key | |
else: | |
num, mods_int, ev, codes = _parse_kitty_body(body) # colon/event may be present | |
evname = _evname_from_evcode(ev) | |
mods = _modkey_from_int(mods_int) | |
assert num is None or num == 1 | |
key = { | |
b'D': Key.KEY_LEFT, | |
b'C': Key.KEY_RIGHT, | |
b'A': Key.KEY_UP, | |
b'B': Key.KEY_DOWN, | |
b'H': Key.KEY_HOME, | |
b'F': Key.KEY_END, | |
b'P': Key.KEY_F1, | |
b'Q': Key.KEY_F2, | |
b'S': Key.KEY_F4, | |
}[final] | |
if key is not None and evname is not None: | |
events.append(KeyEvent(key=key, mod=mods, ev=evname, text=_text_from_codes(codes))) | |
buf = buf[end:] | |
continue | |
return buf, events | |
# Draw text rows on the screen starting at row 0, column 0. | |
def _display_lines(lines: list[str]): | |
for y, line in enumerate(lines): | |
x = 0 | |
sys.stdout.write(f"\x1b[{y + 1};{x + 1}H") | |
line_trunc = f"{line:<80s}" # clear up to 80 columns | |
sys.stdout.write(line_trunc) | |
sys.stdout.flush() | |
# --- ESC helpers ------------------------------------------------------- | |
ALT_ON = "\x1b[?1049h" | |
ALT_OFF = "\x1b[?1049l" | |
HIDE = "\x1b[?25l" | |
SHOW = "\x1b[?25h" | |
ENABLE_MOUSE = "\x1b[?1003h\x1b[?1006h" # hover + drag (any-motion) | |
DISABLE_MOUSE = "\x1b[?1002l\x1b[?1006l" # match the ENABLE you chose | |
CLS = "\x1b[2J" | |
HOME = "\x1b[H" | |
RESET = "\x1b[0m" | |
#------------------------------------------------------------------------ | |
def main(): | |
fd_in = sys.stdin.fileno() | |
fd_out = sys.stdout.fileno() | |
buf = b"" | |
screen_init = HIDE + CLS + HOME + ENABLE_MOUSE | |
screen_cleanup = SHOW + RESET + DISABLE_MOUSE | |
def cleanup(): | |
sys.stdout.write(screen_cleanup) | |
try: | |
kkp_pop(sys.stdout.buffer) | |
except Exception: | |
pass | |
sys.stdout.flush() | |
atexit.register(cleanup) | |
with CBreakMode(fd_in): | |
os.set_blocking(fd_out, True) | |
sys.stdout.write(screen_init) | |
kkp_push(sys.stdout.buffer) | |
# Flags: 1 disambiguate, 2 event types, 8 report all keys, 16 associated text | |
kkp_setflags(sys.stdout.buffer, 27) | |
print("Kitty-only mode. Press 'q' to quit (Kitty event).", file=sys.stderr) | |
frame = 0 | |
while True: | |
r, _, _ = select.select([fd_in], [], [], 1.0) | |
if not r: | |
continue | |
chunk = os.read(fd_in, 4096) | |
if not chunk: | |
break | |
buf += chunk | |
screen_lines = [] | |
screen_lines.append(f"ev count: {frame}") | |
# Consume only CSI-u sequences; drop everything else. | |
buf, events = parse_input_events(buf) | |
for e in events: | |
if isinstance(e, KeyEvent) and e.key == Key.KEY_Q: | |
return | |
ev_str = str(e) | |
screen_lines.append(f"{ev_str:<120s}") | |
screen_lines.extend([""]*5) | |
_display_lines(screen_lines) | |
frame += 1 | |
#------------------------------------------------------------------------ | |
if __name__ == "__main__": | |
try: | |
main() | |
except KeyboardInterrupt: | |
# If your terminal/mux still generates SIGINT, exit cleanly. | |
pass | |
#------------------------------------------------------------------------ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment