Skip to content

Commit 995ad2b

Browse files
puskinnodejs-github-bot
authored andcommitted
repl: add proper vertical cursor movements
PR-URL: #58003 Reviewed-By: Ruben Bridgewater <[email protected]> Reviewed-By: Pietro Marchini <[email protected]>
1 parent 96be783 commit 995ad2b

File tree

3 files changed

+121
-38
lines changed

3 files changed

+121
-38
lines changed

lib/internal/readline/interface.js

+55-33
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ const kSavePreviousState = Symbol('_savePreviousState');
155155
const kRestorePreviousState = Symbol('_restorePreviousState');
156156
const kPreviousLine = Symbol('_previousLine');
157157
const kPreviousCursor = Symbol('_previousCursor');
158+
const kPreviousCursorCols = Symbol('_previousCursorCols');
159+
const kMultilineMove = Symbol('_multilineMove');
158160
const kPreviousPrevRows = Symbol('_previousPrevRows');
159161
const kAddNewLineOnTTY = Symbol('_addNewLineOnTTY');
160162

@@ -245,6 +247,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
245247
this[kRedoStack] = [];
246248
this.history = history;
247249
this.historySize = historySize;
250+
this[kPreviousCursorCols] = -1;
248251

249252
// The kill ring is a global list of blocks of text that were previously
250253
// killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest
@@ -1114,27 +1117,50 @@ class Interface extends InterfaceConstructor {
11141117
this[kRefreshLine]();
11151118
}
11161119

