Skip to content

Commit 54be8d7

Browse files
authored
fix: trim lines from correct end (#532)
1 parent 266eaf9 commit 54be8d7

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)