Skip to content

Commit 5c79ed8

Browse files
authored
feat: improve tui smoothness (#349)
* feat: improve tui smoothness Signed-off-by: Chapman Pendery <[email protected]> * fix: prettier Signed-off-by: Chapman Pendery <[email protected]> --------- Signed-off-by: Chapman Pendery <[email protected]>
1 parent e0c3c61 commit 5c79ed8

File tree

5 files changed

+147
-189
lines changed

5 files changed

+147
-189
lines changed

src/isterm/pty.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import os from "node:os";
77
import path from "node:path";
88
import url from "node:url";
99
import fs from "node:fs";
10-
import stripAnsi from "strip-ansi";
1110
import { Unicode11Addon } from "@xterm/addon-unicode11";
1211

1312
import pty, { IPty, IEvent } from "@homebridge/node-pty-prebuilt-multiarch";
@@ -35,6 +34,12 @@ type ISTermOptions = {
3534
login: boolean;
3635
};
3736

37+
export type ISTermPatch = {
38+
startX: number;
39+
length: number;
40+
data: string;
41+
};
42+
3843
export class ISTerm implements IPty {
3944
readonly pid: number;
4045
cols: number;
@@ -45,6 +50,8 @@ export class ISTerm implements IPty {
4550
readonly onExit: IEvent<{ exitCode: number; signal?: number }>;
4651
shellBuffer?: string;
4752
cwd: string = "";
53+
cursorHidden: boolean = false;
54+
cursorShift: number = 0;
4855

4956
readonly #pty: IPty;
5057
readonly #ptyEmitter: EventEmitter;
@@ -78,7 +85,20 @@ export class ISTerm implements IPty {
7885

7986
this.#ptyEmitter = new EventEmitter();
8087
this.#pty.onData((data) => {
88+
const cursorY = this.#term.buffer.active.cursorY;
8189
this.#term.write(data, () => {
90+
if (data.includes(ansi.cursorHide)) {
91+
this.cursorHidden = true;
92+
}
93+
if (data.includes(ansi.cursorShow)) {
94+
this.cursorHidden = false;
95+
}
96+
const newCursorY = this.#term.buffer.active.cursorY;
97+
if (newCursorY > cursorY) {
98+
this.cursorShift = newCursorY - cursorY;
99+
} else {
100+
this.cursorShift = 0;
101+
}
82102
log.debug({ msg: "parsing data", data, bytes: Uint8Array.from([...data].map((c) => c.charCodeAt(0))) });
83103
this.#commandManager.termSync();
84104
this.#ptyEmitter.emit(ISTermOnDataEvent, data);
@@ -119,12 +139,6 @@ export class ISTerm implements IPty {
119139
return cwd;
120140
}
121141

122-
private _sanitizedPrompt(prompt: string): string {
123-
// eslint-disable-next-line no-control-regex -- strip OSC control sequences
124-
const oscStrippedPrompt = prompt.replace(/\x1b\][0-9]+;.*\x07/g, "");
125-
return stripAnsi(oscStrippedPrompt);
126-
}
127-
128142
private _handleIsSequence(data: string): boolean {
129143
const argsIndex = data.indexOf(";");
130144
const sequence = argsIndex === -1 ? data : data.substring(0, argsIndex);
@@ -191,6 +205,8 @@ export class ISTerm implements IPty {
191205
remainingLines: Math.max(this.#term.rows - 2 - this.#term.buffer.active.cursorY, 0),
192206
cursorX: this.#term.buffer.active.cursorX,
193207
cursorY: this.#term.buffer.active.cursorY,
208+
hidden: this.cursorHidden,
209+
shift: this.cursorShift,
194210
};
195211
}
196212

@@ -267,14 +283,27 @@ export class ISTerm implements IPty {
267283
this.#commandManager.clearActiveCommand();
268284
}
269285

270-
getCells(height: number, direction: "below" | "above") {
286+
getPatch(height: number, patches: ISTermPatch[], direction: "below" | "above"): string {
271287
const currentCursorPosition = this.#term.buffer.active.cursorY + this.#term.buffer.active.baseY;
272-
const writeLine = (y: number) => {
288+
const writeLine = (y: number, patch?: ISTermPatch): string => {
273289
const line = this.#term.buffer.active.getLine(y);
274-
const ansiLine = [ansi.resetColor, ansi.resetLine];
290+
const hasPatch = patch != null;
291+
const ansiPrePatch = [ansi.resetColor, ansi.resetLine];
292+
const ansiPostPatch = hasPatch ? [ansi.resetColor] : [];
275293
if (line == null) return "";
294+
276295
let prevCell: ICellData | undefined;
296+
let ansiLine = ansiPrePatch;
297+
298+
const patchStartX = patch?.startX ?? 0;
299+
const patchEndX = (patch?.startX ?? 0) + (patch?.length ?? 0);
277300
for (let x = 0; x < line.length; x++) {
301+
if (hasPatch && x >= patchStartX && x < patchEndX) {
302+
prevCell = undefined;
303+
ansiLine = ansiPostPatch;
304+
continue;
305+
}
306+
278307
const cell = line.getCell(x) as unknown as ICellData | undefined;
279308
const chars = cell?.getChars() ?? "";
280309

@@ -294,24 +323,31 @@ export class ISTerm implements IPty {
294323
ansiLine.push(chars == "" ? cursorForward : chars);
295324
prevCell = cell;
296325
}
297-
return ansiLine.join("");
326+
return [ansiPrePatch.join(""), patch?.data ?? "", ansiPostPatch.join("")].join("");
298327
};
299328

300-
const lines = [];
329+
const lines: string[] = [];
301330
if (direction == "above") {
302331
const startCursorPosition = currentCursorPosition - 1;
303332
const endCursorPosition = currentCursorPosition - 1 - height;
333+
let patchIdx = patches.length - 1;
304334
for (let y = startCursorPosition; y > endCursorPosition; y--) {
305-
lines.push(writeLine(y));
335+
const patch = patches[patchIdx];
336+
lines.push(writeLine(y, patch));
337+
patchIdx--;
306338
}
307339
} else {
308340
const startCursorPosition = currentCursorPosition + 1;
309341
const endCursorPosition = currentCursorPosition + 1 + height;
342+
let patchIdx = 0;
310343
for (let y = startCursorPosition; y < endCursorPosition; y++) {
311-
lines.push(writeLine(y));
344+
const patch = patches[patchIdx];
345+
lines.push(writeLine(y, patch));
346+
patchIdx++;
312347
}
313348
}
314-
return lines.reverse().join(ansi.cursorNextLine);
349+
350+
return (direction == "above" ? lines.reverse() : lines).join(ansi.cursorNextLine);
315351
}
316352
}
317353

src/ui/suggestionManager.ts

Lines changed: 51 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33

44
import { Suggestion, SuggestionBlob } from "../runtime/model.js";
55
import { getSuggestions } from "../runtime/runtime.js";
6-
import { ISTerm } from "../isterm/pty.js";
6+
import { ISTerm, ISTermPatch } from "../isterm/pty.js";
77
import { renderBox, truncateText, truncateMultilineText } from "./utils.js";
8-
import ansi from "ansi-escapes";
98
import chalk from "chalk";
109
import { Shell } from "../utils/shell.js";
1110
import log from "../utils/log.js";
@@ -17,11 +16,8 @@ const descriptionWidth = 30;
1716
const descriptionHeight = 5;
1817
const borderWidth = 2;
1918
const activeSuggestionBackgroundColor = "#7D56F4";
20-
export const MAX_LINES = borderWidth + Math.max(maxSuggestions, descriptionHeight);
21-
type SuggestionsSequence = {
22-
data: string;
23-
rows: number;
24-
};
19+
export const MAX_LINES = borderWidth + Math.max(maxSuggestions, descriptionHeight) + 1; // accounts when there is a unhandled newline at the end of the command
20+
export const MIN_WIDTH = borderWidth + descriptionWidth;
2521

2622
export type KeyPressEvent = [string | null | undefined, KeyPress];
2723

@@ -67,100 +63,89 @@ export class SuggestionManager {
6763
this.#activeSuggestionIdx = 0;
6864
}
6965

70-
private _renderArgumentDescription(description: string | undefined, x: number) {
71-
if (!description) return "";
72-
return renderBox([truncateText(description, descriptionWidth - borderWidth)], descriptionWidth, x);
66+
private _renderArgumentDescription(description: string | undefined) {
67+
if (!description) return [];
68+
return renderBox([truncateText(description, descriptionWidth - borderWidth)], descriptionWidth);
7369
}
7470

75-
private _renderDescription(description: string | undefined, x: number) {
71+
private _renderDescription(description: string | undefined) {
7672
if (!description) return "";
77-
return renderBox(truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight), descriptionWidth, x);
73+
return renderBox(truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight), descriptionWidth);
7874
}
7975

80-
private _descriptionRows(description: string | undefined) {
81-
if (!description) return 0;
82-
return truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight).length;
83-
}
84-
85-
private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number, x: number) {
76+
private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number) {
8677
return renderBox(
8778
suggestions.map((suggestion, idx) => {
8879
const suggestionText = `${suggestion.icon} ${suggestion.name}`;
8980
const truncatedSuggestion = truncateText(suggestionText, suggestionWidth - 2);
9081
return idx == activeSuggestionIdx ? chalk.bgHex(activeSuggestionBackgroundColor)(truncatedSuggestion) : truncatedSuggestion;
9182
}),
9283
suggestionWidth,
93-
x,
9484
);
9585
}
9686

97-
validate(suggestion: SuggestionsSequence): SuggestionsSequence {
98-
const commandText = this.#term.getCommandState().commandText;
99-
return !commandText ? { data: "", rows: 0 } : suggestion;
87+
private _calculatePadding(description: string): { padding: number; swapDescription: boolean } {
88+
const wrappedPadding = this.#term.getCursorState().cursorX % this.#term.cols;
89+
const maxPadding = description.length !== 0 ? this.#term.cols - suggestionWidth - descriptionWidth : this.#term.cols - suggestionWidth;
90+
const swapDescription = wrappedPadding > maxPadding && description.length !== 0;
91+
const swappedPadding = swapDescription ? Math.max(wrappedPadding - descriptionWidth, 0) : wrappedPadding;
92+
const padding = Math.min(Math.min(wrappedPadding, swappedPadding), maxPadding);
93+
return { padding, swapDescription };
10094
}
10195

102-
async render(remainingLines: number): Promise<SuggestionsSequence> {
103-
await this._loadSuggestions();
96+
private _calculateRowPadding(padding: number, swapDescription: boolean, suggestionContent?: string, descriptionContent?: string): number {
97+
if (swapDescription) {
98+
return descriptionContent == null ? padding + descriptionWidth : padding;
99+
}
100+
return suggestionContent == null ? padding + suggestionWidth : padding;
101+
}
102+
103+
async exec(): Promise<void> {
104+
return await this._loadSuggestions();
105+
}
106+
107+
render(direction: "above" | "below"): ISTermPatch[] {
104108
if (!this.#suggestBlob) {
105-
return { data: "", rows: 0 };
109+
return [];
106110
}
107111
const { suggestions, argumentDescription } = this.#suggestBlob;
108112

109113
const page = Math.min(Math.floor(this.#activeSuggestionIdx / maxSuggestions) + 1, Math.floor(suggestions.length / maxSuggestions) + 1);
110114
const pagedSuggestions = suggestions.filter((_, idx) => idx < page * maxSuggestions && idx >= (page - 1) * maxSuggestions);
111115
const activePagedSuggestionIndex = this.#activeSuggestionIdx % maxSuggestions;
112116
const activeDescription = pagedSuggestions.at(activePagedSuggestionIndex)?.description || argumentDescription || "";
113-
114-
const wrappedPadding = this.#term.getCursorState().cursorX % this.#term.cols;
115-
const maxPadding = activeDescription.length !== 0 ? this.#term.cols - suggestionWidth - descriptionWidth : this.#term.cols - suggestionWidth;
116-
const swapDescription = wrappedPadding > maxPadding && activeDescription.length !== 0;
117-
const swappedPadding = swapDescription ? Math.max(wrappedPadding - descriptionWidth, 0) : wrappedPadding;
118-
const clampedLeftPadding = Math.min(Math.min(wrappedPadding, swappedPadding), maxPadding);
117+
const { swapDescription, padding } = this._calculatePadding(activeDescription);
119118

120119
if (suggestions.length <= this.#activeSuggestionIdx) {
121120
this.#activeSuggestionIdx = Math.max(suggestions.length - 1, 0);
122121
}
123122

124123
if (pagedSuggestions.length == 0) {
125124
if (argumentDescription != null) {
126-
return {
127-
data:
128-
ansi.cursorHide +
129-
ansi.cursorUp(2) +
130-
ansi.cursorForward(clampedLeftPadding) +
131-
this._renderArgumentDescription(argumentDescription, clampedLeftPadding),
132-
rows: 3,
133-
};
125+
return this._renderArgumentDescription(argumentDescription).map((row) => ({ startX: padding, length: descriptionWidth, data: row }));
134126
}
135-
return { data: "", rows: 0 };
127+
return [];
136128
}
137-
138-
const suggestionRowsUsed = pagedSuggestions.length + borderWidth;
139-
let descriptionRowsUsed = this._descriptionRows(activeDescription) + borderWidth;
140-
let rows = Math.max(descriptionRowsUsed, suggestionRowsUsed);
141-
if (rows <= remainingLines) {
142-
descriptionRowsUsed = suggestionRowsUsed;
143-
rows = suggestionRowsUsed;
129+
const descriptionUI = this._renderDescription(activeDescription);
130+
const suggestionUI = this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex);
131+
const ui = [];
132+
const maxRows = Math.max(descriptionUI.length, suggestionUI.length);
133+
for (let i = 0; i < maxRows; i++) {
134+
const [suggestionUIRow, descriptionUIRow] =
135+
direction == "above"
136+
? [suggestionUI[i - maxRows + suggestionUI.length], descriptionUI[i - maxRows + descriptionUI.length]]
137+
: [suggestionUI[i], descriptionUI[i]];
138+
139+
const data = swapDescription ? (descriptionUIRow ?? "") + (suggestionUIRow ?? "") : (suggestionUIRow ?? "") + (descriptionUIRow ?? "");
140+
const rowPadding = this._calculateRowPadding(padding, swapDescription, suggestionUIRow, descriptionUIRow);
141+
142+
ui.push({
143+
startX: rowPadding,
144+
length: (suggestionUIRow == null ? 0 : suggestionWidth) + (descriptionUIRow == null ? 0 : descriptionWidth),
145+
data: data,
146+
});
144147
}
145-
146-
const descriptionUI =
147-
ansi.cursorUp(descriptionRowsUsed - 1) +
148-
(swapDescription
149-
? this._renderDescription(activeDescription, clampedLeftPadding)
150-
: this._renderDescription(activeDescription, clampedLeftPadding + suggestionWidth)) +
151-
ansi.cursorDown(descriptionRowsUsed - 1);
152-
const suggestionUI =
153-
ansi.cursorUp(suggestionRowsUsed - 1) +
154-
(swapDescription
155-
? this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding + descriptionWidth)
156-
: this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding)) +
157-
ansi.cursorDown(suggestionRowsUsed - 1);
158-
159-
const ui = swapDescription ? descriptionUI + suggestionUI : suggestionUI + descriptionUI;
160-
return {
161-
data: ansi.cursorHide + ansi.cursorForward(clampedLeftPadding) + ui + ansi.cursorShow,
162-
rows,
163-
};
148+
return ui;
164149
}
165150

166151
update(keyPress: KeyPress): boolean {

0 commit comments

Comments
 (0)