Skip to content

Commit

Permalink
readline: improve unicode support and tab completion
Browse files Browse the repository at this point in the history
1. This reduces the number of write operations used during tab
   completion.
2. The tab completion calculated the string width using the length
   of the string instead of using the actual width. That is fixed.
3. The key decoder is now capable of handling characters composed
   out of two code points. That reduces the number of "keypress"
   events that are emitted which again lowers the amount of writes
   triggered.

PR-URL: #31288
Reviewed-By: James M Snell <[email protected]>
Reviewed-By: Rich Trott <[email protected]>
  • Loading branch information
BridgeAR authored and targos committed Apr 28, 2020
1 parent ee40b67 commit 7582bce
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 71 deletions.
3 changes: 1 addition & 2 deletions lib/internal/readline/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ function stripVTControlCharacters(str) {
return str.replace(ansi, '');
}


/*
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 @@ -450,7 +449,7 @@ function* emitKeys(stream) {
if (s.length !== 0 && (key.name !== undefined || escaped)) {
/* Named character or sequence */
stream.emit('keypress', escaped ? undefined : s, key);
} else if (s.length === 1) {
} else if (charLengthAt(s, 0) === s.length) {
/* Single unnamed character, e.g. "." */
stream.emit('keypress', s, key);
}
Expand Down
123 changes: 54 additions & 69 deletions lib/readline.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,81 +494,67 @@ Interface.prototype._insertString = function(c) {
};

