Skip to content

Commit 3881189

Browse files
BridgeARMylesBorins
authored andcommitted
readline: improve unicode support and tab completion
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 <jasnell@gmail.com> Reviewed-By: Rich Trott <rtrott@gmail.com>
1 parent 52504fb commit 3881189

File tree

3 files changed

+123
-71
lines changed

3 files changed

+123
-71
lines changed

lib/internal/readline/utils.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,6 @@ function stripVTControlCharacters(str) {
163163
return str.replace(ansi, '');
164164
}
165165

166-
167166
/*
168167
Some patterns seen in terminal key escape codes, derived from combos seen
169168
at http://www.midnight-commander.org/browser/lib/tty/key.c
@@ -450,7 +449,7 @@ function* emitKeys(stream) {
450449
if (s.length !== 0 && (key.name !== undefined || escaped)) {
451450
/* Named character or sequence */
452451
stream.emit('keypress', escaped ? undefined : s, key);
453-
} else if (s.length === 1) {
452+
} else if (charLengthAt(s, 0) === s.length) {
454453
/* Single unnamed character, e.g. "." */
455454
stream.emit('keypress', s, key);
456455
}

lib/readline.js

+54-69
Original file line numberDiff line numberDiff line change
@@ -494,81 +494,67 @@ Interface.prototype._insertString = function(c) {
494494
};
495495

