Skip to content

Commit 9843012

Browse files
committed
fix: trim lines from correct end
This changes the limit-options logic such that we trim lines from the end of the list we're dealing with. For example, if we know we are already going to render a top ellipsis, we should trim the preceding lines (top of the list) until we can fit on screen. Similarly, if we are rendering a bottom ellipsis, do the same for following lines. If there's currently no ellipsis, we trim the following lines, then the preceding lines. This particular part can be improved one day in a follow up. On top of this, correct wrapping has been added for the group multi-select prompt's options.
1 parent 284677e commit 9843012

7 files changed

Lines changed: 109 additions & 55 deletions

File tree

.changeset/moody-lies-play.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": patch
3+
"@clack/core": patch
4+
---
5+
6+
Fix line wrapping and overflow computation in group multi-select and other list-like prompts.

packages/core/src/utils/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export function wrapTextWithPrefix(
103103
text: string,
104104
prefix: string,
105105
startPrefix: string = prefix,
106+
endPrefix: string = prefix,
106107
lineFormatter?: (line: string, index: number) => string
107108
): string {
108109
const columns = getColumns(output ?? stdout);
@@ -112,9 +113,14 @@ export function wrapTextWithPrefix(
112113
});
113114
const lines = wrapped
114115
.split('\n')
115-
.map((line, index) => {
116+
.map((line, index, arr) => {
116117
const lineString = lineFormatter ? lineFormatter(line, index) : line;
117-
return `${index === 0 ? startPrefix : prefix}${lineString}`;
118+
if (index === 0) {
119+
return `${startPrefix}${lineString}`;
120+
} else if (index === arr.length - 1) {
121+
return `${endPrefix}${lineString}`;
122+
}
123+
return `${prefix}${lineString}`;
118124
})
119125
.join('\n');
120126
return lines;

packages/prompts/src/group-multi-select.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { styleText } from 'node:util';
2-
import { GroupMultiSelectPrompt, settings } from '@clack/core';
2+
import { GroupMultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core';
33
import {
44
type CommonOptions,
55
S_BAR,
@@ -41,44 +41,87 @@ export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) =>
4141
const isItem = typeof option.group === 'string';
4242
const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true });
4343
const isLast = isItem && next && next.group === true;
44-
const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : '';
44+
let prefix = '';
45+
let prefixEnd = '';
46+
if (isItem) {
47+
if (selectableGroups) {
48+
prefix = isLast ? `${S_BAR_END} ` : `${S_BAR} `;
49+
prefixEnd = isLast ? ` ` : `${S_BAR} `;
50+
} else {
51+
prefix = ' ';
52+
}
53+
}
4554
let spacingPrefix = '';
4655
if (groupSpacing > 0 && !isItem) {
4756
spacingPrefix = '\n'.repeat(groupSpacing);
4857
}
4958

5059
if (state === 'active') {
51-
return `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${label}${
52-
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
53-
}`;
60+
return wrapTextWithPrefix(
61+
opts.output,
62+
`${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`,
63+
`${spacingPrefix}${styleText('dim', prefix)} `,
64+
`${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} `,
65+
`${spacingPrefix}${styleText('dim', prefixEnd)} `
66+
);
5467
}
5568
if (state === 'group-active') {
56-
return `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${styleText('dim', label)}`;
69+
return wrapTextWithPrefix(
70+
opts.output,
71+
label,
72+
`${spacingPrefix}${prefix} `,
73+
`${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} `,
74+
`${spacingPrefix}${prefixEnd} `,
75+
(str) => styleText('dim', str)
76+
);
5777
}
5878
if (state === 'group-active-selected') {
59-
return `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} ${styleText('dim', label)}`;
79+
return wrapTextWithPrefix(
80+
opts.output,
81+
label,
82+
`${spacingPrefix}${prefix} `,
83+
`${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} `,
84+
`${spacingPrefix}${prefixEnd} `,
85+
(str) => styleText('dim', str)
86+
);
6087
}
6188
if (state === 'selected') {
6289
const selectedCheckbox =
6390
isItem || selectableGroups ? styleText('green', S_CHECKBOX_SELECTED) : '';
64-
return `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} ${styleText('dim', label)}${
65-
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
66-
}`;
91+
return wrapTextWithPrefix(
92+
opts.output,
93+
`${label}${option.hint ? ` (${option.hint})` : ''}`,
94+
`${spacingPrefix}${styleText('dim', prefix)} `,
95+
`${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} `,
96+
`${spacingPrefix}${styleText('dim', prefixEnd)} `,
97+
(str) => styleText('dim', str)
98+
);
6799
}
68100
if (state === 'cancelled') {
69101
return `${styleText(['strikethrough', 'dim'], label)}`;
70102
}
71103
if (state === 'active-selected') {
72-
return `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} ${label}${
73-
option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''
74-
}`;
104+
return wrapTextWithPrefix(
105+
opts.output,
106+
`${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`,
107+
`${spacingPrefix}${styleText('dim', prefix)} `,
108+
`${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} `,
109+
`${spacingPrefix}${styleText('dim', prefixEnd)} `
110+
);
75111
}
76112
if (state === 'submitted') {
77113
return `${styleText('dim', label)}`;
78114
}
79115
const unselectedCheckbox =
80116
isItem || selectableGroups ? styleText('dim', S_CHECKBOX_INACTIVE) : '';
81-
return `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} ${styleText('dim', label)}`;
117+
return wrapTextWithPrefix(
118+
opts.output,
119+
label,
120+
`${spacingPrefix}${styleText('dim', prefix)} `,
121+
`${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} `,
122+
`${spacingPrefix}${styleText('dim', prefixEnd)} `,
123+
(str) => styleText('dim', str)
124+
);
82125
};
83126
const required = opts.required ?? true;
84127

