279 lines
9.0 KiB
Python
279 lines
9.0 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,
|
|
}
|
|
|
|
# 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[<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)]
|
|
# 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+<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 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+<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()
|