Skip to content

Commit 97bb161

Browse files
committed
fix: support off-screen lines when re-rendering
This fixes a deep, difficult bug 👀 Let's try explain by example... If you have an `autocomplete` which has 2 options: - First option is multiple lines (let's say 6 lines) - Second option is one line In a terminal which is 10 rows tall, we are likely to exceed the terminal height since `6 (row 1) + 1 (row 2) + 4 (message and UI) = 11`. When this happens, the first render will be fine and the top row will be off screen. Now when we navigate (e.g. press `<down>`), the current rendering logic will assume that the _entire_ frame is on screen. It'll diff the previous frame and this frame, then update the changed rows. Because it assumes the entire frame is on screen, it'll try move down to row `N` (where `N` is the changed row). Here lies the problem: row `N` will be off by `1` (because `1` row is off screen right now). That means row `4` of the raw text is visually row `3` in your terminal. This fix basically accounts for that offset, which fixes a whole bunch of weird behaviours in smaller terminals, or just long prompts. One notable thing we also account for in this fix: if the new frame fits on screen, but the last frame didn't, we erase the entire frame and render it all to regain screen space.
1 parent b0fa7d8 commit 97bb161

File tree

4 files changed

+60
-27
lines changed

4 files changed

+60
-27
lines changed

.changeset/little-ghosts-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/core": patch
3+
---
4+
5+
Support short terminal windows when re-rendering by accounting for off-screen lines

packages/core/src/prompts/prompt.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import { wrapAnsi } from 'fast-wrap-ansi';
55
import { cursor, erase } from 'sisteransi';
66
import type { ClackEvents, ClackState } from '../types.js';
77
import type { Action } from '../utils/index.js';
8-
import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils/index.js';
8+
import {
9+
CANCEL_SYMBOL,
10+
diffLines,
11+
getRows,
12+
isActionKey,
13+
setRawMode,
14+
settings,
15+
} from '../utils/index.js';
916

1017
export interface PromptOptions<TValue, Self extends Prompt<TValue>> {
1118
render(this: Omit<Self, 'prompt'>): string | undefined;
@@ -274,28 +281,44 @@ export default class Prompt<TValue> {
274281
this.output.write(cursor.hide);
275282
} else {
276283
const diff = diffLines(this._prevFrame, frame);
284+
const rows = getRows(this.output);
277285
this.restoreCursor();
278-
// If a single line has changed, only update that line
279-
if (diff && diff?.length === 1) {
280-
const diffLine = diff[0];
281-
this.output.write(cursor.move(0, diffLine));
282-
this.output.write(erase.lines(1));
283-
const lines = frame.split('\n');
284-
this.output.write(lines[diffLine]);
285-
this._prevFrame = frame;
286-
this.output.write(cursor.move(0, lines.length - diffLine - 1));
287-
return;
288-
// If many lines have changed, rerender everything past the first line
289-
}
290-
if (diff && diff?.length > 1) {
291-
const diffLine = diff[0];
292-
this.output.write(cursor.move(0, diffLine));
293-
this.output.write(erase.down());
294-
const lines = frame.split('\n');
295-
const newLines = lines.slice(diffLine);
296-
this.output.write(newLines.join('\n'));
297-
this._prevFrame = frame;
298-
return;
286+
if (diff) {
287+
const diffOffsetAfter = Math.max(0, diff.numLinesAfter - rows);
288+
const diffOffsetBefore = Math.max(0, diff.numLinesBefore - rows);
289+
let diffLine = diff.lines.find((line) => line >= diffOffsetAfter);
290+
291+
if (diffLine === undefined) {
292+
this._prevFrame = frame;
293+
return;
294+
}
295+
296+
// If a single line has changed, only update that line
297+
if (diff.lines.length === 1) {
298+
this.output.write(cursor.move(0, diffLine - diffOffsetBefore));
299+
this.output.write(erase.lines(1));
300+
const lines = frame.split('\n');
301+
this.output.write(lines[diffLine]);
302+
this._prevFrame = frame;
303+
this.output.write(cursor.move(0, lines.length - diffLine - 1));
304+
return;
305+
// If many lines have changed, rerender everything past the first line
306+
} else if (diff.lines.length > 1) {
307+
if (diffOffsetAfter < diffOffsetBefore) {
308+
diffLine = diffOffsetAfter;
309+
} else {
310+
const adjustedDiffLine = diffLine - diffOffsetBefore;
311+
if (adjustedDiffLine > 0) {
312+
this.output.write(cursor.move(0, adjustedDiffLine));
313+
}
314+
}
315+
this.output.write(erase.down());
316+
const lines = frame.split('\n');
317+
const newLines = lines.slice(diffLine);
318+
this.output.write(newLines.join('\n'));
319+
this._prevFrame = frame;
320+
return;
321+
}
299322
}
300323

301324
this.output.write(erase.down());

packages/core/src/utils/string.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ export function diffLines(a: string, b: string) {
33

44
const aLines = a.split('\n');
55
const bLines = b.split('\n');
6+
const numLines = Math.max(aLines.length, bLines.length);
67
const diff: number[] = [];
78

8-
for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
9+
for (let i = 0; i < numLines; i++) {
910
if (aLines[i] !== bLines[i]) diff.push(i);
1011
}
1112

12-
return diff;
13+
return {
14+
lines: diff,
15+
numLinesBefore: aLines.length,
16+
numLinesAfter: bLines.length,
17+
numLines,
18+
};
1319
}

packages/prompts/test/__snapshots__/autocomplete.test.ts.snap

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = `
6161
│ ↑/↓ to select • Enter: confirm • Type: to search
6262
└",
6363
"<cursor.backward count=999><cursor.up count=10>",
64-
"<cursor.down count=1>",
6564
"<erase.down>",
6665
"◇ Select an option
6766
│ Line 0
@@ -136,9 +135,9 @@ exports[`autocomplete > renders top ellipsis when scrolled down and its do not f
136135
│ ↑/↓ to select • Enter: confirm • Type: to search
137136
└",
138137
"<cursor.backward count=999><cursor.up count=7>",
139-
"<cursor.down count=1>",
140138
"<erase.down>",
141-
"◇ Select an option
139+
"│
140+
◇ Select an option
142141
│ Option 2",
143142
"
144143
",

0 commit comments

Comments
 (0)