1117-
[kMoveDownOrHistoryNext]() {
1118-
const { cols, rows } = this.getCursorPos();
1119-
const splitLine = StringPrototypeSplit(this.line, '\n');
1120-
if (!this.historyIndex && rows === splitLine.length) {
1121-
return;
1120+
[kMultilineMove](direction, splitLines, { rows, cols }) {
1121+
const curr = splitLines[rows];
1122+
const down = direction === 1;
1123+
const adj = splitLines[rows + direction];
1124+
const promptLen = kMultilinePrompt.description.length;
1125+
let amountToMove;
1126+
// Clamp distance to end of current + prompt + next/prev line + newline
1127+
const clamp = down ?
1128+
curr.length - cols + promptLen + adj.length + 1 :
1129+
-cols + 1;
1130+
const shouldClamp = cols > adj.length + 1;
1131+
1132+
if (shouldClamp) {
1133+
if (this[kPreviousCursorCols] === -1) {
1134+
this[kPreviousCursorCols] = cols;
1135+
}
1136+
amountToMove = clamp;
1137+
} else {
1138+
if (down) {
1139+
amountToMove = curr.length + 1;
1140+
} else {
1141+
amountToMove = -adj.length - 1;
1142+
}
1143+
if (this[kPreviousCursorCols] !== -1) {
1144+
if (this[kPreviousCursorCols] <= adj.length) {
1145+
amountToMove += this[kPreviousCursorCols] - cols;
1146+
this[kPreviousCursorCols] = -1;
1147+
} else {
1148+
amountToMove = clamp;
1149+
}
1150+
}
11221151
}
1123-
// Go to the next history only if the cursor is in the first line of the multiline input.
1124-
// Otherwise treat the "arrow down" as a movement to the next row.
1125-
if (this[kIsMultiline] && rows < splitLine.length - 1) {
1126-
const currentLine = splitLine[rows];
1127-
const nextLine = splitLine[rows + 1];
1128-
// If I am moving down and the current line is longer than the next line
1129-
const amountToMove = (cols > nextLine.length + 1) ?
1130-
currentLine.length - cols + nextLine.length +
1131-
kMultilinePrompt.description.length + 1 : // Move to the end of the current line
1132-
// + chars to account for the kMultilinePrompt prefix, + 1 to go to the first char
1133-
currentLine.length + 1; // Otherwise just move to the next line, in the same position
1134-
this[kMoveCursor](amountToMove);
1152+
1153+
this[kMoveCursor](amountToMove);
1154+
}
1155+
1156+
[kMoveDownOrHistoryNext]() {
1157+
const cursorPos = this.getCursorPos();
1158+
const splitLines = StringPrototypeSplit(this.line, '\n');
1159+
if (this[kIsMultiline] && cursorPos.rows < splitLines.length - 1) {
1160+
this[kMultilineMove](1, splitLines, cursorPos);
11351161
return;
11361162
}
1137-
1163+
this[kPreviousCursorCols] = -1;
11381164
this[kHistoryNext]();
11391165
}
11401166

@@ -1169,23 +1195,13 @@ class Interface extends InterfaceConstructor {
11691195
}
11701196

11711197
[kMoveUpOrHistoryPrev]() {
1172-
const { cols, rows } = this.getCursorPos();
1173-
if (this.historyIndex === this.history.length && rows) {
1174-
return;
1175-
}
1176-
// Go to the previous history only if the cursor is in the first line of the multiline input.
1177-
// Otherwise treat the "arrow up" as a movement to the previous row.
1178-
if (this[kIsMultiline] && rows > 0) {
1179-
const splitLine = StringPrototypeSplit(this.line, '\n');
1180-
const previousLine = splitLine[rows - 1];
1181-
// If I am moving up and the current line is longer than the previous line
1182-
const amountToMove = (cols > previousLine.length + 1) ?
1183-
-cols + 1 : // Move to the beginning of the current line + 1 char to go to the end of the previous line
1184-
-previousLine.length - 1; // Otherwise just move to the previous line, in the same position
1185-
this[kMoveCursor](amountToMove);
1198+
const cursorPos = this.getCursorPos();
1199+
if (this[kIsMultiline] && cursorPos.rows > 0) {
1200+
const splitLines = StringPrototypeSplit(this.line, '\n');
1201+
this[kMultilineMove](-1, splitLines, cursorPos);
11861202
return;
11871203
}
1188-
1204+
this[kPreviousCursorCols] = -1;
11891205
this[kHistoryPrev]();
11901206
}
11911207

@@ -1296,6 +1312,7 @@ class Interface extends InterfaceConstructor {
12961312
const previousKey = this[kPreviousKey];
12971313
key ||= kEmptyObject;
12981314
this[kPreviousKey] = key;
1315+
let shouldResetPreviousCursorCols = true;
12991316

13001317
if (!key.meta || key.name !== 'y') {
13011318
// Reset yanking state unless we are doing yank pop.
@@ -1543,10 +1560,12 @@ class Interface extends InterfaceConstructor {
15431560
break;
15441561

15451562
case 'up':
1563+
shouldResetPreviousCursorCols = false;
15461564
this[kMoveUpOrHistoryPrev]();
15471565
break;
15481566

15491567
case 'down':
1568+
shouldResetPreviousCursorCols = false;
15501569
this[kMoveDownOrHistoryNext]();
15511570
break;
15521571

@@ -1582,6 +1601,9 @@ class Interface extends InterfaceConstructor {
15821601
}
15831602
}
15841603
}
1604+
if (shouldResetPreviousCursorCols) {
1605+
this[kPreviousCursorCols] = -1;
1606+
}
15851607
}
15861608

15871609
/**

test/parallel/test-repl-multiline-navigation-while-adding.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ tmpdir.refresh();
5555
r.write('22222222222222'); // The command is not complete yet. I can still edit it
5656
r.input.run([{ name: 'up' }]);
5757
r.input.run([{ name: 'up' }]); // I am on the first line
58-
for (let i = 0; i < 4; i++) {
58+
for (let i = 0; i < 3; i++) {
5959
r.input.run([{ name: 'right' }]);
6060
} // I am at the end of the first line
6161
assert.strictEqual(r.cursor, 17);
@@ -101,7 +101,7 @@ tmpdir.refresh();
101101
r.write('22222222222222'); // The command is not complete yet. I can still edit it
102102
r.input.run([{ name: 'up' }]);
103103
r.input.run([{ name: 'up' }]); // I am on the first line
104-
for (let i = 0; i < 2; i++) {
104+
for (let i = 0; i < 3; i++) {
105105
r.input.run([{ name: 'left' }]);
106106
} // I am right after the string definition
107107
assert.strictEqual(r.cursor, 11);

test/parallel/test-repl-multiline-navigation.js

+64-3
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,77 @@ tmpdir.refresh();
7171
assert.strictEqual(r.cursor, 15);
7272
r.input.run([{ name: 'up' }]);
7373

74-
for (let i = 0; i < 5; i++) {
74+
for (let i = 0; i < 4; i++) {
7575
r.input.run([{ name: 'right' }]);
7676
}
77-
assert.strictEqual(r.cursor, 8);
77+
assert.strictEqual(r.cursor, 11);
7878

7979
r.input.run([{ name: 'down' }]);
8080
assert.strictEqual(r.cursor, 15);
8181

8282
r.input.run([{ name: 'down' }]);
83-
assert.strictEqual(r.cursor, 19);
83+
assert.strictEqual(r.cursor, 27);
84+
});
85+
86+
repl.createInternalRepl(
87+
{ NODE_REPL_HISTORY: historyPath },
88+
{
89+
terminal: true,
90+
input: new ActionStream(),
91+
output: new stream.Writable({
92+
write(chunk, _, next) {
93+
next();
94+
}
95+
}),
96+
},
97+
checkResults
98+
);
99+
}
100+
101+
{
102+
const historyPath = tmpdir.resolve(`.${Math.floor(Math.random() * 10000)}`);
103+
// Make sure the cursor is at the right places.
104+
// This is testing cursor clamping and restoring when moving up and down from long lines.
105+
const checkResults = common.mustSucceed((r) => {
106+
r.write('let ddd = `000');
107+
r.input.run([{ name: 'enter' }]);
108+
r.write('1111111111111');
109+
r.input.run([{ name: 'enter' }]);
110+
r.write('22222');
111+
r.input.run([{ name: 'enter' }]);
112+
r.write('2222');
113+
r.input.run([{ name: 'enter' }]);
114+
r.write('22222');
115+
r.input.run([{ name: 'enter' }]);
116+
r.write('33333333`');
117+
r.input.run([{ name: 'up' }]);
118+
assert.strictEqual(r.cursor, 45);
119+
120+
r.input.run([{ name: 'up' }]);
121+
assert.strictEqual(r.cursor, 39);
122+
123+
r.input.run([{ name: 'up' }]);
124+
assert.strictEqual(r.cursor, 34);
125+
126+
r.input.run([{ name: 'up' }]);
127+
assert.strictEqual(r.cursor, 24);
128+
129+
r.input.run([{ name: 'right' }]);
130+
// This is to reach a cursor pos which is much higher than the line we want to go to,
131+
// So we can check that the cursor is clamped to the end of the line.
132+
r.input.run([{ name: 'right' }]);
133+
134+
r.input.run([{ name: 'down' }]);
135+
assert.strictEqual(r.cursor, 34);
136+
137+
r.input.run([{ name: 'down' }]);
138+
assert.strictEqual(r.cursor, 39);
139+
140+
r.input.run([{ name: 'down' }]);
141+
assert.strictEqual(r.cursor, 45);
142+
143+
r.input.run([{ name: 'down' }]);
144+
assert.strictEqual(r.cursor, 55);
84145
});
85146

86147
repl.createInternalRepl(

0 commit comments

Comments
 (0)