496496
Interface.prototype._tabComplete = function(lastKeypressWasTab) {
497-
const self = this;
498-
499-
self.pause();
500-
self.completer(self.line.slice(0, self.cursor), function onComplete(err, rv) {
501-
self.resume();
497+
this.pause();
498+
this.completer(this.line.slice(0, this.cursor), (err, value) => {
499+
this.resume();
502500

503501
if (err) {
504-
self._writeToOutput(`Tab completion error: ${inspect(err)}`);
502+
this._writeToOutput(`Tab completion error: ${inspect(err)}`);
505503
return;
506504
}
507505

508506
// Result and the text that was completed.
509-
const [completions, completeOn] = rv;
507+
const [completions, completeOn] = value;
510508

511509
if (!completions || completions.length === 0) {
512510
return;
513511
}
514512

515-
// Apply/show completions.
516-
if (lastKeypressWasTab) {
517-
self._writeToOutput('\r\n');
518-
const width = completions.reduce((a, b) => {
519-
return a.length > b.length ? a : b;
520-
}).length + 2; // 2 space padding
521-
let maxColumns = MathFloor(self.columns / width);
522-
if (!maxColumns || maxColumns === Infinity) {
523-
maxColumns = 1;
524-
}
525-
let group = [];
526-
for (const c of completions) {
527-
if (c === '') {
528-
handleGroup(self, group, width, maxColumns);
529-
group = [];
530-
} else {
531-
group.push(c);
532-
}
533-
}
534-
handleGroup(self, group, width, maxColumns);
535-
}
536-
537513
// If there is a common prefix to all matches, then apply that portion.
538-
const f = completions.filter((e) => e);
539-
const prefix = commonPrefix(f);
514+
const prefix = commonPrefix(completions.filter((e) => e !== ''));
540515
if (prefix.length > completeOn.length) {
541-
self._insertString(prefix.slice(completeOn.length));
516+
this._insertString(prefix.slice(completeOn.length));
517+
return;
542518
}
543519

544-
self._refreshLine();
545-
});
546-
};
520+
if (!lastKeypressWasTab) {
521+
return;
522+
}
547523

548-
// this = Interface instance
549-
function handleGroup(self, group, width, maxColumns) {
550-
if (group.length === 0) {
551-
return;
552-
}
553-
const minRows = MathCeil(group.length / maxColumns);
554-
for (let row = 0; row < minRows; row++) {
555-
for (let col = 0; col < maxColumns; col++) {
556-
const idx = row * maxColumns + col;
557-
if (idx >= group.length) {
558-
break;
524+
// Apply/show completions.
525+
const completionsWidth = completions.map((e) => getStringWidth(e));
526+
const width = MathMax(...completionsWidth) + 2; // 2 space padding
527+
let maxColumns = MathFloor(this.columns / width) || 1;
528+
if (maxColumns === Infinity) {
529+
maxColumns = 1;
530+
}
531+
let output = '\r\n';
532+
let lineIndex = 0;
533+
let whitespace = 0;
534+
for (let i = 0; i < completions.length; i++) {
535+
const completion = completions[i];
536+
if (completion === '' || lineIndex === maxColumns) {
537+
output += '\r\n';
538+
lineIndex = 0;
539+
whitespace = 0;
540+
} else {
541+
output += ' '.repeat(whitespace);
559542
}
560-
const item = group[idx];
561-
self._writeToOutput(item);
562-
if (col < maxColumns - 1) {
563-
for (let s = 0; s < width - item.length; s++) {
564-
self._writeToOutput(' ');
565-
}
543+
if (completion !== '') {
544+
output += completion;
545+
whitespace = width - completionsWidth[i];
546+
lineIndex++;
547+
} else {
548+
output += '\r\n';
566549
}
567550
}
568-
self._writeToOutput('\r\n');
569-
}
570-
self._writeToOutput('\r\n');
571-
}
551+
if (lineIndex !== 0) {
552+
output += '\r\n\r\n';
553+
}
554+
this._writeToOutput(output);
555+
this._refreshLine();
556+
});
557+
};
572558

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

1128-
function emitKeypressEvents(stream, iface) {
1114+
function emitKeypressEvents(stream, iface = {}) {
11291115
if (stream[KEYPRESS_DECODER]) return;
11301116

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

11391125
function onData(b) {
11401126
if (stream.listenerCount('keypress') > 0) {
1141-
const r = stream[KEYPRESS_DECODER].write(b);
1142-
if (r) {
1127+
const string = stream[KEYPRESS_DECODER].write(b);
1128+
if (string) {
11431129
clearTimeout(timeoutId);
11441130

1145-
let escapeTimeout = ESCAPE_CODE_TIMEOUT;
1146-
1147-
if (iface) {
1148-
iface._sawKeyPress = r.length === 1;
1149-
escapeTimeout = iface.escapeCodeTimeout;
1150-
}
1131+
// This supports characters of length 2.
1132+
iface._sawKeyPress = charLengthAt(string, 0) === string.length;
1133+
const escapeTimeout = iface.escapeCodeTimeout || ESCAPE_CODE_TIMEOUT;
11511134

1152-
for (let i = 0; i < r.length; i++) {
1153-
if (r[i] === '\t' && typeof r[i + 1] === 'string' && iface) {
1135+
let length = 0;
1136+
for (const character of string) {
1137+
length += character.length;
1138+
if (character === '\t' && length !== string.length) {
11541139
iface.isCompletionEnabled = false;
11551140
}
11561141

11571142
try {
1158-
stream[ESCAPE_DECODER].next(r[i]);
1143+
stream[ESCAPE_DECODER].next(character);
11591144
// Escape letter at the tail position
1160-
if (r[i] === kEscape && i + 1 === r.length) {
1145+
if (character === kEscape && length === string.length) {
11611146
timeoutId = setTimeout(escapeCodeTimeout, escapeTimeout);
11621147
}
11631148
} catch (err) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use strict';
2+
3+
// Flags: --expose_internals
4+
5+
const common = require('../common');
6+
const readline = require('readline');
7+
const assert = require('assert');
8+
const EventEmitter = require('events').EventEmitter;
9+
const { getStringWidth } = require('internal/readline/utils');
10+
11+
// This test verifies that the tab completion supports unicode and the writes
12+
// are limited to the minimum.
13+
[
14+
'あ',
15+
'𐐷',
16+
'🐕'
17+
].forEach((char) => {
18+
[true, false].forEach((lineBreak) => {
19+
const completer = (line) => [
20+
[
21+
'First group',
22+
'',
23+
`${char}${'a'.repeat(10)}`, `${char}${'b'.repeat(10)}`, char.repeat(11),
24+
],
25+
line
26+
];
27+
28+
let output = '';
29+
const width = getStringWidth(char) - 1;
30+
31+
class FakeInput extends EventEmitter {
32+
columns = ((width + 1) * 10 + (lineBreak ? 0 : 10)) * 3
33+
34+
write = common.mustCall((data) => {
35+
output += data;
36+
}, 6)
37+
38+
resume() {}
39+
pause() {}
40+
end() {}
41+
}
42+
43+
const fi = new FakeInput();
44+
const rli = new readline.Interface({
45+
input: fi,
46+
output: fi,
47+
terminal: true,
48+
completer: completer
49+
});
50+
51+
const last = '\r\nFirst group\r\n\r\n' +
52+
`${char}${'a'.repeat(10)}${' '.repeat(2 + width * 10)}` +
53+
`${char}${'b'.repeat(10)}` +
54+
(lineBreak ? '\r\n' : ' '.repeat(2 + width * 10)) +
55+
`${char.repeat(11)}\r\n` +
56+
`\r\n\u001b[1G\u001b[0J> ${char}\u001b[${4 + width}G`;
57+
58+
const expectations = [char, '', last];
59+
60+
rli.on('line', common.mustNotCall());
61+
for (const character of `${char}\t\t`) {
62+
fi.emit('data', character);
63+
assert.strictEqual(output, expectations.shift());
64+
output = '';
65+
}
66+
rli.close();
67+
});
68+
});

0 commit comments

Comments
 (0)