diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 08f7aaa9e3e7e8..a0710b666405f7 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -98,6 +98,7 @@ const ESCAPE_CODE_TIMEOUT = 500; const kMaxLengthOfKillRing = 32; const kMultilinePrompt = Symbol('| '); +const kKeyboardProtocolWasEnabled = Symbol('_keyboardProtocolWasEnabled'); const kAddHistory = Symbol('_addHistory'); const kBeforeEdit = Symbol('_beforeEdit'); @@ -281,6 +282,8 @@ function InterfaceConstructor(input, output, completer, terminal) { } function onkeypress(s, key) { + if (key?.eventType === 'release') + return; self[kTtyWrite](s, key); if (key?.sequence) { // If the key.sequence is half of a surrogate pair @@ -333,6 +336,13 @@ function InterfaceConstructor(input, output, completer, terminal) { // Cursor position on the line. this.cursor = 0; + if (typeof output?.setKeyboardProtocol === 'function' && output.isTTY) { + output.setKeyboardProtocol('kitty'); + this[kKeyboardProtocolWasEnabled] = true; + } else { + this[kKeyboardProtocolWasEnabled] = false; + } + if (output !== null && output !== undefined) output.on('resize', onresize); @@ -549,6 +559,9 @@ class Interface extends InterfaceConstructor { if (this.closed) return; this.pause(); if (this.terminal) { + if (this[kKeyboardProtocolWasEnabled]) { + this.output.setKeyboardProtocol('legacy'); + } this[kSetRawMode](false); } this.closed = true; diff --git a/lib/internal/readline/utils.js b/lib/internal/readline/utils.js index 93029df7c8da30..09a18877292db0 100644 --- a/lib/internal/readline/utils.js +++ b/lib/internal/readline/utils.js @@ -4,6 +4,7 @@ const { ArrayPrototypeToSorted, RegExpPrototypeExec, StringFromCharCode, + StringFromCodePoint, StringPrototypeCharCodeAt, StringPrototypeCodePointAt, StringPrototypeSlice, @@ -15,6 +16,26 @@ const { const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 const kEscape = '\x1b'; const kSubstringSearch = Symbol('kSubstringSearch'); +const kKittyModifierShift = 1; +const kKittyModifierAlt = 2; +const kKittyModifierCtrl = 4; +const kKittyModifierRightAlt = 8; + +const kittyEventTypes = { + __proto__: null, + 1: 'press', + 2: 'repeat', + 3: 'release', +}; + +const kittySpecialKeyNames = { + __proto__: null, + 9: 'tab', + 13: 'return', + 27: 'escape', + 32: 'space', + 127: 'backspace', +}; function CSI(strings, ...args) { let ret = `${kEscape}[`; @@ -56,6 +77,77 @@ function charLengthAt(str, i) { return StringPrototypeCodePointAt(str, i) >= kUTF16SurrogateThreshold ? 2 : 1; } +function decodeKittyCodePoints(text) { + if (text === undefined || text === '') + return undefined; + const chars = StringPrototypeSplit(text, ':'); + let ret = ''; + for (let i = 0; i < chars.length; i++) { + const code = Number(chars[i]); + if (!Number.isInteger(code)) + return undefined; + ret += StringFromCodePoint(code); + } + return ret; +} + +function getKittyBaseName(codepoint, text) { + if (kittySpecialKeyNames[codepoint] !== undefined) + return kittySpecialKeyNames[codepoint]; + + const source = text?.length ? text : + (codepoint > 0 ? StringFromCodePoint(codepoint) : ''); + if (RegExpPrototypeExec(/^[0-9A-Za-z]$/, source) !== null) + return StringPrototypeToLowerCase(source); + return undefined; +} + +function parseKittySequence(code, key) { + const match = RegExpPrototypeExec( + /^(\d+(?::\d+)*)((?:;(?:\d*(?::\d+)?))?)(?:;(\d+(?::\d+)*))?u$/, + code, + ); + if (match === null) + return false; + + const codepoints = StringPrototypeSplit(match[1], ':'); + const primaryCodepoint = Number(codepoints[0]); + if (!Number.isInteger(primaryCodepoint)) + return false; + + let modifiers = 1; + let eventType = 1; + if (match[2] !== '') { + const modifierField = StringPrototypeSlice(match[2], 1); + if (modifierField !== '') { + const modifierParts = StringPrototypeSplit(modifierField, ':'); + modifiers = Number(modifierParts[0] || '1'); + if (!Number.isInteger(modifiers)) + return false; + if (modifierParts.length > 1) { + eventType = Number(modifierParts[1] || '1'); + if (!Number.isInteger(eventType)) + return false; + } + } + } + + const modifierFlags = modifiers - 1; + key.ctrl = !!(modifierFlags & kKittyModifierCtrl); + key.meta = !!(modifierFlags & (kKittyModifierAlt | kKittyModifierRightAlt)); + key.shift = !!(modifierFlags & kKittyModifierShift); + key.modifiers = modifierFlags; + key.eventType = kittyEventTypes[eventType] || 'press'; + key.code = `[${code}`; + + const text = decodeKittyCodePoints(match[3]); + if (text !== undefined) + key.text = text; + + key.name = getKittyBaseName(primaryCodepoint, text); + return true; +} + /* Some patterns seen in terminal key escape codes, derived from combos seen at http://www.midnight-commander.org/browser/lib/tty/key.c @@ -165,27 +257,8 @@ function* emitKeys(stream) { * */ const cmdStart = s.length - 1; - - // Skip one or two leading digits - if (ch >= '0' && ch <= '9') { + while (ch >= '0' && ch <= '9' || ch === ';' || ch === ':') { s += (ch = yield); - - if (ch >= '0' && ch <= '9') { - s += (ch = yield); - - if (ch >= '0' && ch <= '9') { - s += (ch = yield); - } - } - } - - // skip modifier - if (ch === ';') { - s += (ch = yield); - - if (ch >= '0' && ch <= '9') { - s += yield; - } } /* @@ -202,6 +275,9 @@ function* emitKeys(stream) { code += match[1] + match[3]; modifier = (match[2] || 1) - 1; } + } else if (cmd.endsWith('u') && parseKittySequence(cmd, key)) { + code += cmd; + modifier = key.modifiers; } else if ( (match = RegExpPrototypeExec(/^((\d;)?(\d))?([A-Za-z])$/, cmd)) ) { @@ -216,10 +292,10 @@ function* emitKeys(stream) { key.ctrl = !!(modifier & 4); key.meta = !!(modifier & 10); key.shift = !!(modifier & 1); - key.code = code; + key.code ??= code; // Parse the key itself - switch (code) { + if (key.name === undefined) switch (code) { /* xterm/gnome ESC [ letter (with modifier) */ case '[P': key.name = 'f1'; break; case '[Q': key.name = 'f2'; break; @@ -366,9 +442,11 @@ function* emitKeys(stream) { key.sequence = s; + const keypress = escaped && typeof key.text === 'string' ? key.text : s; + if (s.length !== 0 && (key.name !== undefined || escaped)) { /* Named character or sequence */ - stream.emit('keypress', escaped ? undefined : s, key); + stream.emit('keypress', escaped ? keypress : s, key); } else if (charLengthAt(s, 0) === s.length) { /* Single unnamed character, e.g. "." */ stream.emit('keypress', s, key); diff --git a/lib/tty.js b/lib/tty.js index b1a2d3e7a9bc4d..3b1c29861d90e1 100644 --- a/lib/tty.js +++ b/lib/tty.js @@ -43,6 +43,9 @@ const { // Lazy loaded for startup performance. let readline; +const kKittyKeyboardProtocolEnhancementDisambiguateEscapeCodes = 1; +const kKittyKeyboardProtocolRestore = '\x1b[= 0 && fd <= 2147483647 && isTTY(fd); @@ -144,6 +147,31 @@ WriteStream.prototype._refreshSize = function() { } }; +WriteStream.prototype.setKeyboardProtocol = function(protocol, options = {}) { + if (protocol === 'legacy') { + this.write(kKittyKeyboardProtocolRestore); + return this; + } + + if (protocol !== 'kitty') { + throw new ERR_INVALID_ARG_VALUE('protocol', protocol); + } + + let enhancements = + kKittyKeyboardProtocolEnhancementDisambiguateEscapeCodes; + if (options !== null && typeof options === 'object' && + options.enhancements !== undefined) { + enhancements = options.enhancements; + } + + if (!NumberIsInteger(enhancements) || enhancements < 0) { + throw new ERR_INVALID_ARG_VALUE('options.enhancements', enhancements); + } + + this.write(`\x1b[>${enhancements}u`); + return this; +}; + // Backwards-compat WriteStream.prototype.cursorTo = function(x, y, callback) { if (readline === undefined) readline = require('readline'); diff --git a/test/parallel/test-readline-emit-keypress-events.js b/test/parallel/test-readline-emit-keypress-events.js index a9ffd3276c419c..e25528f8ca37cf 100644 --- a/test/parallel/test-readline-emit-keypress-events.js +++ b/test/parallel/test-readline-emit-keypress-events.js @@ -14,6 +14,28 @@ const expectedKeys = [ { sequence: 'o', name: 'o', ctrl: false, meta: false, shift: false }, { sequence: 'o', name: 'o', ctrl: false, meta: false, shift: false }, ]; +const kittyExpectedKeys = [ + { + sequence: '\x1b[127u', + name: 'backspace', + ctrl: false, + meta: false, + shift: false, + modifiers: 0, + eventType: 'press', + code: '[127u', + }, + { + sequence: '\x1b[99;5u', + name: 'c', + ctrl: true, + meta: false, + shift: false, + modifiers: 4, + eventType: 'press', + code: '[99;5u', + }, +]; { const stream = new PassThrough(); @@ -31,6 +53,22 @@ const expectedKeys = [ assert.deepStrictEqual(keys, expectedKeys); } +{ + const stream = new PassThrough(); + const sequence = []; + const keys = []; + + readline.emitKeypressEvents(stream); + stream.on('keypress', (s, k) => { + sequence.push(s); + keys.push(k); + }); + stream.write('\x1b[127u\x1b[99;5u'); + + assert.deepStrictEqual(sequence, ['\x1b[127u', '\x1b[99;5u']); + assert.deepStrictEqual(keys, kittyExpectedKeys); +} + { const stream = new PassThrough(); const sequence = []; diff --git a/test/parallel/test-readline-keys.js b/test/parallel/test-readline-keys.js index 4379193b82f1ed..ec41d058ff6fdb 100644 --- a/test/parallel/test-readline-keys.js +++ b/test/parallel/test-readline-keys.js @@ -127,6 +127,15 @@ addTest('\x01\x0b\x10', [ { name: 'p', sequence: '\x10', ctrl: true }, ]); +// kitty keyboard protocol (`CSI ... u`) +addTest('\x1b[127u\x1b[107;5u\x1b[97;3u\x1b[97;2;65u\x1b[97;5:3u', [ + { name: 'backspace', sequence: '\x1b[127u', code: '[127u', modifiers: 0, eventType: 'press' }, + { name: 'k', sequence: '\x1b[107;5u', code: '[107;5u', ctrl: true, modifiers: 4, eventType: 'press' }, + { name: 'a', sequence: '\x1b[97;3u', code: '[97;3u', meta: true, modifiers: 2, eventType: 'press' }, + { name: 'a', sequence: '\x1b[97;2;65u', code: '[97;2;65u', shift: true, modifiers: 1, eventType: 'press', text: 'A' }, + { name: 'a', sequence: '\x1b[97;5:3u', code: '[97;5:3u', ctrl: true, modifiers: 4, eventType: 'release' }, +]); + // Alt keys addTest('a\x1baA\x1bA', [ { name: 'a', sequence: 'a' }, diff --git a/test/parallel/test-readline-set-raw-mode.js b/test/parallel/test-readline-set-raw-mode.js index 57015077781fd0..3a6e601347b93f 100644 --- a/test/parallel/test-readline-set-raw-mode.js +++ b/test/parallel/test-readline-set-raw-mode.js @@ -30,11 +30,17 @@ let expectedRawMode = true; let rawModeCalled = false; let resumeCalled = false; let pauseCalled = false; +const keyboardProtocols = []; + +stream.isTTY = true; stream.setRawMode = common.mustCallAtLeast(function(mode) { rawModeCalled = true; assert.strictEqual(mode, expectedRawMode); }); +stream.setKeyboardProtocol = common.mustCall(function(protocol) { + keyboardProtocols.push(protocol); +}, 2); stream.resume = function() { resumeCalled = true; }; @@ -53,6 +59,7 @@ assert(rli.terminal); assert(rawModeCalled); assert(resumeCalled); assert(!pauseCalled); +assert.deepStrictEqual(keyboardProtocols, ['kitty']); // pause() should call *not* call setRawMode() @@ -84,6 +91,7 @@ rli.close(); assert(rawModeCalled); assert(!resumeCalled); assert(pauseCalled); +assert.deepStrictEqual(keyboardProtocols, ['kitty', 'legacy']); assert.deepStrictEqual(stream.listeners('keypress'), []); // One data listener for the keypress events.