267 lines
6.1 KiB
TypeScript
267 lines
6.1 KiB
TypeScript
const ESC = "\x1b";
|
|
const CR = "\r";
|
|
const TAB = "\t";
|
|
const BACKSPACE = "\x7f";
|
|
|
|
export const BRACKETED_PASTE_START = `${ESC}[200~`;
|
|
export const BRACKETED_PASTE_END = `${ESC}[201~`;
|
|
|
|
type Modifiers = {
|
|
ctrl: boolean;
|
|
alt: boolean;
|
|
shift: boolean;
|
|
};
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
const namedKeyMap = new Map<string, string>([
|
|
["enter", CR],
|
|
["return", CR],
|
|
["tab", TAB],
|
|
["escape", ESC],
|
|
["esc", ESC],
|
|
["space", " "],
|
|
["bspace", BACKSPACE],
|
|
["backspace", BACKSPACE],
|
|
["up", `${ESC}[A`],
|
|
["down", `${ESC}[B`],
|
|
["right", `${ESC}[C`],
|
|
["left", `${ESC}[D`],
|
|
["home", `${ESC}[1~`],
|
|
["end", `${ESC}[4~`],
|
|
["pageup", `${ESC}[5~`],
|
|
["pgup", `${ESC}[5~`],
|
|
["ppage", `${ESC}[5~`],
|
|
["pagedown", `${ESC}[6~`],
|
|
["pgdn", `${ESC}[6~`],
|
|
["npage", `${ESC}[6~`],
|
|
["insert", `${ESC}[2~`],
|
|
["ic", `${ESC}[2~`],
|
|
["delete", `${ESC}[3~`],
|
|
["del", `${ESC}[3~`],
|
|
["dc", `${ESC}[3~`],
|
|
["btab", `${ESC}[Z`],
|
|
["f1", `${ESC}OP`],
|
|
["f2", `${ESC}OQ`],
|
|
["f3", `${ESC}OR`],
|
|
["f4", `${ESC}OS`],
|
|
["f5", `${ESC}[15~`],
|
|
["f6", `${ESC}[17~`],
|
|
["f7", `${ESC}[18~`],
|
|
["f8", `${ESC}[19~`],
|
|
["f9", `${ESC}[20~`],
|
|
["f10", `${ESC}[21~`],
|
|
["f11", `${ESC}[23~`],
|
|
["f12", `${ESC}[24~`],
|
|
["kp/", `${ESC}Oo`],
|
|
["kp*", `${ESC}Oj`],
|
|
["kp-", `${ESC}Om`],
|
|
["kp+", `${ESC}Ok`],
|
|
["kp7", `${ESC}Ow`],
|
|
["kp8", `${ESC}Ox`],
|
|
["kp9", `${ESC}Oy`],
|
|
["kp4", `${ESC}Ot`],
|
|
["kp5", `${ESC}Ou`],
|
|
["kp6", `${ESC}Ov`],
|
|
["kp1", `${ESC}Oq`],
|
|
["kp2", `${ESC}Or`],
|
|
["kp3", `${ESC}Os`],
|
|
["kp0", `${ESC}Op`],
|
|
["kp.", `${ESC}On`],
|
|
["kpenter", `${ESC}OM`],
|
|
]);
|
|
|
|
const modifiableNamedKeys = new Set([
|
|
"up",
|
|
"down",
|
|
"left",
|
|
"right",
|
|
"home",
|
|
"end",
|
|
"pageup",
|
|
"pgup",
|
|
"ppage",
|
|
"pagedown",
|
|
"pgdn",
|
|
"npage",
|
|
"insert",
|
|
"ic",
|
|
"delete",
|
|
"del",
|
|
"dc",
|
|
]);
|
|
|
|
export type KeyEncodingRequest = {
|
|
keys?: string[];
|
|
hex?: string[];
|
|
literal?: string;
|
|
};
|
|
|
|
export type KeyEncodingResult = {
|
|
data: string;
|
|
warnings: string[];
|
|
};
|
|
|
|
export function encodeKeySequence(request: KeyEncodingRequest): KeyEncodingResult {
|
|
const warnings: string[] = [];
|
|
let data = "";
|
|
|
|
if (request.literal) {
|
|
data += request.literal;
|
|
}
|
|
|
|
if (request.hex?.length) {
|
|
for (const raw of request.hex) {
|
|
const byte = parseHexByte(raw);
|
|
if (byte === null) {
|
|
warnings.push(`Invalid hex byte: ${raw}`);
|
|
continue;
|
|
}
|
|
data += String.fromCharCode(byte);
|
|
}
|
|
}
|
|
|
|
if (request.keys?.length) {
|
|
for (const token of request.keys) {
|
|
data += encodeKeyToken(token, warnings);
|
|
}
|
|
}
|
|
|
|
return { data, warnings };
|
|
}
|
|
|
|
export function encodePaste(text: string, bracketed = true): string {
|
|
if (!bracketed) return text;
|
|
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
|
}
|
|
|
|
function encodeKeyToken(raw: string, warnings: string[]): string {
|
|
const token = raw.trim();
|
|
if (!token) return "";
|
|
|
|
if (token.length === 2 && token.startsWith("^")) {
|
|
const ctrl = toCtrlChar(token[1]);
|
|
if (ctrl) return ctrl;
|
|
}
|
|
|
|
const parsed = parseModifiers(token);
|
|
const base = parsed.base;
|
|
const baseLower = base.toLowerCase();
|
|
|
|
if (baseLower === "tab" && parsed.mods.shift) {
|
|
return `${ESC}[Z`;
|
|
}
|
|
|
|
const baseSeq = namedKeyMap.get(baseLower);
|
|
if (baseSeq) {
|
|
let seq = baseSeq;
|
|
if (modifiableNamedKeys.has(baseLower) && hasAnyModifier(parsed.mods)) {
|
|
const mod = xtermModifier(parsed.mods);
|
|
if (mod > 1) {
|
|
const modified = applyXtermModifier(seq, mod);
|
|
if (modified) {
|
|
seq = modified;
|
|
return seq;
|
|
}
|
|
}
|
|
}
|
|
if (parsed.mods.alt) {
|
|
return `${ESC}${seq}`;
|
|
}
|
|
return seq;
|
|
}
|
|
|
|
if (base.length === 1) {
|
|
return applyCharModifiers(base, parsed.mods);
|
|
}
|
|
|
|
if (parsed.hasModifiers) {
|
|
warnings.push(`Unknown key "${base}" for modifiers; sending literal.`);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function parseModifiers(token: string) {
|
|
const mods: Modifiers = { ctrl: false, alt: false, shift: false };
|
|
let rest = token;
|
|
let sawModifiers = false;
|
|
|
|
while (rest.length > 2 && rest[1] === "-") {
|
|
const mod = rest[0].toLowerCase();
|
|
if (mod === "c") mods.ctrl = true;
|
|
else if (mod === "m") mods.alt = true;
|
|
else if (mod === "s") mods.shift = true;
|
|
else break;
|
|
sawModifiers = true;
|
|
rest = rest.slice(2);
|
|
}
|
|
|
|
return { mods, base: rest, hasModifiers: sawModifiers };
|
|
}
|
|
|
|
function applyCharModifiers(char: string, mods: Modifiers): string {
|
|
let value = char;
|
|
if (mods.shift && value.length === 1 && /[a-z]/.test(value)) {
|
|
value = value.toUpperCase();
|
|
}
|
|
if (mods.ctrl) {
|
|
const ctrl = toCtrlChar(value);
|
|
if (ctrl) value = ctrl;
|
|
}
|
|
if (mods.alt) {
|
|
value = `${ESC}${value}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function toCtrlChar(char: string): string | null {
|
|
if (char.length !== 1) return null;
|
|
if (char === "?") return "\x7f";
|
|
const code = char.toUpperCase().charCodeAt(0);
|
|
if (code >= 64 && code <= 95) {
|
|
return String.fromCharCode(code & 0x1f);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function xtermModifier(mods: Modifiers): number {
|
|
let mod = 1;
|
|
if (mods.shift) mod += 1;
|
|
if (mods.alt) mod += 2;
|
|
if (mods.ctrl) mod += 4;
|
|
return mod;
|
|
}
|
|
|
|
function applyXtermModifier(sequence: string, modifier: number): string | null {
|
|
const escPattern = escapeRegExp(ESC);
|
|
const csiNumber = new RegExp(`^${escPattern}\\[(\\d+)([~A-Z])$`);
|
|
const csiArrow = new RegExp(`^${escPattern}\\[(A|B|C|D|H|F)$`);
|
|
|
|
const numberMatch = sequence.match(csiNumber);
|
|
if (numberMatch) {
|
|
return `${ESC}[${numberMatch[1]};${modifier}${numberMatch[2]}`;
|
|
}
|
|
|
|
const arrowMatch = sequence.match(csiArrow);
|
|
if (arrowMatch) {
|
|
return `${ESC}[1;${modifier}${arrowMatch[1]}`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function hasAnyModifier(mods: Modifiers): boolean {
|
|
return mods.ctrl || mods.alt || mods.shift;
|
|
}
|
|
|
|
function parseHexByte(raw: string): number | null {
|
|
const trimmed = raw.trim().toLowerCase();
|
|
const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
|
|
if (!/^[0-9a-f]{1,2}$/.test(normalized)) return null;
|
|
const value = Number.parseInt(normalized, 16);
|
|
if (Number.isNaN(value) || value < 0 || value > 0xff) return null;
|
|
return value;
|
|
}
|