/** TTY key parsing for /bin/edit: consume one logical key from a mutable byte queue. */ /** * @param {number} b1 */ function bareEditUtf8TrailCount(b1) { if (b1 >= 0xc0 && b1 < 0xe0) return 1 if (b1 >= 0xe0 && b1 < 0xf0) return 2 if (b1 >= 0xf0) return 3 return 0 } /** * @param {number[]} bytes */ function bareEditUtf8DecodeKey(bytes) { try { const u = new Uint8Array(bytes) if (typeof TextDecoder !== 'undefined') { return new TextDecoder('utf-8', { fatal: false }).decode(u) } } catch { /* fall through */ } let s = '' for (const b of bytes) s += String.fromCharCode(b) return s } /** * @param {string} seq CSI payload after ESC [, including final byte (e.g. "A", "1;5A", "3~") */ function bareEditCsiToEvent(seq) { if (seq === '3~') return { type: 'ctrl', code: 'delete' } if (seq === '5~') return { type: 'nav', key: 'pageup' } if (seq === '6~') return { type: 'nav', key: 'pagedown' } const last = seq.charAt(seq.length - 1) if (last === '~') { if (seq === '1~' || seq === '7~') return { type: 'nav', key: 'home' } if (seq === '4~' || seq === '8~') return { type: 'nav', key: 'end' } const tildeNum = /^(\d+)~$/.exec(seq) if (tildeNum) { const n = parseInt(tildeNum[1], 10) const fnMap = { 11: 1, 12: 2, 13: 3, 14: 4, 15: 5, 17: 6, 18: 7, 19: 8, 20: 9, 21: 10, 23: 11, 24: 12 } if (fnMap[n] != null) return { type: 'fn', n: fnMap[n] } } return { type: 'unknown' } } if (last === 'Z') return { type: 'nav', key: 'stab' } if (last === 'A' || last === 'B' || last === 'C' || last === 'D') { const map = { A: 'up', B: 'down', C: 'right', D: 'left' } return { type: 'nav', key: map[last] } } if (last === 'H') return { type: 'nav', key: 'home' } if (last === 'F') return { type: 'nav', key: 'end' } return { type: 'unknown' } } /** * @param {number} b3 byte after ESC O */ function bareEditSs3ToEvent(b3) { if (b3 === 72) return { type: 'nav', key: 'home' } if (b3 === 70) return { type: 'nav', key: 'end' } if (b3 === 65) return { type: 'nav', key: 'up' } if (b3 === 66) return { type: 'nav', key: 'down' } if (b3 === 67) return { type: 'nav', key: 'right' } if (b3 === 68) return { type: 'nav', key: 'left' } if (b3 === 80) return { type: 'fn', n: 1 } if (b3 === 81) return { type: 'fn', n: 2 } if (b3 === 82) return { type: 'fn', n: 3 } if (b3 === 83) return { type: 'fn', n: 4 } return { type: 'unknown' } } /** ESC [ 200 ~ */ const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126] /** ESC [ 201 ~ */ const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126] /** * @param {number[]} q * @param {number[]} prefix */ function bareEditStartsWithBytes(q, prefix) { if (q.length < prefix.length) return false for (let i = 0; i < prefix.length; i++) { if (q[i] !== prefix[i]) return false } return true } /** * True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes. * @param {number[]} q */ function bareEditCouldBeBracketPastePrefix(q) { const pre = BARE_EDIT_BRACKET_PASTE_START const n = Math.min(q.length, pre.length) for (let i = 0; i < n; i++) { if (q[i] !== pre[i]) return false } return true } /** * @param {number[]} bytes */ function bareEditDecodePasteInner(bytes) { try { if (typeof TextDecoder !== 'undefined') { return new TextDecoder('utf-8', { fatal: false }).decode( new Uint8Array(bytes) ) } } catch { /* fall through */ } let s = '' for (const b of bytes) s += String.fromCharCode(b) return s } /** * If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text. * @param {number[]} q * @returns {string|undefined} */ function bareEditFinalizeBracketedPasteOnEof(q) { const SL = BARE_EDIT_BRACKET_PASTE_START.length if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) { const inner = q.slice(SL) q.length = 0 return bareEditDecodePasteInner(inner) } return undefined } /** * xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~ * @param {number[]} q mutable queue * @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front */ function bareEditTryConsumeBracketedPaste(q) { const SL = BARE_EDIT_BRACKET_PASTE_START.length const EL = BARE_EDIT_BRACKET_PASTE_END.length if (!q.length) return undefined if (q.length < SL) { return bareEditCouldBeBracketPastePrefix(q) ? null : undefined } if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) { return undefined } for (let i = SL; i <= q.length - EL; i++) { let ok = true for (let j = 0; j < EL; j++) { if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) { ok = false break } } if (ok) { const inner = q.slice(SL, i) q.splice(0, i + EL) return bareEditDecodePasteInner(inner) } } return null } /** * @param {number[]} q mutable queue (front = index 0) * @returns {Record | null} null if more bytes needed */ function bareEditTryConsumeKey(q) { if (!q.length) return null const b1 = q[0] if (b1 === 3) { q.shift() return { type: 'ctrl', code: 'interrupt' } } if (b1 === 8 || b1 === 127) { q.shift() return { type: 'ctrl', code: 'backspace' } } if (b1 === 13 || b1 === 10) { q.shift() return { type: 'key', ch: '\n' } } if (b1 === 9) { q.shift() return { type: 'key', ch: '\t' } } if (b1 === 27) { if (q.length < 2) return null const b2 = q[1] if (b2 === 91) { let i = 2 while (i < q.length) { const b = q[i] if (b >= 0x40 && b <= 0x7e) { const seq = String.fromCharCode.apply(null, q.slice(2, i + 1)) q.splice(0, i + 1) return bareEditCsiToEvent(seq) } i++ } return null } if (b2 === 79) { if (q.length < 3) return null const b3 = q[2] q.splice(0, 3) return bareEditSs3ToEvent(b3) } q.splice(0, 2) return { type: 'key', ch: String.fromCharCode(b2) } } if (b1 < 0x20) { q.shift() return { type: 'ctrl', code: b1 } } const need = bareEditUtf8TrailCount(b1) if (q.length < 1 + need) return null const chunk = q.splice(0, 1 + need) return { type: 'key', ch: bareEditUtf8DecodeKey(chunk) } }