Interface.prototype._tabComplete = function(lastKeypressWasTab) {
const self = this;

self.pause();
self.completer(self.line.slice(0, self.cursor), function onComplete(err, rv) {
self.resume();
this.pause();
this.completer(this.line.slice(0, this.cursor), (err, value) => {
this.resume();

if (err) {
self._writeToOutput(`Tab completion error: ${inspect(err)}`);
this._writeToOutput(`Tab completion error: ${inspect(err)}`);
return;
}

// Result and the text that was completed.
const [completions, completeOn] = rv;
const [completions, completeOn] = value;

if (!completions || completions.length === 0) {
return;
}

// Apply/show completions.
if (lastKeypressWasTab) {
self._writeToOutput('\r\n');
const width = completions.reduce((a, b) => {
return a.length > b.length ? a : b;
}).length + 2; // 2 space padding
let maxColumns = MathFloor(self.columns / width);
if (!maxColumns || maxColumns === Infinity) {
maxColumns = 1;
}
let group = [];
for (const c of completions) {
if (c === '') {
handleGroup(self, group, width, maxColumns);
group = [];
} else {
group.push(c);
}
}
handleGroup(self, group, width, maxColumns);
}

// If there is a common prefix to all matches, then apply that portion.
const f = completions.filter((e) => e);
const prefix = commonPrefix(f);
const prefix = commonPrefix(completions.filter((e) => e !== ''));
if (prefix.length > completeOn.length) {
self._insertString(prefix.slice(completeOn.length));
this._insertString(prefix.slice(completeOn.length));
return;
}

self._refreshLine();
});
};
if (!lastKeypressWasTab) {
return;
}

// this = Interface instance
function handleGroup(self, group, width, maxColumns) {
if (group.length === 0) {
return;
}
const minRows = MathCeil(group.length / maxColumns);
for (let row = 0; row < minRows; row++) {
for (let col = 0; col < maxColumns; col++) {
const idx = row * maxColumns + col;
if (idx >= group.length) {
break;
// Apply/show completions.
const completionsWidth = completions.map((e) => getStringWidth(e));
const width = MathMax(...completionsWidth) + 2; // 2 space padding
let maxColumns = MathFloor(this.columns / width) || 1;
if (maxColumns === Infinity) {
maxColumns = 1;
}
let output = '\r\n';
let lineIndex = 0;
let whitespace = 0;
for (let i = 0; i < completions.length; i++) {
const completion = completions[i];
if (completion === '' || lineIndex === maxColumns) {
output += '\r\n';
lineIndex = 0;
whitespace = 0;
} else {
output += ' '.repeat(whitespace);
}
const item = group[idx];
self._writeToOutput(item);
if (col < maxColumns - 1) {
for (let s = 0; s < width - item.length; s++) {
self._writeToOutput(' ');
}
if (completion !== '') {
output += completion;
whitespace = width - completionsWidth[i];
lineIndex++;
} else {
output += '\r\n';
}
}
self._writeToOutput('\r\n');
}
self._writeToOutput('\r\n');
}
if (lineIndex !== 0) {
output += '\r\n\r\n';
}
this._writeToOutput(output);
this._refreshLine();
});
};

Interface.prototype._wordLeft = function() {
if (this.cursor > 0) {
Expand Down Expand Up @@ -1125,7 +1111,7 @@ Interface.prototype[SymbolAsyncIterator] = function() {
* accepts a readable Stream instance and makes it emit "keypress" events
*/

function emitKeypressEvents(stream, iface) {
function emitKeypressEvents(stream, iface = {}) {
if (stream[KEYPRESS_DECODER]) return;

stream[KEYPRESS_DECODER] = new StringDecoder('utf8');
Expand All @@ -1138,26 +1124,25 @@ function emitKeypressEvents(stream, iface) {

function onData(b) {
if (stream.listenerCount('keypress') > 0) {
const r = stream[KEYPRESS_DECODER].write(b);
if (r) {
const string = stream[KEYPRESS_DECODER].write(b);
if (string) {
clearTimeout(timeoutId);

let escapeTimeout = ESCAPE_CODE_TIMEOUT;

if (iface) {
iface._sawKeyPress = r.length === 1;
escapeTimeout = iface.escapeCodeTimeout;
}
// This supports characters of length 2.
iface._sawKeyPress = charLengthAt(string, 0) === string.length;
const escapeTimeout = iface.escapeCodeTimeout || ESCAPE_CODE_TIMEOUT;

for (let i = 0; i < r.length; i++) {
if (r[i] === '\t' && typeof r[i + 1] === 'string' && iface) {
let length = 0;
for (const character of string) {
length += character.length;
if (character === '\t' && length !== string.length) {
iface.isCompletionEnabled = false;
}

try {
stream[ESCAPE_DECODER].next(r[i]);
stream[ESCAPE_DECODER].next(character);
// Escape letter at the tail position
if (r[i] === kEscape && i + 1 === r.length) {
if (character === kEscape && length === string.length) {
timeoutId = setTimeout(escapeCodeTimeout, escapeTimeout);
}
} catch (err) {
Expand Down
68 changes: 68 additions & 0 deletions test/parallel/test-readline-tab-complete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use strict';

// Flags: --expose_internals

const common = require('../common');
const readline = require('readline');
const assert = require('assert');
const EventEmitter = require('events').EventEmitter;
const { getStringWidth } = require('internal/readline/utils');

// This test verifies that the tab completion supports unicode and the writes
// are limited to the minimum.
[
'あ',
'𐐷',
'🐕'
].forEach((char) => {
[true, false].forEach((lineBreak) => {
const completer = (line) => [
[
'First group',
'',
`${char}${'a'.repeat(10)}`, `${char}${'b'.repeat(10)}`, char.repeat(11),
],
line
];

let output = '';
const width = getStringWidth(char) - 1;

class FakeInput extends EventEmitter {
columns = ((width + 1) * 10 + (lineBreak ? 0 : 10)) * 3

write = common.mustCall((data) => {
output += data;
}, 6)

resume() {}
pause() {}
end() {}
}

const fi = new FakeInput();
const rli = new readline.Interface({
input: fi,
output: fi,
terminal: true,
completer: completer
});

const last = '\r\nFirst group\r\n\r\n' +
`${char}${'a'.repeat(10)}${' '.repeat(2 + width * 10)}` +
`${char}${'b'.repeat(10)}` +
(lineBreak ? '\r\n' : ' '.repeat(2 + width * 10)) +
`${char.repeat(11)}\r\n` +
`\r\n\u001b[1G\u001b[0J> ${char}\u001b[${4 + width}G`;

const expectations = [char, '', last];

rli.on('line', common.mustNotCall());
for (const character of `${char}\t\t`) {
fi.emit('data', character);
assert.strictEqual(output, expectations.shift());
output = '';
}
rli.close();
});
});

0 comments on commit 7582bce

Please sign in to comment.