Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/internal/readline/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down
124 changes: 101 additions & 23 deletions lib/internal/readline/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ArrayPrototypeToSorted,
RegExpPrototypeExec,
StringFromCharCode,
StringFromCodePoint,
StringPrototypeCharCodeAt,
StringPrototypeCodePointAt,
StringPrototypeSlice,
Expand All @@ -15,6 +16,26 @@
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}[`;
Expand Down Expand Up @@ -56,6 +77,77 @@
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]);

Check failure on line 86 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { Number } = primordials;` instead of the global
if (!Number.isInteger(code))

Check failure on line 87 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { NumberIsInteger } = primordials;` instead of the global
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]);

Check failure on line 114 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { Number } = primordials;` instead of the global
if (!Number.isInteger(primaryCodepoint))

Check failure on line 115 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { NumberIsInteger } = primordials;` instead of the global
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');

Check failure on line 124 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { Number } = primordials;` instead of the global
if (!Number.isInteger(modifiers))

Check failure on line 125 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { NumberIsInteger } = primordials;` instead of the global
return false;
if (modifierParts.length > 1) {
eventType = Number(modifierParts[1] || '1');

Check failure on line 128 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { Number } = primordials;` instead of the global
if (!Number.isInteger(eventType))

Check failure on line 129 in lib/internal/readline/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Use `const { NumberIsInteger } = primordials;` instead of the global
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
Expand Down Expand Up @@ -165,27 +257,8 @@
*
*/
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;
}
}

/*
Expand All @@ -202,6 +275,9 @@
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))
) {
Expand All @@ -216,10 +292,10 @@
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;
Expand Down Expand Up @@ -366,9 +442,11 @@

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);
Expand Down
28 changes: 28 additions & 0 deletions lib/tty.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
// Lazy loaded for startup performance.
let readline;

const kKittyKeyboardProtocolEnhancementDisambiguateEscapeCodes = 1;
const kKittyKeyboardProtocolRestore = '\x1b[<u';

function isatty(fd) {
return NumberIsInteger(fd) && fd >= 0 && fd <= 2147483647 &&
isTTY(fd);
Expand Down Expand Up @@ -144,6 +147,31 @@
}
};

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);

Check failure on line 157 in lib/tty.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'ERR_INVALID_ARG_VALUE' is not defined
}

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);

Check failure on line 168 in lib/tty.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'ERR_INVALID_ARG_VALUE' is not defined
}

this.write(`\x1b[>${enhancements}u`);
return this;
};

// Backwards-compat
WriteStream.prototype.cursorTo = function(x, y, callback) {
if (readline === undefined) readline = require('readline');
Expand Down
38 changes: 38 additions & 0 deletions test/parallel/test-readline-emit-keypress-events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 = [];
Expand Down
9 changes: 9 additions & 0 deletions test/parallel/test-readline-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
8 changes: 8 additions & 0 deletions test/parallel/test-readline-set-raw-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -53,6 +59,7 @@ assert(rli.terminal);
assert(rawModeCalled);
assert(resumeCalled);
assert(!pauseCalled);
assert.deepStrictEqual(keyboardProtocols, ['kitty']);


// pause() should call *not* call setRawMode()
Expand Down Expand Up @@ -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.
Expand Down
Loading