# 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 re # 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, } 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'[115;3u' → Alt+S (kitty) b'[115;5u' → Ctrl+S # 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:]] m = re.match(r"^\[(\d+)(?:;(\d+))?([u~])$", text) if not m: # 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)] return KEY_ESC code = int(m.group(1)) mod = _csi_mod(m.group(2)) terminator = m.group(3) # F1-F12 dengan/tanpa modifier if terminator in ("u", "~") and str(code) in _CSI_F_KEYS: return _CSI_F_KEYS[str(code)] # Enter + Ctrl → Ctrl+Enter if code == 13 and mod["ctrl"] and terminator in ("u", "~"): return KEY_CTRL_ENTER # CSI-u untuk tombol printable (kitty protocol): # Ctrl+ → kode ASCII murni (misal 's' → 19) # Alt+ → KEY_ALT() if terminator == "u" and 32 <= code <= 126: ch = chr(code).lower() if mod["ctrl"]: if 97 <= ord(ch) <= 122: return ord(ch) & 0x1f return KEY_ESC if mod["alt"]: return KEY_ALT(ch) return KEY_ESC return KEY_ESC 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 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()