anbani
Version:
Multifunctional javascript toolkit for Georgian Alphabet - Anbani
199 lines (187 loc) • 6.22 kB
JavaScript
// Pure key parser: a byte state machine turning raw terminal input into a
// stream of normalised events. No timers, no I/O (the ESC-disambiguation timer
// lives in term.mjs and calls flushEscape). Handles UTF-8 (Georgian is 3-byte),
// arrow/nav keys, ctrl combos and bracketed paste, all resilient to a sequence
// being split across read chunks.
//
// Event shapes:
// { type: "char", char } printable / multibyte codepoint
// { type: "key", name, ctrl?, shift? } named key (see NAMES below)
// { type: "paste", text } bracketed-paste payload
//
// KEEP IN SYNC WITH anbani.py/anbani/tui/keys.py
const PASTE_END = [0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e]; // ESC [ 2 0 1 ~
const PASTE_MAX = 65536;
export function makeParserState() {
return {
mode: "GROUND", // GROUND | ESC | CSI | SS3 | PASTE | UTF8
csi: "",
ss3: false,
utf8Need: 0,
utf8Bytes: [],
pasteBytes: [],
escPending: false,
};
}
const key = (name, extra) => Object.assign({ type: "key", name }, extra || {});
const chr = (c) => ({ type: "char", char: c });
function decodeUtf8(bytes) {
return Buffer.from(bytes).toString("utf8");
}
function groundByte(b, events, st) {
if (b === 0x0d || b === 0x0a) events.push(key("enter"));
else if (b === 0x09) events.push(key("tab"));
else if (b === 0x7f || b === 0x08) events.push(key("backspace"));
else if (b === 0x03) events.push(key("ctrl+c", { ctrl: true }));
else if (b === 0x04) events.push(key("ctrl+d", { ctrl: true }));
else if (b === 0x01) events.push(key("home"));
else if (b === 0x05) events.push(key("end"));
else if (b === 0x15) events.push(key("ctrl+u", { ctrl: true }));
else if (b === 0x17) events.push(key("ctrl+w", { ctrl: true }));
else if (b === 0x0b) events.push(key("ctrl+k", { ctrl: true }));
else if (b === 0x1b) {
st.mode = "ESC";
st.escPending = true;
} else if (b >= 0x20 && b <= 0x7e) events.push(chr(String.fromCharCode(b)));
else if (b >= 0xc2 && b <= 0xf4) {
st.mode = "UTF8";
st.utf8Need = b < 0xe0 ? 2 : b < 0xf0 ? 3 : 4;
st.utf8Bytes = [b];
}
// other C0 / stray continuation bytes: ignored
}
// Returns true if the byte was consumed, false if it must be reprocessed.
function escByte(b, events, st) {
if (b === 0x5b) {
st.mode = "CSI";
st.csi = "";
st.escPending = false;
return true;
}
if (b === 0x4f) {
st.mode = "SS3";
st.escPending = false;
return true;
}
events.push(key("escape"));
st.escPending = false;
st.mode = "GROUND";
return false; // reprocess b in GROUND
}
function interpretCsi(params, final, events, st) {
const letter = String.fromCharCode(final);
const arrow = { A: "up", B: "down", C: "right", D: "left", H: "home", F: "end" };
if (letter === "Z") {
events.push(key("shift+tab", { shift: true }));
return;
}
if (final === 0x7e) {
// "~"-terminated: numeric parameter selects the key
const num = parseInt(params.split(";")[0], 10);
if (num === 200) {
st.mode = "PASTE";
st.pasteBytes = [];
return;
}
const map = { 1: "home", 7: "home", 4: "end", 8: "end", 3: "delete", 5: "pageup", 6: "pagedown" };
if (map[num]) events.push(key(map[num]));
// 2 (insert), 201 (stray), unknown -> ignore
return;
}
if (arrow[letter]) {
const parts = params.split(";");
const extra = {};
if (parts.length >= 2) {
const mod = parseInt(parts[1], 10) - 1;
if (mod & 1) extra.shift = true;
if (mod & 4) extra.ctrl = true;
}
events.push(key(arrow[letter], extra));
}
// unknown final -> ignore
}
function endsWith(arr, suffix) {
if (arr.length < suffix.length) return false;
const off = arr.length - suffix.length;
for (let i = 0; i < suffix.length; i++) if (arr[off + i] !== suffix[i]) return false;
return true;
}
function normalizePaste(text) {
let out = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// strip C0 controls except newline and tab
return out.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
}
export function parseKeys(buffer, state) {
const st = state || makeParserState();
const events = [];
let i = 0;
while (i < buffer.length) {
const b = buffer[i];
switch (st.mode) {
case "GROUND":
groundByte(b, events, st);
i++;
break;
case "ESC":
if (escByte(b, events, st)) i++;
break;
case "CSI":
if (b >= 0x40 && b <= 0x7e) {
interpretCsi(st.csi, b, events, st);
if (st.mode === "CSI") st.mode = "GROUND";
} else {
st.csi += String.fromCharCode(b);
}
i++;
break;
case "SS3": {
const l = String.fromCharCode(b);
const m = { A: "up", B: "down", C: "right", D: "left", H: "home", F: "end" };
if (m[l]) events.push(key(m[l]));
st.mode = "GROUND";
i++;
break;
}
case "UTF8":
if (b >= 0x80 && b <= 0xbf) {
st.utf8Bytes.push(b);
if (st.utf8Bytes.length === st.utf8Need) {
events.push(chr(decodeUtf8(st.utf8Bytes)));
st.mode = "GROUND";
}
i++;
} else {
events.push(chr("�"));
st.mode = "GROUND";
// reprocess b in GROUND (do not increment)
}
break;
case "PASTE":
st.pasteBytes.push(b);
if (endsWith(st.pasteBytes, PASTE_END)) {
const content = st.pasteBytes.slice(0, st.pasteBytes.length - PASTE_END.length);
const text = normalizePaste(decodeUtf8(content.slice(0, PASTE_MAX)));
events.push({ type: "paste", text });
st.pasteBytes = [];
st.mode = "GROUND";
}
i++;
break;
default:
st.mode = "GROUND";
i++;
}
}
return { events, state: st };
}
// Called by the term layer when the ESC-disambiguation timer fires with a lone
// ESC still pending: flush it as an `escape` key.
export function flushEscape(state) {
const events = [];
if (state && state.mode === "ESC") {
events.push(key("escape"));
state.mode = "GROUND";
state.escPending = false;
}
return { events, state };
}