hendrik/interfaces/tui/keycodes.py

251 lines
7.9 KiB
Python

# keycodes.py — Konstanta key code & pembaca key untuk TUI.
#
# Terminologi:
# * Ctrl+<huruf> → 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+<huruf> → terminal mengirim "\x1b <huruf>"; read_key() menggabungkan
# keduanya menjadi kode sintetis KEY_ALT(<huruf>).
# Fallback CSI-u: "\x1b[<ascii>;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+<huruf>: 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'[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[<code>;<mod>u / ESC[<code>;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;<mod>;<code>[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)]
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+<huruf> → kode ASCII murni (misal 's' → 19)
# Alt+<huruf> → KEY_ALT(<huruf>)
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+<huruf> → kembalikan KEY_ALT(<huruf>)
# * pembuka CSI seq → parse (F-keys, Ctrl+Enter, Alt+<huruf>, 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+<huruf> = "\x1b <huruf>"
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()