// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and // other Node contributors). See LICENSE-node. // Port of Node v24's lib/internal/readline/utils.js: character stepping, the keypress // parser (emitKeys) and string width. getStringWidth is Node's build without ICU: east // Asian wide characters count two columns, combining and control characters none. import { stripVTControlCharacters } from "../inspect"; import { kEscape } from "./callbacks"; const kUTF16SurrogateThreshold = 0x10000; export interface Key { sequence: string | null; name: string | undefined; ctrl: boolean; meta: boolean; shift: boolean; code?: string; } interface KeyEmitter { emit(event: "keypress", s: string | undefined, key: Key): unknown; } /** UTF-16 units of the character left of index i. */ export function charLengthLeft(str: string, i: number): number { if (i <= 0) return 0; if ((i > 1 && str.codePointAt(i - 2)! >= kUTF16SurrogateThreshold) || str.codePointAt(i - 1)! >= kUTF16SurrogateThreshold) { return 2; } return 1; } /** UTF-16 units of the character at index i (1 past the end). */ export function charLengthAt(str: string, i: number): number { if (str.length <= i) return 1; return str.codePointAt(i)! >= kUTF16SurrogateThreshold ? 2 : 1; } const KEY_NAMES: Record = { "[P": ["f1"], "[Q": ["f2"], "[R": ["f3"], "[S": ["f4"], OP: ["f1"], OQ: ["f2"], OR: ["f3"], OS: ["f4"], "[11~": ["f1"], "[12~": ["f2"], "[13~": ["f3"], "[14~": ["f4"], "[200~": ["paste-start"], "[201~": ["paste-end"], "[[A": ["f1"], "[[B": ["f2"], "[[C": ["f3"], "[[D": ["f4"], "[[E": ["f5"], "[15~": ["f5"], "[17~": ["f6"], "[18~": ["f7"], "[19~": ["f8"], "[20~": ["f9"], "[21~": ["f10"], "[23~": ["f11"], "[24~": ["f12"], "[A": ["up"], "[B": ["down"], "[C": ["right"], "[D": ["left"], "[E": ["clear"], "[F": ["end"], "[H": ["home"], OA: ["up"], OB: ["down"], OC: ["right"], OD: ["left"], OE: ["clear"], OF: ["end"], OH: ["home"], "[1~": ["home"], "[2~": ["insert"], "[3~": ["delete"], "[4~": ["end"], "[5~": ["pageup"], "[6~": ["pagedown"], "[[5~": ["pageup"], "[[6~": ["pagedown"], "[7~": ["home"], "[8~": ["end"], "[a": ["up", "shift"], "[b": ["down", "shift"], "[c": ["right", "shift"], "[d": ["left", "shift"], "[e": ["clear", "shift"], "[2$": ["insert", "shift"], "[3$": ["delete", "shift"], "[5$": ["pageup", "shift"], "[6$": ["pagedown", "shift"], "[7$": ["home", "shift"], "[8$": ["end", "shift"], Oa: ["up", "ctrl"], Ob: ["down", "ctrl"], Oc: ["right", "ctrl"], Od: ["left", "ctrl"], Oe: ["clear", "ctrl"], "[2^": ["insert", "ctrl"], "[3^": ["delete", "ctrl"], "[5^": ["pageup", "ctrl"], "[6^": ["pagedown", "ctrl"], "[7^": ["home", "ctrl"], "[8^": ["end", "ctrl"], "[Z": ["tab", "shift"], }; /** * The keypress parser: fed one character per next(), it emits 'keypress' on `stream` for * each key, buffering escape sequences until they are whole. The shapes it accepts: * ESC letter; ESC [ letter; ESC [ modifier letter; ESC [ 1 ; modifier letter; * ESC [ num char; ESC [ num ; modifier char; ESC O letter; ESC O modifier letter; * ESC O 1 ; modifier letter; ESC [ [ num ; modifier char; ESC [ [ 1 ; modifier letter; * ESC ESC [ num char; ESC ESC O letter. The modifier is 1 + shift + 2·alt + 4·ctrl + 8·meta. */ export function* emitKeys(stream: KeyEmitter): Generator { while (true) { let ch = yield; let s = ch; let escaped = false; const key: Key = { sequence: null, name: undefined, ctrl: false, meta: false, shift: false }; if (ch === kEscape) { escaped = true; s += (ch = yield); if (ch === kEscape) s += (ch = yield); } if (escaped && (ch === "O" || ch === "[")) { let code = ch; let modifier = 0; if (ch === "O") { s += (ch = yield); if (ch >= "0" && ch <= "9") { modifier = (Number(ch) >> 0) - 1; s += (ch = yield); } code += ch; } else if (ch === "[") { s += (ch = yield); if (ch === "[") { code += ch; s += (ch = yield); } // Buffer just enough for a whole sequence: `\x1b[24;5~` is { code: '[24~', // modifier: 5 } (up to three digits, three only for bracketed paste) and // `\x1b[1;5H` is { code: '[H', modifier: 5 }. const cmdStart = s.length - 1; if (ch >= "0" && ch <= "9") { s += (ch = yield); if (ch >= "0" && ch <= "9") { s += (ch = yield); if (ch >= "0" && ch <= "9") s += (ch = yield); } } if (ch === ";") { s += (ch = yield); if (ch >= "0" && ch <= "9") s += yield; } const cmd = s.slice(cmdStart); let match: RegExpExecArray | null; if ((match = /^(?:(\d\d?)(?:;(\d))?([~^$])|(\d{3}~))$/.exec(cmd))) { if (match[4]) { code += match[4]; } else { code += match[1]! + match[3]!; modifier = Number(match[2] || 1) - 1; } } else if ((match = /^((\d;)?(\d))?([A-Za-z])$/.exec(cmd))) { code += match[4]!; modifier = Number(match[3] || 1) - 1; } else { code += cmd; } } key.ctrl = Boolean(modifier & 4); key.meta = Boolean(modifier & 10); key.shift = Boolean(modifier & 1); key.code = code; const named = KEY_NAMES[code]; key.name = named ? named[0] : "undefined"; if (named?.[1]) key[named[1]] = true; } else if (ch === "\r") { key.name = "return"; key.meta = escaped; } else if (ch === "\n") { key.name = "enter"; key.meta = escaped; } else if (ch === "\t") { key.name = "tab"; key.meta = escaped; } else if (ch === "\b" || ch === "\x7f") { key.name = "backspace"; key.meta = escaped; } else if (ch === kEscape) { key.name = "escape"; key.meta = escaped; } else if (ch === " ") { key.name = "space"; key.meta = escaped; } else if (!escaped && ch <= "\x1a") { key.name = String.fromCharCode(ch.charCodeAt(0) + "a".charCodeAt(0) - 1); key.ctrl = true; } else if (/^[0-9A-Za-z]$/.exec(ch) !== null) { key.name = ch.toLowerCase(); key.shift = /^[A-Z]$/.exec(ch) !== null; key.meta = escaped; } else if (escaped) { // An escape that timed out ('' is the timeout's nudge). key.name = ch.length ? undefined : "escape"; key.meta = true; } key.sequence = s; if (s.length !== 0 && (key.name !== undefined || escaped)) { stream.emit("keypress", escaped ? undefined : s, key); } else if (charLengthAt(s, 0) === s.length) { stream.emit("keypress", s, key); } // An unrecognised or broken escape sequence emits nothing. } } export function commonPrefix(strings: string[]): string { if (strings.length === 0) return ""; if (strings.length === 1) return strings[0]!; const sorted = [...strings].sort(); const min = sorted[0]!; const max = sorted[sorted.length - 1]!; for (let i = 0; i < min.length; i++) { if (min[i] !== max[i]) return min.slice(0, i); } return min; } export function reverseString(line: string, from = "\r", to = "\r"): string { const parts = line.split(from); let result = ""; for (let i = parts.length - 1; i > 0; i--) result += parts[i] + to; result += parts[0]; return result; } function isZeroWidthCodePoint(code: number): boolean { return code <= 0x1f || (code >= 0x7f && code <= 0x9f) || (code >= 0x300 && code <= 0x36f) || (code >= 0x200b && code <= 0x200f) || (code >= 0x20d0 && code <= 0x20ff) || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0xfe20 && code <= 0xfe2f) || (code >= 0xe0100 && code <= 0xe01ef); } function isFullWidthCodePoint(code: number): boolean { return code >= 0x1100 && ( code <= 0x115f || code === 0x2329 || code === 0x232a || (code >= 0x2e80 && code <= 0x3247 && code !== 0x303f) || (code >= 0x3250 && code <= 0x4dbf) || (code >= 0x4e00 && code <= 0xa4c6) || (code >= 0xa960 && code <= 0xa97c) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe10 && code <= 0xfe19) || (code >= 0xfe30 && code <= 0xfe6b) || (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) || (code >= 0x1b000 && code <= 0x1b001) || (code >= 0x1f200 && code <= 0x1f251) || (code >= 0x1f300 && code <= 0x1f64f) || (code >= 0x20000 && code <= 0x3fffd) ); } /** Columns needed to display `str`. */ export function getStringWidth(str: string, removeControlChars = true): number { let width = 0; if (removeControlChars) str = stripVTControlCharacters(str); for (const char of str.normalize("NFC")) { const code = char.codePointAt(0)!; if (isFullWidthCodePoint(code)) width += 2; else if (!isZeroWidthCodePoint(code)) width++; } return width; }