* accepts a readable Stream instance and makes it emit "keypress" events
*/
+const KEYPRESS_DECODER = Symbol('keypress-decoder');
+const ESCAPE_DECODER = Symbol('escape-decoder');
+
function emitKeypressEvents(stream) {
- if (stream._keypressDecoder) return;
+ if (stream[KEYPRESS_DECODER]) return;
var StringDecoder = require('string_decoder').StringDecoder; // lazy load
- stream._keypressDecoder = new StringDecoder('utf8');
+ stream[KEYPRESS_DECODER] = new StringDecoder('utf8');
+
+ stream[ESCAPE_DECODER] = emitKeys(stream);
+ stream[ESCAPE_DECODER].next();
function onData(b) {
if (EventEmitter.listenerCount(stream, 'keypress') > 0) {
- var r = stream._keypressDecoder.write(b);
- if (r) emitKeys(stream, r);
+ var r = stream[KEYPRESS_DECODER].write(b);
+ if (r) {
+ for (var i = 0; i < r.length; i++) {
+ stream[ESCAPE_DECODER].next(r[i]);
+ }
+ }
} else {
// Nobody's watching anyway
stream.removeListener('data', onData);
// Regexes used for ansi escape code splitting
const metaKeyCodeReAnywhere = /(?:\x1b)([a-zA-Z0-9])/;
-const metaKeyCodeRe = new RegExp('^' + metaKeyCodeReAnywhere.source + '$');
const functionKeyCodeReAnywhere = new RegExp('(?:\x1b+)(O|N|\\[|\\[\\[)(?:' + [
'(\\d+)(?:;(\\d+))?([~^$])',
'(?:M([@ #!a`])(.)(.))', // mouse
'(?:1;)?(\\d+)?([a-zA-Z])'
].join('|') + ')');
-const functionKeyCodeRe = new RegExp('^' + functionKeyCodeReAnywhere.source);
-const escapeCodeReAnywhere = new RegExp([
- functionKeyCodeReAnywhere.source, metaKeyCodeReAnywhere.source, /\x1b./.source
-].join('|'));
-
-function emitKeys(stream, s) {
- if (s instanceof Buffer) {
- if (s[0] > 127 && s[1] === undefined) {
- s[0] -= 128;
- s = '\x1b' + s.toString(stream.encoding || 'utf-8');
- } else {
- s = s.toString(stream.encoding || 'utf-8');
- }
- }
- var buffer = [];
- var match;
- while (match = escapeCodeReAnywhere.exec(s)) {
- buffer = buffer.concat(s.slice(0, match.index).split(''));
- buffer.push(match[0]);
- s = s.slice(match.index + match[0].length);
- }
- buffer = buffer.concat(s.split(''));
-
- buffer.forEach(function(s) {
- var ch,
- key = {
- sequence: s,
- name: undefined,
- ctrl: false,
- meta: false,
- shift: false
- },
- parts;
-
- if (s === '\r') {
- // carriage return
- key.name = 'return';
- } else if (s === '\n') {
- // enter, should have been called linefeed
- key.name = 'enter';
+function* emitKeys(stream) {
+ while (true) {
+ var ch = yield;
+ var s = ch;
+ var escaped = false;
+ var key = {
+ sequence: null,
+ name: undefined,
+ ctrl: false,
+ meta: false,
+ shift: false
+ };
+
+ if (ch === '\x1b') {
+ escaped = true;
+ s += (ch = yield);
+
+ if (ch === '\x1b') {
+ s += (ch = yield);
+ }
+ }
- } else if (s === '\t') {
- // tab
- key.name = 'tab';
+ if (escaped && (ch === 'O' || ch === '[')) {
+ // ansi escape sequence
+ var code = ch;
+ var modifier = 0;
- } else if (s === '\b' || s === '\x7f' ||
- s === '\x1b\x7f' || s === '\x1b\b') {
- // backspace or ctrl+h
- key.name = 'backspace';
- key.meta = (s.charAt(0) === '\x1b');
+ if (ch === 'O') {
+ // ESC O letter
+ // ESC O modifier letter
+ s += (ch = yield);
- } else if (s === '\x1b' || s === '\x1b\x1b') {
- // escape key
- key.name = 'escape';
- key.meta = (s.length === 2);
+ if (ch >= '0' && ch <= '9') {
+ modifier = (ch >> 0) - 1;
+ s += (ch = yield);
+ }
- } else if (s === ' ' || s === '\x1b ') {
- key.name = 'space';
- key.meta = (s.length === 2);
+ code += ch;
- } else if (s.length === 1 && s <= '\x1a') {
- // ctrl+letter
- key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
- key.ctrl = true;
+ } else if (ch === '[') {
+ // ESC [ letter
+ // ESC [ modifier letter
+ // ESC [ [ modifier letter
+ // ESC [ [ num char
+ s += (ch = yield);
- } else if (s.length === 1 && s >= 'a' && s <= 'z') {
- // lowercase letter
- key.name = s;
+ if (ch === '[') {
+ // \x1b[[A
+ // ^--- escape codes might have a second bracket
+ code += ch;
+ s += (ch = yield);
+ }
- } else if (s.length === 1 && s >= 'A' && s <= 'Z') {
- // shift+letter
- key.name = s.toLowerCase();
- key.shift = true;
+ /*
+ * Here and later we try to buffer just enough data to get
+ * a complete ascii sequence.
+ *
+ * We have basically two classes of ascii characters to process:
+ *
+ *
+ * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 }
+ *
+ * This particular example is featuring Ctrl+F12 in xterm.
+ *
+ * - `;5` part is optional, e.g. it could be `\x1b[24~`
+ * - first part can contain one or two digits
+ *
+ * So the generic regexp is like /^\d\d?(;\d)?[~^$]$/
+ *
+ *
+ * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 }
+ *
+ * This particular example is featuring Ctrl+Home in xterm.
+ *
+ * - `1;5` part is optional, e.g. it could be `\x1b[H`
+ * - `1;` part is optional, e.g. it could be `\x1b[5H`
+ *
+ * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/
+ *
+ */
+ const cmdStart = s.length - 1;
+
+ // skip one or two leading digits
+ if (ch >= '0' && ch <= '9') {
+ s += (ch = yield);
+
+ if (ch >= '0' && ch <= '9') {
+ s += (ch = yield);
+ }
+ }
- } else if (parts = metaKeyCodeRe.exec(s)) {
- // meta+character key
- key.name = parts[1].toLowerCase();
- key.meta = true;
- key.shift = /^[A-Z]$/.test(parts[1]);
+ // skip modifier
+ if (ch === ';') {
+ s += (ch = yield);
- } else if (parts = functionKeyCodeRe.exec(s)) {
- // ansi escape sequence
+ if (ch >= '0' && ch <= '9') {
+ s += (ch = yield);
+ }
+ }
- // reassemble the key code leaving out leading \x1b's,
- // the modifier key bitflag and any meaningless "1;" sequence
- var code = (parts[1] || '') + (parts[2] || '') +
- (parts[4] || '') + (parts[9] || ''),
- modifier = (parts[3] || parts[8] || 1) - 1;
+ /*
+ * We buffered enough data, now trying to extract code
+ * and modifier from it
+ */
+ const cmd = s.slice(cmdStart);
+ var match;
+
+ if ((match = cmd.match(/^(\d\d?)(;(\d))?([~^$])$/))) {
+ code += match[1] + match[4];
+ modifier = (match[3] || 1) - 1;
+ } else if ((match = cmd.match(/^((\d;)?(\d))?([A-Za-z])$/))) {
+ code += match[4];
+ modifier = (match[3] || 1) - 1;
+ } else {
+ code += cmd;
+ }
+ }
// Parse the key modifier
key.ctrl = !!(modifier & 4);
/* misc. */
case '[Z': key.name = 'tab'; key.shift = true; break;
default: key.name = 'undefined'; break;
-
}
- }
- // Don't emit a key if no name was found
- if (key.name === undefined) {
- key = undefined;
- }
+ } else if (ch === '\r') {
+ // carriage return
+ key.name = 'return';
+
+ } else if (ch === '\n') {
+ // enter, should have been called linefeed
+ key.name = 'enter';
+
+ } else if (ch === '\t') {
+ // tab
+ key.name = 'tab';
- if (s.length === 1) {
- ch = s;
+ } else if (ch === '\b' || ch === '\x7f') {
+ // backspace or ctrl+h
+ key.name = 'backspace';
+ key.meta = escaped;
+
+ } else if (ch === '\x1b') {
+ // escape key
+ key.name = 'escape';
+ key.meta = escaped;
+
+ } else if (ch === ' ') {
+ key.name = 'space';
+ key.meta = escaped;
+
+ } else if (!escaped && ch <= '\x1a') {
+ // ctrl+letter
+ key.name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
+ key.ctrl = true;
+
+ } else if (/^[0-9A-Za-z]$/.test(ch)) {
+ // letter, number, shift+letter
+ key.name = ch.toLowerCase();
+ key.shift = /^[A-Z]$/.test(ch);
+ key.meta = escaped;
}
- if (key || ch) {
- stream.emit('keypress', ch, key);
+ key.sequence = s;
+
+ if (key.name !== undefined) {
+ /* Named character or sequence */
+ stream.emit('keypress', escaped ? undefined : s, key);
+ } else if (s.length === 1) {
+ /* Single unnamed character, e.g. "." */
+ stream.emit('keypress', s);
+ } else {
+ /* Unrecognized or broken escape sequence, don't emit anything */
}
- });
+ }
}
--- /dev/null
+var EventEmitter = require('events').EventEmitter;
+var PassThrough = require('stream').PassThrough;
+var assert = require('assert');
+var inherits = require('util').inherits;
+var extend = require('util')._extend;
+var Interface = require('readline').Interface;
+
+
+function FakeInput() {
+ PassThrough.call(this);
+}
+inherits(FakeInput, PassThrough);
+
+
+var fi = new FakeInput();
+var fo = new FakeInput();
+var rli = new Interface({ input: fi, output: fo, terminal: true });
+
+var keys = [];
+fi.on('keypress', function (s, k) {
+ keys.push(k);
+});
+
+
+function addTest(sequences, expectedKeys) {
+ if (!Array.isArray(sequences)) {
+ sequences = [ sequences ];
+ }
+
+ if (!Array.isArray(expectedKeys)) {
+ expectedKeys = [ expectedKeys ];
+ }
+
+ expectedKeys = expectedKeys.map(function (k) {
+ return k ? extend({ ctrl: false, meta: false, shift: false }, k) : k;
+ });
+
+ keys = [];
+
+ sequences.forEach(function (sequence) {
+ fi.write(sequence);
+ });
+ assert.deepStrictEqual(keys, expectedKeys);
+}
+
+// regular alphanumerics
+addTest('io.JS', [
+ { name: 'i', sequence: 'i' },
+ { name: 'o', sequence: 'o' },
+ undefined, // emitted as `emit('keypress', '.', undefined)`
+ { name: 'j', sequence: 'J', shift: true },
+ { name: 's', sequence: 'S', shift: true },
+]);
+
+// named characters
+addTest('\n\r\t', [
+ { name: 'enter', sequence: '\n' },
+ { name: 'return', sequence: '\r' },
+ { name: 'tab', sequence: '\t' },
+]);
+
+// space and backspace
+addTest('\b\x7f\x1b\b\x1b\x7f \x1b ', [
+ { name: 'backspace', sequence: '\b' },
+ { name: 'backspace', sequence: '\x7f' },
+ { name: 'backspace', sequence: '\x1b\b', meta: true },
+ { name: 'backspace', sequence: '\x1b\x7f', meta: true },
+ { name: 'space', sequence: ' ' },
+ { name: 'space', sequence: '\x1b ', meta: true },
+]);
+
+// control keys
+addTest('\x01\x0b\x10', [
+ { name: 'a', sequence: '\x01', ctrl: true },
+ { name: 'k', sequence: '\x0b', ctrl: true },
+ { name: 'p', sequence: '\x10', ctrl: true },
+]);
+
+// alt keys
+addTest('a\x1baA\x1bA', [
+ { name: 'a', sequence: 'a' },
+ { name: 'a', sequence: '\x1ba', meta: true },
+ { name: 'a', sequence: 'A', shift: true },
+ { name: 'a', sequence: '\x1bA', meta: true, shift: true },
+]);
+
+// xterm/gnome
+addTest('\x1bOA\x1bOB', [
+ { name: 'up', sequence: '\x1bOA', code: 'OA' },
+ { name: 'down', sequence: '\x1bOB', code: 'OB' },
+]);
+
+// old xterm shift-arrows
+addTest('\x1bO2A\x1bO2B', [
+ { name: 'up', sequence: '\x1bO2A', code: 'OA', shift: true },
+ { name: 'down', sequence: '\x1bO2B', code: 'OB', shift: true },
+]);
+
+// gnome terminal
+addTest('\x1b[A\x1b[B\x1b[2A\x1b[2B', [
+ { name: 'up', sequence: '\x1b[A', code: '[A' },
+ { name: 'down', sequence: '\x1b[B', code: '[B' },
+ { name: 'up', sequence: '\x1b[2A', code: '[A', shift: true },
+ { name: 'down', sequence: '\x1b[2B', code: '[B', shift: true },
+]);
+
+// rxvt
+addTest('\x1b[20~\x1b[2$\x1b[2^', [
+ { name: 'f9', sequence: '\x1b[20~', code: '[20~' },
+ { name: 'insert', sequence: '\x1b[2$', code: '[2$', shift: true },
+ { name: 'insert', sequence: '\x1b[2^', code: '[2^', ctrl: true },
+]);
+
+// xterm + modifiers
+addTest('\x1b[20;5~\x1b[6;5^', [
+ { name: 'f9', sequence: '\x1b[20;5~', code: '[20~', ctrl: true },
+ { name: 'pagedown', sequence: '\x1b[6;5^', code: '[6^', ctrl: true },
+]);
+
+addTest('\x1b[H\x1b[5H\x1b[1;5H', [
+ { name: 'home', sequence: '\x1b[H', code: '[H' },
+ { name: 'home', sequence: '\x1b[5H', code: '[H', ctrl: true },
+ { name: 'home', sequence: '\x1b[1;5H', code: '[H', ctrl: true },
+]);
+
+// escape sequences broken into multiple data chunks
+addTest('\x1b[D\x1b[C\x1b[D\x1b[C'.split(''), [
+ { name: 'left', sequence: '\x1b[D', code: '[D' },
+ { name: 'right', sequence: '\x1b[C', code: '[C' },
+ { name: 'left', sequence: '\x1b[D', code: '[D' },
+ { name: 'right', sequence: '\x1b[C', code: '[C' },
+]);
+
+// escape sequences mixed with regular ones
+addTest('\x1b[DD\x1b[2DD\x1b[2^D', [
+ { name: 'left', sequence: '\x1b[D', code: '[D' },
+ { name: 'd', sequence: 'D', shift: true },
+ { name: 'left', sequence: '\x1b[2D', code: '[D', shift: true },
+ { name: 'd', sequence: 'D', shift: true },
+ { name: 'insert', sequence: '\x1b[2^', code: '[2^', ctrl: true },
+ { name: 'd', sequence: 'D', shift: true },
+]);
+
+// color sequences
+addTest('\x1b[31ma\x1b[39ma', [
+ { name: 'undefined', sequence: '\x1b[31m', code: '[31m' },
+ { name: 'a', sequence: 'a' },
+ { name: 'undefined', sequence: '\x1b[39m', code: '[39m' },
+ { name: 'a', sequence: 'a' },
+]);