Skip to content

Commit 4ba2d78

Browse files
authored
fix: support off-screen lines when re-rendering (#414)
1 parent 4d1d83b commit 4ba2d78

File tree

6 files changed

+163
-27
lines changed

6 files changed

+163
-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
",

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ exports[`select (isCI = false) > down arrow selects next option 1`] = `
6363
]
6464
`;
6565
66+
exports[`select (isCI = false) > handles mixed size re-renders 1`] = `
67+
[
68+
"<cursor.hide>",
69+
"│
70+
◆ Whatever
71+
│ ● Long Option
72+
│ Long Option
73+
│ Long Option
74+
│ Long Option
75+
│ Long Option
76+
│ Long Option
77+
│ Long Option
78+
│ Long Option
79+
│ ...
80+
└
81+
",
82+
"<cursor.backward count=999><cursor.up count=12>",
83+
"<erase.down>",
84+
"│
85+
◆ Whatever
86+
│ ...
87+
│ ○ Option 0
88+
│ ○ Option 1
89+
│ ○ Option 2
90+
│ ● Option 3
91+
└
92+
",
93+
"<cursor.backward count=999><cursor.up count=8>",
94+
"<cursor.down count=1>",
95+
"<erase.down>",
96+
"◇ Whatever
97+
│ Option 3",
98+
"
99+
",
100+
"<cursor.show>",
101+
]
102+
`;
103+
66104
exports[`select (isCI = false) > renders disabled options 1`] = `
67105
[
68106
"<cursor.hide>",
@@ -351,6 +389,44 @@ exports[`select (isCI = true) > down arrow selects next option 1`] = `
351389
]
352390
`;
353391
392+
exports[`select (isCI = true) > handles mixed size re-renders 1`] = `
393+
[
394+
"<cursor.hide>",
395+
"│
396+
◆ Whatever
397+
│ ● Long Option
398+
│ Long Option
399+
│ Long Option
400+
│ Long Option
401+
│ Long Option
402+
│ Long Option
403+
│ Long Option
404+
│ Long Option
405+
│ ...
406+
└
407+
",
408+
"<cursor.backward count=999><cursor.up count=12>",
409+
"<erase.down>",
410+
"│
411+
◆ Whatever
412+
│ ...
413+
│ ○ Option 0
414+
│ ○ Option 1
415+
│ ○ Option 2
416+
│ ● Option 3
417+
└
418+
",
419+
"<cursor.backward count=999><cursor.up count=8>",
420+
"<cursor.down count=1>",
421+
"<erase.down>",
422+
"◇ Whatever
423+
│ Option 3",
424+
"
425+
",
426+
"<cursor.show>",
427+
]
428+
`;
429+
354430
exports[`select (isCI = true) > renders disabled options 1`] = `
355431
[
356432
"<cursor.hide>",

packages/prompts/test/select.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,4 +248,31 @@ describe.each(['true', 'false'])('select (isCI = %s)', (isCI) => {
248248

249249
expect(output.buffer).toMatchSnapshot();
250250
});
251+
252+
test('handles mixed size re-renders', async () => {
253+
output.rows = 10;
254+
255+
const result = prompts.select({
256+
message: 'Whatever',
257+
options: [
258+
{
259+
value: 'longopt',
260+
label: Array.from({ length: 8 }, () => 'Long Option').join('\n'),
261+
},
262+
...Array.from({ length: 4 }, (_, i) => ({
263+
value: `opt${i}`,
264+
label: `Option ${i}`,
265+
})),
266+
],
267+
input,
268+
output,
269+
});
270+
271+
input.emit('keypress', '', { name: 'up' });
272+
input.emit('keypress', '', { name: 'return' });
273+
274+
await result;
275+
276+
expect(output.buffer).toMatchSnapshot();
277+
});
251278
});

0 commit comments

Comments
 (0)