# keycodes.py — Konstanta key code & pembaca key untuk TUI. # # Terminologi: # * Ctrl+ → kode ASCII murni (Ctrl+N = 14, dst). # * F2/F4/F6/F10 → konstanta curses (KEY_F*). # * Ctrl+Enter → tidak punya kode ASCII di terminal biasa, sehingga # dibaca via read_key() dari escaped sequence CSI-u # (misal "\x1b[13;5u" di kitty/foot/terminal modern, # atau "\x1b[13;5~" di xterm modifyOtherKeys). # * Alt+ → terminal mengirim "\x1b "; read_key() menggabungkan # keduanya menjadi kode sintetis KEY_ALT(). # Fallback CSI-u: "\x1b[;3u" (kitty protocol). # # read_key() adalah pembungkus getch() yang menormalkan semua representasi # escape sequence antar-terminal (xterm, kitty, foot, iTerm, WezTerm, dll) # menjadi satu set kode yang bisa dibandingkan di actions.py. import curses import os import re import sys # Waktu (ms) menunggu byte lanjutan setelah ESC di read_key() ESC_PEEK_MS = 30 # -- ASCII control codes (nilai = chr & 0x1f) -- KEY_CTRL_C = 3 KEY_CTRL_D = 4 KEY_CTRL_E = 5 KEY_CTRL_F = 6 KEY_CTRL_L = 12 KEY_CTRL_N = 14 KEY_CTRL_O = 15 KEY_CTRL_R = 18 KEY_CTRL_W = 23 KEY_CTRL_X = 24 # -- Tombol lain yang sudah punya representasi curses -- KEY_ESC = 27 KEY_ENTER = curses.KEY_ENTER KEY_BACKSPACE = curses.KEY_BACKSPACE KEY_UP = curses.KEY_UP KEY_DOWN = curses.KEY_DOWN KEY_LEFT = curses.KEY_LEFT KEY_RIGHT = curses.KEY_RIGHT KEY_PPAGE = curses.KEY_PPAGE KEY_NPAGE = curses.KEY_NPAGE KEY_HOME = curses.KEY_HOME KEY_END = curses.KEY_END KEY_INSERT = curses.KEY_IC KEY_DC = curses.KEY_DC KEY_F1 = curses.KEY_F1 KEY_F2 = curses.KEY_F2 KEY_F3 = curses.KEY_F3 KEY_F4 = curses.KEY_F4 KEY_F5 = curses.KEY_F5 KEY_F6 = curses.KEY_F6 KEY_F7 = curses.KEY_F7 KEY_F8 = curses.KEY_F8 KEY_F9 = curses.KEY_F9 KEY_F10 = curses.KEY_F10 KEY_F11 = curses.KEY_F11 KEY_F12 = curses.KEY_F12 # -- Kode sintetis untuk kombinasi tanpa representasi asli -- _KEY_SYNTH_BASE = 3000 KEY_CTRL_ENTER = _KEY_SYNTH_BASE + 1 # hasil parse CSI-u dari read_key() # Alt+: kode sintetis = _KEY_ALT_BASE + ord(huruf) _KEY_ALT_BASE = _KEY_SYNTH_BASE + 32 def KEY_ALT(ch): return _KEY_ALT_BASE + ord(ch) KEY_ALT_S = KEY_ALT("s") KEY_ALT_A = KEY_ALT("a") KEY_ALT_M = KEY_ALT("m") KEY_ALT_W = KEY_ALT("w") # Kode CSI untuk F-keys & navigasi (xterm / kitty compatible) _CSI_F_KEYS = { "11": KEY_F1, "12": KEY_F2, "13": KEY_F3, "14": KEY_F4, "15": KEY_F5, "17": KEY_F6, "18": KEY_F7, "19": KEY_F8, "20": KEY_F9, "21": KEY_F10, "23": KEY_F11, "24": KEY_F12, } _CSI_NAV = { "A": KEY_UP, "B": KEY_DOWN, "C": KEY_RIGHT, "D": KEY_LEFT, "H": KEY_HOME, "F": KEY_END, "1~": KEY_HOME, "4~": KEY_END, "2~": KEY_INSERT, "3~": KEY_DC, "5~": KEY_PPAGE, "6~": KEY_NPAGE, } # F1-F4 bentuk huruf (kitty protocol aktif): CSI P/Q/S = F1/F2/F4. # F3 (huruf R) dihapus dari spec (bentrok Cursor Position Report) → pakai [13~. _CSI_LETTER_F = { "P": KEY_F1, "Q": KEY_F2, "S": KEY_F4, } def _csi_mod(mod_str: str) -> int: # Modifier CSI-u: 1=default, 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, # 6=Shift+Ctrl, 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. # Ambil bit-nya: (mod - 1): bit0=Shift, bit1=Alt, bit2=Ctrl. mod = int(mod_str) if mod_str else 1 bits = mod - 1 return { "shift": bool(bits & 1), "alt": bool(bits & 2), "ctrl": bool(bits & 4), } def _parse_csi(data: bytes) -> int: # data contoh: # b'[12~' → F2 b'[21~' → F10 # b'[13;5u' → Ctrl+Enter b'[13;5~' → Ctrl+Enter # b'[27;5;13~' → Ctrl+Enter (xterm modifyOtherKeys) # b'[115;3u' → Alt+S (kitty) b'[27;3;115~' → Alt+S (xterm modifyOtherKeys) # b'[13u' → Enter (CSI-u) b'[13~' → F3 (legacy) # b'[A' / b'[B' → panah atas / bawah try: text = data.decode("ascii", "ignore") except Exception: return KEY_ESC # Navigasi sederhana (kalau curses gagal menterjemahkan) if text[1:] in _CSI_NAV: return _CSI_NAV[text[1:]] # Format standar CSI-u: ESC[;u / ESC[;mod~ (legacy) m = re.match(r"^\[(\d+)(?:;(\d+))?([u~])$", text) if m: code = int(m.group(1)) mod = _csi_mod(m.group(2)) terminator = m.group(3) else: # Format legacy xterm modifyOtherKeys: ESC[27;;[u~] m27 = re.match(r"^\[27;(\d+);(\d+)([u~])$", text) if not m27: # Navigasi dengan prefiks angka (misal [1;5A = Ctrl+Up) m2 = re.match(r"^\[(?:\d+;)?(\d*)([ABCDHF])$", text) if m2 and m2.group(2) in _CSI_NAV: return _CSI_NAV[m2.group(2)] # F1/F2/F4 bentuk huruf (kitty protocol aktif): \x1b[P / \x1b[Q / \x1b[S. # Modifier boleh ada (misal [1;2Q = Shift+F2); kode huruf tetap jadi patokan. mF = re.match(r"^\[(?:\d+;)*(\d*)([PQRS])$", text) if mF and mF.group(2) in _CSI_LETTER_F: return _CSI_LETTER_F[mF.group(2)] return KEY_ESC code = int(m27.group(2)) mod = _csi_mod(m27.group(1)) terminator = m27.group(3) result = _map_modified_key(code, mod, terminator) if result is not None: return result return KEY_ESC def _map_modified_key(code: int, mod, terminator: str) -> int | None: # Enter (code 13) khusus karena bentrok dengan F3: # * Ctrl+Enter → KEY_CTRL_ENTER (\x1b[13;5u / \x1b[13;5~ / \x1b[27;5;13~) # * \x1b[13~ tanpa modifier → F3 legacy # * selain itu (Enter CSI-u/kitty/modifier lain) → Enter (13) if code == 13: if mod["ctrl"]: return KEY_CTRL_ENTER if not (mod["shift"] or mod["alt"]) and terminator == "~": return _CSI_F_KEYS["13"] # F3 return 13 # F1-F12 dengan/tanpa modifier if str(code) in _CSI_F_KEYS: return _CSI_F_KEYS[str(code)] # CSI-u untuk tombol printable (kitty protocol / xterm modifyOtherKeys): # Ctrl+ → kode ASCII murni (misal 's' → 19) # Alt+ → KEY_ALT() if terminator in ("u", "~") and 32 <= code <= 126: ch = chr(code).lower() if mod["ctrl"]: if 97 <= ord(ch) <= 122: return ord(ch) & 0x1f return None if mod["alt"]: return KEY_ALT(ch) return None return None def read_key(stdscr, timeout_ms: int = -1) -> int: # Baca satu key dari stdscr. Jika ESC muncul, cek apakah itu: # * ESC murni → kembalikan KEY_ESC # * Alt+ → kembalikan KEY_ALT() # * pembuka CSI seq → parse (F-keys, Ctrl+Enter, Alt+, dst.) key = stdscr.getch() if key != KEY_ESC: return key # Nodelay sementara: tunggu sebentar byte lanjutan (kalau sequence-nya # terpecah antar-read di terminal lambat/SSH), sisanya ESC murni. stdscr.timeout(ESC_PEEK_MS) try: nxt = stdscr.getch() finally: stdscr.timeout(timeout_ms) if nxt == -1: return KEY_ESC if nxt == ord("["): # CSI sequence: kumpulkan sampai byte final (0x40..0x7e). seq = [nxt] while True: c = stdscr.getch() if c == -1: break seq.append(c) if 0x40 <= c <= 0x7e: break return _parse_csi(bytes(seq)) if nxt == ord("O"): # SS3 (application keypad): F1-F4 = \x1bOP..\x1bOS, # panah = \x1bOA..\x1bOD, Home/End = \x1bOH/\x1bOF. stdscr.timeout(ESC_PEEK_MS) try: c = stdscr.getch() finally: stdscr.timeout(timeout_ms) ss3 = { "P": KEY_F1, "Q": KEY_F2, "R": KEY_F3, "S": KEY_F4, "A": KEY_UP, "B": KEY_DOWN, "C": KEY_RIGHT, "D": KEY_LEFT, "H": KEY_HOME, "F": KEY_END, } if c != -1 and chr(c) in ss3: return ss3[chr(c)] return KEY_ALT("O") # Alt+Shift+O (bukan SS3) if 32 <= nxt <= 126: return KEY_ALT(chr(nxt)) # Alt+ = "\x1b " return KEY_ESC def is_alt_key(key: int) -> bool: return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126 def alt_key_char(key: int) -> str | None: if not is_alt_key(key): return None return chr(key - _KEY_ALT_BASE).lower() # -------------------------------------------------------------------- protokol keyboard modern --- # Supaya Ctrl+Enter (dan kombinasi Alt/Ctrl lain) punya escape sequence sendiri # (bukan jatuh jadi \r / ESC+huruf), aktifkan protokol keyboard di terminal: # * \x1b[>1u → kitty keyboard protocol (kitty, foot, WezTerm, iTerm2) # * \x1b[>4;2m → xterm modifyOtherKeys mode 2 (xterm) # Terminal yang tidak mendukung akan meng-ignore sekuen ini dengan aman. ENABLE_EXTENDED_KEYS = b"\x1b[>1u\x1b[>4;2m" DISABLE_EXTENDED_KEYS = b"\x1b[<1u\x1b[>4m" def enable_extended_keys(): os.write(sys.stdout.fileno(), ENABLE_EXTENDED_KEYS) sys.stdout.flush() def disable_extended_keys(): os.write(sys.stdout.fileno(), DISABLE_EXTENDED_KEYS) sys.stdout.flush()