packages/prompts/src/limit-options.ts

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,22 @@ const trimLines = (
1717
initialLineCount: number,
1818
startIndex: number,
1919
endIndex: number,
20-
maxLines: number
20+
maxLines: number,
21+
fromEnd = false
2122
) => {
2223
let lineCount = initialLineCount;
2324
let removals = 0;
24-
for (let i = startIndex; i < endIndex; i++) {
25-
const group = groups[i];
26-
lineCount = lineCount - group.length;
27-
removals++;
28-
if (lineCount <= maxLines) {
29-
break;
25+
if (fromEnd) {
26+
for (let i = endIndex - 1; i >= startIndex; i--) {
27+
lineCount -= groups[i].length;
28+
removals++;
29+
if (lineCount <= maxLines) break;
30+
}
31+
} else {
32+
for (let i = startIndex; i < endIndex; i++) {
33+
lineCount -= groups[i].length;
34+
removals++;
35+
if (lineCount <= maxLines) break;
3036
}
3137
}
3238
return { lineCount, removals };
@@ -94,30 +100,31 @@ export const limitOptions = <TOption>({
94100
let followingRemovals = 0;
95101
let newLineCount = lineCount;
96102
const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis;
97-
const trimLinesLocal = (startIndex: number, endIndex: number) =>
98-
trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems);
103+
let adjustedMax = outputMaxItems;
104+
const trimPreceding = () =>
105+
trimLines(lineGroups, newLineCount, 0, cursorGroupIndex, adjustedMax);
106+
const trimFollowing = () =>
107+
trimLines(
108+
lineGroups,
109+
newLineCount,
110+
cursorGroupIndex + 1,
111+
lineGroups.length,
112+
adjustedMax,
113+
true
114+
);
99115

100116
if (shouldRenderTopEllipsis) {
101-
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
102-
0,
103-
cursorGroupIndex
104-
));
105-
if (newLineCount > outputMaxItems) {
106-
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
107-
cursorGroupIndex + 1,
108-
lineGroups.length
109-
));
117+
({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding());
118+
if (newLineCount > adjustedMax) {
119+
if (!shouldRenderBottomEllipsis) adjustedMax -= 1;
120+
({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing());
110121
}
111122
} else {
112-
({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(
113-
cursorGroupIndex + 1,
114-
lineGroups.length
115-
));
116-
if (newLineCount > outputMaxItems) {
117-
({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(
118-
0,
119-
cursorGroupIndex
120-
));
123+
if (!shouldRenderBottomEllipsis) adjustedMax -= 1;
124+
({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing());
125+
if (newLineCount > adjustedMax) {
126+
adjustedMax -= 1;
127+
({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding());
121128
}
122129
}
123130

packages/prompts/src/multi-line.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const multiline = (opts: MultiLineOptions) => {
4141
case 'submit': {
4242
const submitPrefix = `${styleText('gray', S_BAR)} `;
4343
const lines = hasGuide
44-
? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, (str) =>
44+
? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, undefined, (str) =>
4545
styleText('dim', str)
4646
)
4747
: value
@@ -52,7 +52,7 @@ export const multiline = (opts: MultiLineOptions) => {
5252
case 'cancel': {
5353
const cancelPrefix = `${styleText('gray', S_BAR)} `;
5454
const lines = hasGuide
55-
? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, (str) =>
55+
? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, undefined, (str) =>
5656
styleText(['strikethrough', 'dim'], str)
5757
)
5858
: value

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,12 @@ exports[`select (isCI = false) > handles mixed size re-renders 1`] = `
220220
"│
221221
◆ Whatever
222222
│ ...
223-
│ ○ Option 0
224223
│ ○ Option 1
225224
│ ○ Option 2
226225
│ ● Option 3
227226
└
228227
",
229-
"<cursor.backward count=999><cursor.up count=8>",
228+
"<cursor.backward count=999><cursor.up count=7>",
230229
"<cursor.down count=1>",
231230
"<erase.down>",
232231
"◇ Whatever
@@ -700,13 +699,12 @@ exports[`select (isCI = true) > handles mixed size re-renders 1`] = `
700699
"│
701700
◆ Whatever
702701
│ ...
703-
│ ○ Option 0
704702
│ ○ Option 1
705703
│ ○ Option 2
706704
│ ● Option 3
707705
└
708706
",
709-
"<cursor.backward count=999><cursor.up count=8>",
707+
"<cursor.backward count=999><cursor.up count=7>",
710708
"<cursor.down count=1>",
711709
"<erase.down>",
712710
"◇ Whatever

packages/prompts/test/limit-options.test.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,6 @@ describe('limitOptions', () => {
142142
'Item 4',
143143
'Item 5',
144144
'Item 6',
145-
'Item 7',
146-
'Item 8',
147145
styleText('dim', '...'),
148146
]);
149147
});
@@ -171,8 +169,6 @@ describe('limitOptions', () => {
171169
const result = limitOptions(options);
172170
expect(result).toEqual([
173171
styleText('dim', '...'),
174-
'Item 2',
175-
'Item 3',
176172
'Item 4',
177173
'A long item that will take up a lot of space (line 0)',
178174
'A long item that will take up a lot of space (line 1)',
@@ -208,8 +204,6 @@ describe('limitOptions', () => {
208204
const result = limitOptions(options);
209205
expect(result).toEqual([
210206
styleText('dim', '...'),
211-
'Item 4',
212-
'Item 5',
213207
'Item 6',
214208
'Item 7',
215209
'A long item that will take up a lot of space (line 0)',

0 commit comments

Comments
 (0)