241 lines
6.4 KiB
JavaScript
241 lines
6.4 KiB
JavaScript
/** 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<string, unknown> | 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) }
|
|
}
|