Skip to content

Instantly share code, notes, and snippets.

@nurpax
Last active October 19, 2025 13:44
Show Gist options
  • Save nurpax/57f2598f64f94254dddd1968bb2b1ed6 to your computer and use it in GitHub Desktop.
Save nurpax/57f2598f64f94254dddd1968bb2b1ed6 to your computer and use it in GitHub Desktop.
Fully standalone console mode keyboard event loop (Kitty protocol)
"""
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