UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

301 lines (256 loc) 11.7 kB
/** * Keyboard patch for @opentui/core - adds keydown/keyup events with held state tracking * Auto-detects protocol (win32/kitty/legacy) with graceful fallback * MUST be imported before any @opentui/core imports! */ import { KeyHandler, KeyEvent } from "@opentui/core" import * as fs from "fs" export type KeyboardProtocol = "kitty" | "win32" | "legacy" declare module "@opentui/core" { interface KeyHandlerEventMap { keydown: [KeyEvent]; keyup: [KeyEvent] } interface KeyHandler { isDown(key: string): boolean getHeldKeys(): string[] clearHeldKeys(): void getProtocol(): KeyboardProtocol setProtocol(protocol: KeyboardProtocol): void } } // Key mappings const WIN32_VK: Record<number, string> = { 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause", 20: "capslock", 27: "escape", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home", 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "delete", ...Object.fromEntries([...Array(10)].map((_, i) => [48 + i, String(i)])), ...Object.fromEntries([...Array(26)].map((_, i) => [65 + i, String.fromCharCode(97 + i)])), 91: "meta", 92: "meta", ...Object.fromEntries([...Array(12)].map((_, i) => [112 + i, `f${i + 1}`])), 160: "shift", 161: "shift", 162: "ctrl", 163: "ctrl", 164: "alt", 165: "alt", } const SPECIAL_KEYS: Record<number, string> = { 9: "tab", 13: "return", 27: "escape", 127: "backspace", 1: "home", 2: "insert", 3: "delete", 4: "end", 5: "pageup", 6: "pagedown", 7: "home", 8: "end", 11: "f1", 12: "f2", 13: "f3", 14: "f4", 15: "f5", 17: "f6", 18: "f7", 19: "f8", 20: "f9", 21: "f10", 23: "f11", 24: "f12", 57358: "capslock", 57359: "scrolllock", 57360: "numlock", 57441: "shift", 57442: "ctrl", 57443: "alt", 57444: "meta", } const CURSOR_KEYS: Record<string, string> = { A: "up", B: "down", C: "right", D: "left", F: "end", H: "home", P: "f1", Q: "f2", R: "f3", S: "f4", } const LEGACY_SEQ: Record<string, string> = { "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left", "\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left", "\x1b[1~": "home", "\x1b[2~": "insert", "\x1b[3~": "delete", "\x1b[4~": "end", "\x1b[5~": "pageup", "\x1b[6~": "pagedown", "\x1b[H": "home", "\x1b[F": "end", "\x1bOP": "f1", "\x1bOQ": "f2", "\x1bOR": "f3", "\x1bOS": "f4", "\x1b[15~": "f5", "\x1b[17~": "f6", "\x1b[18~": "f7", "\x1b[19~": "f8", "\x1b[20~": "f9", "\x1b[21~": "f10", "\x1b[23~": "f11", "\x1b[24~": "f12", } // State const state = new WeakMap<KeyHandler, { held: Map<string, KeyEvent> timeouts: Map<string, Timer> lastSeen: Map<string, number> intervals: Map<string, number[]> protocol: KeyboardProtocol enabled: boolean verified: boolean keyCount: number releaseCount: number }>() const getState = (h: KeyHandler) => { if (!state.has(h)) state.set(h, { held: new Map(), timeouts: new Map(), lastSeen: new Map(), intervals: new Map(), protocol: detectProtocol(), enabled: false, verified: false, keyCount: 0, releaseCount: 0 }) return state.get(h)! } const write = (d: string) => { try { fs.writeSync(process.stdout.fd, d) } catch { process.stdout.write(d) } } function detectProtocol(): KeyboardProtocol { const env = process.env const tp = (env.TERM_PROGRAM || "").toLowerCase() const term = (env.TERM || "").toLowerCase() // Windows Terminal (native or WSL) if (env.WT_SESSION) return "win32" if (process.platform === "win32") return "win32" // Kitty-compatible terminals const kitty = tp === "kitty" || term.includes("kitty") || env.KITTY_WINDOW_ID || tp === "wezterm" || env.WEZTERM_PANE || tp === "ghostty" || env.GHOSTTY_RESOURCES_DIR || tp === "foot" || term === "foot" || tp === "rio" || tp === "contour" || tp === "iterm.app" || env.LC_TERMINAL?.toLowerCase() === "iterm2" || env.ITERM_SESSION_ID || tp === "alacritty" || env.ALACRITTY_SOCKET || tp === "konsole" return kitty ? "kitty" : "legacy" } function ensureEnabled(h: KeyHandler) { const s = getState(h) if (s.enabled) return s.enabled = true if (s.protocol === "legacy") return const seq = s.protocol === "win32" ? { on: "\x1b[?9001h", off: "\x1b[?9001l" } : { on: "\x1b[>1u", off: "\x1b[<u" } write(seq.on) const cleanup = () => write(seq.off) process.on("exit", cleanup) process.on("SIGINT", () => { cleanup(); process.exit(0) }) process.on("SIGTERM", () => { cleanup(); process.exit(0) }) } function makeEvent(name: string, seq: string, mods: { ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }, release: boolean, source: string): KeyEvent { return new KeyEvent({ name, sequence: seq, raw: seq, source, ctrl: mods.ctrl || false, meta: mods.meta || false, shift: mods.shift || false, option: mods.alt || false, number: /^[0-9]$/.test(name), eventType: release ? "release" : "press", }) } function emitDown(h: KeyHandler, e: KeyEvent) { const s = getState(h) const k = e.name.toLowerCase() if (!s.held.has(k)) { s.held.set(k, e); h.emit("keydown" as any, e) } h.emit("keypress", e) } function emitUp(h: KeyHandler, e: KeyEvent) { const s = getState(h) const k = e.name.toLowerCase() if (s.held.has(k)) { s.held.delete(k); h.emit("keyup" as any, e); h.emit("keyrelease", e) } } function handleLegacy(h: KeyHandler, e: KeyEvent) { const s = getState(h), k = e.name.toLowerCase(), now = Date.now() // Track timing for adaptive timeout const last = s.lastSeen.get(k) if (last && now - last < 500) { const arr = s.intervals.get(k) || []; arr.push(now - last) if (arr.length > 5) arr.shift() s.intervals.set(k, arr) } else s.intervals.delete(k) s.lastSeen.set(k, now) // Clear existing timeout const existing = s.timeouts.get(k) if (existing) clearTimeout(existing) // Emit keydown if new if (!s.held.has(k)) { s.held.set(k, e); h.emit("keydown" as any, e) } h.emit("keypress", e) // Adaptive timeout const intervals = s.intervals.get(k) const timeout = intervals?.length >= 2 ? Math.min(500, Math.max(50, (intervals.reduce((a, b) => a + b) / intervals.length) * 2.5)) : 150 s.timeouts.set(k, setTimeout(() => { s.timeouts.delete(k); s.lastSeen.delete(k); s.intervals.delete(k) if (s.held.has(k)) { s.held.delete(k); h.emit("keyup" as any, e); h.emit("keyrelease", e) } }, timeout)) } function trackKitty(h: KeyHandler, release: boolean) { const s = getState(h) if (s.verified) return if (release) { s.releaseCount++; s.verified = true } else if (++s.keyCount >= 10 && !s.releaseCount && s.protocol === "kitty") { write("\x1b[<u"); s.protocol = "legacy"; s.verified = true } } function handleKittyEvent(h: KeyHandler, e: KeyEvent, release: boolean) { const s = getState(h) trackKitty(h, release) if (release) emitUp(h, e) else if (s.verified && s.releaseCount > 0) emitDown(h, e) else handleLegacy(h, e) } // Parsers function parseWin32(h: KeyHandler, str: string): boolean { const m = str.match(/^\x1b\[(\d+);(\d+);(\d+);(\d+);(\d+);(\d+)_/) if (!m) return false const vk = +m[1], uc = +m[3], down = +m[4] === 1, cs = +m[5] const name = WIN32_VK[vk] || (uc > 0 ? String.fromCharCode(uc).toLowerCase() : `vk-${vk}`) const e = makeEvent(name, m[0], { shift: !!(cs & 0x10), ctrl: !!(cs & 0x0c), alt: !!(cs & 0x03) }, !down, "raw") down ? emitDown(h, e) : emitUp(h, e) return true } function parseKitty(h: KeyHandler, str: string): boolean { // CSI codepoint ; modifiers : event-type [u~] let m = str.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d))?([u~])/) if (m) { const cp = +m[1], mod = +(m[2] || 1), evt = +(m[3] || 1), term = m[4] const mods = { shift: !!((mod-1)&1), alt: !!((mod-1)&2), ctrl: !!((mod-1)&4), meta: !!((mod-1)&8) } const name = term === "~" ? (SPECIAL_KEYS[cp] || `key${cp}`) : (SPECIAL_KEYS[cp] || String.fromCharCode(cp).toLowerCase() || `u${cp}`) handleKittyEvent(h, makeEvent(name, m[0], mods, evt === 3, "kitty"), evt === 3) return true } // CSI 1 ; modifiers : event-type [ABCDFHPQRS] m = str.match(/^\x1b\[1;(\d+)(?::(\d))?([ABCDFHPQRS])/) if (m) { const mod = +m[1], evt = +(m[2] || 1), key = m[3] const mods = { shift: !!((mod-1)&1), alt: !!((mod-1)&2), ctrl: !!((mod-1)&4), meta: !!((mod-1)&8) } handleKittyEvent(h, makeEvent(CURSOR_KEYS[key] || key, m[0], mods, evt === 3, "kitty"), evt === 3) return true } // CSI code ; modifiers : event-type ~ m = str.match(/^\x1b\[(\d+);(\d+)(?::(\d))?~/) if (m) { const code = +m[1], mod = +m[2], evt = +(m[3] || 1) const mods = { shift: !!((mod-1)&1), alt: !!((mod-1)&2), ctrl: !!((mod-1)&4), meta: !!((mod-1)&8) } handleKittyEvent(h, makeEvent(SPECIAL_KEYS[code] || `s${code}`, m[0], mods, evt === 3, "kitty"), evt === 3) return true } return false } function parseLegacy(h: KeyHandler, str: string): boolean { const name = LEGACY_SEQ[str] if (name) { handleLegacy(h, makeEvent(name, str, {}, false, "raw")); return true } if (str.startsWith("\x1b") && str.length === 2 && str[1] !== "[" && str[1] !== "O") { const c = str[1]!, n = c.charCodeAt(0) const key = n < 32 ? (n === 9 ? "tab" : n === 13 ? "return" : n === 27 ? "escape" : String.fromCharCode(n + 96)) : c === " " ? "space" : c.toLowerCase() handleLegacy(h, makeEvent(key, str, { meta: true, alt: true, shift: c !== c.toLowerCase() }, false, "raw")) return true } if (str === "\x1b") { handleLegacy(h, makeEvent("escape", str, {}, false, "raw")); return true } return false } // Patch const original = KeyHandler.prototype.processInput KeyHandler.prototype.processInput = function(data: string): boolean { ensureEnabled(this) const s = getState(this) if (s.protocol === "win32" && parseWin32(this, data)) return true if (s.protocol === "kitty" && parseKitty(this, data)) return true if (parseLegacy(this, data)) return true // Fallback to original const onPress = (e: KeyEvent) => { if (s.protocol === "kitty" && s.verified && s.releaseCount > 0) { if (!s.held.has(e.name.toLowerCase())) { s.held.set(e.name.toLowerCase(), e); this.emit("keydown" as any, e) } } else handleLegacy(this, e) } const onRelease = (e: KeyEvent) => emitUp(this, e) this.once("keypress", onPress) this.once("keyrelease", onRelease) const result = original.call(this, data) this.off("keypress", onPress) this.off("keyrelease", onRelease) return result } KeyHandler.prototype.isDown = function(key) { return getState(this).held.has(key.toLowerCase()) } KeyHandler.prototype.getHeldKeys = function() { return [...getState(this).held.keys()] } KeyHandler.prototype.getProtocol = function() { return getState(this).protocol } KeyHandler.prototype.clearHeldKeys = function() { const s = getState(this) for (const e of s.held.values()) { this.emit("keyup" as any, e); this.emit("keyrelease", e) } s.held.clear() for (const t of s.timeouts.values()) clearTimeout(t) s.timeouts.clear(); s.lastSeen.clear(); s.intervals.clear() s.keyCount = 0; s.releaseCount = 0 } KeyHandler.prototype.setProtocol = function(protocol) { const s = getState(this) if (s.protocol !== "legacy" && s.enabled) { write(s.protocol === "win32" ? "\x1b[?9001l" : "\x1b[<u") } s.protocol = protocol; s.enabled = false; s.verified = false s.keyCount = 0; s.releaseCount = 0 } export {}