Skip to content

Commit b231204

Browse files
authored
Support for line wrapping in Lite Terminal (#1452)
1 parent 978b483 commit b231204

File tree

1 file changed

+177
-43
lines changed

1 file changed

+177
-43
lines changed

src/commands/webSocketTerminal.ts

Lines changed: 177 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
8686
/** The WebSocket used to talk to the server */
8787
private _socket: WebSocket;
8888

89+
/** The number of columns in the terminal */
90+
private _cols: number;
91+
92+
/** The `RegExp` used to strip ANSI color escape codes from a string */
93+
// eslint-disable-next-line no-control-regex
94+
private _colorsRegex = /\x1b[^m]*?m/g;
95+
8996
constructor(private readonly _api: AtelierAPI) {}
9097

9198
/** Hide the cursor, write `data` to the terminal, then show the cursor again. */
@@ -157,7 +164,45 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
157164
return result;
158165
}
159166

160-
open(): void {
167+
/**
168+
* Move the cursor based on user changes (typing/deleting characters, arrow keys) or
169+
* changes to the width of the terminal window
170+
*/
171+
private _moveCursor(cursorColDelta = 0, colsDelta = 0): void {
172+
if (cursorColDelta == 0 && colsDelta == 0) return;
173+
// Calculate the row/column number of the current position
174+
const currCol = this._cursorCol % this._cols;
175+
const currRow = (this._cursorCol - currCol) / this._cols;
176+
// Make the adjustment
177+
if (cursorColDelta != 0) {
178+
this._cursorCol += cursorColDelta;
179+
} else {
180+
this._cols += colsDelta;
181+
}
182+
// Calculate the row/column number of the new position
183+
const newCol = this._cursorCol % this._cols;
184+
const newRow = (this._cursorCol - newCol) / this._cols;
185+
// Move the cursor
186+
const rowDelta = newRow - currRow;
187+
const colDelta = newCol - currCol;
188+
const rowStr = rowDelta ? (rowDelta > 0 ? `\x1b[${rowDelta}B` : `\x1b[${Math.abs(rowDelta)}A`) : "";
189+
const colStr = colDelta ? (colDelta > 0 ? `\x1b[${colDelta}C` : `\x1b[${Math.abs(colDelta)}D`) : "";
190+
this._hideCursorWrite(`${rowStr}${colStr}`);
191+
}
192+
193+
/**
194+
* Move the cursor to the last line of the input (prompt or read)
195+
* so any output doesn't overwrite the end of the input
196+
*/
197+
private _moveCursorToLastLine(): void {
198+
const currRow = (this._cursorCol - (this._cursorCol % this._cols)) / this._cols;
199+
const newRow = Math.ceil((this._margin + this._input.split("\r\n").pop().length + 1) / this._cols) - 1;
200+
const rowDelta = newRow - currRow;
201+
if (rowDelta) this._hideCursorWrite(`\x1b[${rowDelta}B`);
202+
}
203+
204+
open(initialDimensions?: vscode.TerminalDimensions): void {
205+
this._cols = initialDimensions?.columns ?? 100000;
161206
try {
162207
// Open the WebSocket
163208
this._socket = new WebSocket(this._api.terminalUrl(), {
@@ -244,7 +289,7 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
244289
message.text
245290
}\x1b]633;B\x07`
246291
);
247-
this._margin = this._cursorCol = message.text.length;
292+
this._margin = this._cursorCol = message.text.replace(this._colorsRegex, "").length;
248293
this._prompt = message.text;
249294
this._promptExitCode = ";0";
250295
}
@@ -264,17 +309,18 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
264309
break;
265310
case "color": {
266311
// Replace the input with the syntax colored text, keeping the cursor at the same spot
267-
const lines = message.text.split("\r\n").length;
268-
if (lines > 1) {
269-
this._hideCursorWrite(
270-
`\x1b7\x1b[${lines - 1}A\r\x1b[0J${this._prompt}${message.text.replace(
271-
/\r\n/g,
272-
`\r\n${this._multiLinePrompt}`
273-
)}\x1b8`
274-
);
275-
} else {
276-
this._hideCursorWrite(`\x1b7\x1b[2K\r${this._prompt}${message.text}\x1b8`);
312+
let cursorLine = Math.ceil((this._cursorCol + 1) / this._cols) - 1;
313+
if (message.text.includes("\r\n")) {
314+
const lines = message.text.replace(this._colorsRegex, "").split("\r\n");
315+
lines.pop();
316+
cursorLine += lines.reduce((sum, line) => sum + Math.ceil((line.length + 1) / this._cols), 0);
277317
}
318+
this._hideCursorWrite(
319+
`\x1b7${cursorLine > 0 ? `\x1b[${cursorLine}A` : ""}\r\x1b[0J${this._prompt}${message.text.replace(
320+
/\r\n/g,
321+
`\r\n${this._multiLinePrompt}`
322+
)}\x1b8`
323+
);
278324
break;
279325
}
280326
}
@@ -326,6 +372,8 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
326372
// Reset first line tracker
327373
this._firstOutputLineSincePrompt = false;
328374
}
375+
// Move cursor to the last line of the input
376+
this._moveCursorToLastLine();
329377

330378
// Send the input to the server for processing
331379
this._socket.send(JSON.stringify({ type: this._state, input: this._input }));
@@ -351,12 +399,12 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
351399
return;
352400
}
353401
const inputArr = this._input.split("\r\n");
402+
const trailingText = inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin);
354403
inputArr[inputArr.length - 1] =
355-
inputArr[inputArr.length - 1].slice(0, this._cursorCol - this._margin - 1) +
356-
inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin);
404+
inputArr[inputArr.length - 1].slice(0, this._cursorCol - this._margin - 1) + trailingText;
357405
this._input = inputArr.join("\r\n");
358-
this._cursorCol--;
359-
this._hideCursorWrite(actions.cursorBack + actions.deleteChar);
406+
this._moveCursor(-1);
407+
this._hideCursorWrite(`\x1b7\x1b[0J${trailingText}\x1b8`);
360408
if (this._input != "" && this._state == "prompt" && this._syntaxColoringEnabled()) {
361409
// Syntax color input
362410
this._socket.send(JSON.stringify({ type: "color", input: this._input }));
@@ -371,11 +419,11 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
371419
}
372420
const inputArr = this._input.split("\r\n");
373421
if (this._margin + inputArr[inputArr.length - 1].length - this._cursorCol > 0) {
422+
const trailingText = inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin + 1);
374423
inputArr[inputArr.length - 1] =
375-
inputArr[inputArr.length - 1].slice(0, this._cursorCol - this._margin) +
376-
inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin + 1);
424+
inputArr[inputArr.length - 1].slice(0, this._cursorCol - this._margin) + trailingText;
377425
this._input = inputArr.join("\r\n");
378-
this._hideCursorWrite(actions.deleteChar);
426+
this._hideCursorWrite(`\x1b7\x1b[0J${trailingText}\x1b8`);
379427
if (this._input != "" && this._state == "prompt" && this._syntaxColoringEnabled()) {
380428
// Syntax color input
381429
this._socket.send(JSON.stringify({ type: "color", input: this._input }));
@@ -401,7 +449,6 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
401449
// Scroll back one more input
402450
this._historyIdx--;
403451
}
404-
const oldInput = this._input;
405452
if (this._historyIdx >= 0) {
406453
this._input = this._history[this._historyIdx];
407454
} else if (this._historyIdx == -1) {
@@ -411,8 +458,10 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
411458
// If we hit the end, leave the input blank
412459
this._input = "";
413460
}
461+
// Move cursor to start of input, clear everything, then write new input
462+
this._moveCursor(this._margin - this._cursorCol);
463+
this._hideCursorWrite(`\x1b[0J${this._input}`);
414464
this._cursorCol = this._margin + this._input.length;
415-
this._hideCursorWrite(`${oldInput.length ? `\x1b[${oldInput.length}D\x1b[0K` : ""}${this._input}`);
416465
if (this._input != "" && this._syntaxColoringEnabled()) {
417466
// Syntax color input
418467
this._socket.send(JSON.stringify({ type: "color", input: this._input }));
@@ -436,15 +485,16 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
436485
} else {
437486
this._historyIdx++;
438487
}
439-
const oldInput = this._input;
440488
if (this._historyIdx != -1) {
441489
this._input = this._history[this._historyIdx];
442490
} else {
443491
// If we hit the beginning, leave the input blank
444492
this._input = "";
445493
}
494+
// Move cursor to start of input, clear everything, then write new input
495+
this._moveCursor(this._margin - this._cursorCol);
496+
this._hideCursorWrite(`\x1b[0J${this._input}`);
446497
this._cursorCol = this._margin + this._input.length;
447-
this._hideCursorWrite(`${oldInput.length ? `\x1b[${oldInput.length}D\x1b[0K` : ""}${this._input}`);
448498
if (this._input != "" && this._syntaxColoringEnabled()) {
449499
// Syntax color input
450500
this._socket.send(JSON.stringify({ type: "color", input: this._input }));
@@ -457,9 +507,14 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
457507
return;
458508
}
459509
if (this._cursorCol > this._margin) {
460-
// Move the cursor back one column
510+
if (this._cursorCol % this._cols == 0) {
511+
// Move the cursor to the end of the previous line
512+
this._hideCursorWrite(`${actions.cursorUp}\x1b[${this._cols}G`);
513+
} else {
514+
// Move the cursor back one column
515+
this._hideCursorWrite(actions.cursorBack);
516+
}
461517
this._cursorCol--;
462-
this._hideCursorWrite(actions.cursorBack);
463518
}
464519
return;
465520
}
@@ -468,10 +523,15 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
468523
// User can't move cursor
469524
return;
470525
}
471-
if (this._cursorCol < this._margin + this._input.length) {
472-
// Move the cursor forward one column
526+
if (this._cursorCol < this._margin + this._input.split("\r\n").pop().length) {
473527
this._cursorCol++;
474-
this._hideCursorWrite(actions.cursorForward);
528+
if (this._cursorCol % this._cols == 0) {
529+
// Move the cursor to the beginning of the next line
530+
this._hideCursorWrite("\x1b[1E");
531+
} else {
532+
// Move the cursor forward one column
533+
this._hideCursorWrite(actions.cursorForward);
534+
}
475535
}
476536
return;
477537
}
@@ -490,31 +550,31 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
490550
case keys.home:
491551
case keys.ctrlA: {
492552
if (this._state == "prompt" && this._cursorCol - this._margin > 0) {
493-
// Move the cursor to the beginning of the line
494-
this._hideCursorWrite(`\x1b[${this._cursorCol - this._margin}D`);
495-
this._cursorCol = this._margin;
553+
// Move the cursor to the beginning of the input
554+
this._moveCursor(this._margin - this._cursorCol);
496555
}
497556
return;
498557
}
499558
case keys.end:
500559
case keys.ctrlE: {
501560
if (this._state == "prompt") {
502-
// Move the cursor to the end of the line
503-
const inputArr = this._input.split("\r\n");
504-
if (this._margin + inputArr[inputArr.length - 1].length - this._cursorCol > 0) {
505-
this._hideCursorWrite(`\x1b[${this._margin + inputArr[inputArr.length - 1].length - this._cursorCol}C`);
506-
this._cursorCol = this._margin + inputArr[inputArr.length - 1].length;
561+
// Move the cursor to the end of the input
562+
const lineLength = this._input.split("\r\n").pop().length;
563+
if (lineLength > this._cursorCol) {
564+
this._moveCursor(lineLength - this._cursorCol);
507565
}
508566
}
509567
return;
510568
}
511569
case keys.ctrlU: {
512570
if (this._state == "prompt") {
513-
// Erase the line if the cursor is at the end
571+
// Erase the input if the cursor is at the end of it
514572
const inputArr = this._input.split("\r\n");
515573
if (this._cursorCol == this._margin + inputArr[inputArr.length - 1].length) {
516-
this._hideCursorWrite(`\x1b[2K\r${inputArr.length > 1 ? this._multiLinePrompt : this._prompt}`);
517-
this._cursorCol = this._margin;
574+
// Move the cursor to the beginning of the input
575+
this._moveCursor(this._margin - this._cursorCol);
576+
// Erase everyhting to the right of the cursor
577+
this._hideCursorWrite("\x1b[0J");
518578
inputArr[inputArr.length - 1] = "";
519579
this._input = inputArr.join("\r\n");
520580
if (this._input != "" && this._syntaxColoringEnabled()) {
@@ -545,21 +605,66 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
545605
// Replace all single \r with \r\n (prompt) or space (read)
546606
char = char.replace(/\r/g, this._state == "prompt" ? "\r\n" : " ");
547607
const inputArr = this._input.split("\r\n");
608+
let eraseAfterCursor = "",
609+
trailingText = "";
548610
if (this._cursorCol < this._margin + inputArr[inputArr.length - 1].length) {
549611
// Insert the new char(s)
612+
trailingText = inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin);
550613
inputArr[inputArr.length - 1] = `${inputArr[inputArr.length - 1].slice(
551614
0,
552615
this._cursorCol - this._margin
553-
)}${char}${inputArr[inputArr.length - 1].slice(this._cursorCol - this._margin)}`;
616+
)}${char}${trailingText}`;
554617
this._input = inputArr.join("\r\n");
555-
this._cursorCol += char.length;
556-
this._hideCursorWrite(`\x1b[4h${char.replace(/\r\n/g, `\r\n${this._multiLinePrompt}`)}\x1b[4l`);
618+
eraseAfterCursor = "\x1b[0J";
557619
} else {
558620
// Append the new char(s)
559621
this._input += char;
622+
}
623+
const currCol = this._cursorCol % this._cols;
624+
const currRow = (this._cursorCol - currCol) / this._cols;
625+
const originalCol = this._cursorCol;
626+
let newRow: number;
627+
if (char.includes("\r\n")) {
628+
char = char.replace(/\r\n/g, `\r\n${this._multiLinePrompt}`);
629+
this._margin = this._multiLinePrompt.length;
630+
const charLines = char.split("\r\n");
631+
newRow =
632+
charLines.reduce(
633+
(sum, line, i) => sum + Math.ceil(((i == 0 ? this._cursorCol : 0) + line.length + 1) / this._cols),
634+
0
635+
) - 1;
636+
this._cursorCol = charLines[charLines.length - 1].length;
637+
} else {
638+
newRow = Math.ceil((this._cursorCol + char.length + 1) / this._cols) - 1;
560639
this._cursorCol += char.length;
561-
this._hideCursorWrite(char.replace(/\r\n/g, `\r\n${this._multiLinePrompt}`));
562640
}
641+
const rowDelta = newRow - currRow;
642+
const colDelta = (this._cursorCol % this._cols) - currCol;
643+
const rowStr = rowDelta ? (rowDelta > 0 ? `\x1b[${rowDelta}B` : `\x1b[${Math.abs(rowDelta)}A`) : "";
644+
const colStr = colDelta ? (colDelta > 0 ? `\x1b[${colDelta}C` : `\x1b[${Math.abs(colDelta)}D`) : "";
645+
char += trailingText;
646+
const spaceOnCurrentLine = this._cols - (originalCol % this._cols);
647+
if (this._state == "read" && char.length >= spaceOnCurrentLine) {
648+
// There's no auto-line wrapping when in read mode, so we must move the cursor manually
649+
// Extract all the characters that fit on the cursor's line
650+
const firstLine = char.slice(0, spaceOnCurrentLine);
651+
const otherLines = char.slice(spaceOnCurrentLine);
652+
const lines: string[] = [];
653+
if (otherLines.length) {
654+
// Split the rest into an array of lines that fit in the viewport
655+
for (let line = 0, i = 0; line < Math.ceil(otherLines.length / this._cols); line++, i += this._cols) {
656+
lines[line] = otherLines.slice(i, i + this._cols);
657+
}
658+
} else {
659+
// Add a blank "line" to move the cursor to the next viewport row
660+
lines.push("");
661+
}
662+
// Join the lines with the cursor escape code
663+
lines.unshift(firstLine);
664+
char = lines.join("\r\n");
665+
}
666+
// Save the cursor position, write the text, restore the cursor position, then move the cursor manually
667+
this._hideCursorWrite(`\x1b7${eraseAfterCursor}${char}\x1b8${rowStr}${colStr}`);
563668
if (submit) {
564669
if (this._state == "prompt") {
565670
// Reset historyIdx
@@ -579,6 +684,8 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
579684
// Reset first line tracker
580685
this._firstOutputLineSincePrompt = false;
581686
}
687+
// Move cursor to the last line of the input
688+
this._moveCursorToLastLine();
582689

583690
// Send the input to the server for processing
584691
this._socket.send(JSON.stringify({ type: this._state, input: this._input }));
@@ -597,6 +704,33 @@ class WebSocketTerminal implements vscode.Pseudoterminal {
597704
}
598705
}
599706
}
707+
708+
setDimensions(dimensions: vscode.TerminalDimensions): void {
709+
if (this._state != "eval" && this._input != "") {
710+
// Move the cursor to the correct new position
711+
this._moveCursor(undefined, dimensions.columns - this._cols);
712+
// Save the cursor position, move the cursor to just after the margin,
713+
// clear the screen from that point, write the input, then restore the cursor
714+
let cursorLine = Math.ceil((this._cursorCol + 1) / this._cols) - 1;
715+
if (this._input.includes("\r\n")) {
716+
const lines = this._input.split("\r\n");
717+
lines.pop();
718+
cursorLine += lines.reduce((sum, line) => sum + Math.ceil((line.length + 1) / this._cols), 0);
719+
}
720+
this._hideCursorWrite(
721+
`\x1b7${cursorLine > 0 ? `\x1b[${cursorLine}A` : ""}\r\x1b[${this._margin}C\x1b[0J${this._input.replace(
722+
/\r\n/g,
723+
`\r\n${this._multiLinePrompt}`
724+
)}\x1b8`
725+
);
726+
if (this._state == "prompt" && this._syntaxColoringEnabled()) {
727+
// Syntax color input
728+
this._socket.send(JSON.stringify({ type: "color", input: this._input }));
729+
}
730+
} else {
731+
this._cols = dimensions.columns;
732+
}
733+
}
600734
}
601735

602736
function reportError(msg: string, throwErrors = false) {

0 commit comments

Comments
 (0)