diff --git a/.changeset/moody-lies-play.md b/.changeset/moody-lies-play.md new file mode 100644 index 00000000..db103e94 --- /dev/null +++ b/.changeset/moody-lies-play.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Fix line wrapping and overflow computation in group multi-select and other list-like prompts. diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index b2215721..84ddd611 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -103,6 +103,7 @@ export function wrapTextWithPrefix( text: string, prefix: string, startPrefix: string = prefix, + endPrefix: string = prefix, lineFormatter?: (line: string, index: number) => string ): string { const columns = getColumns(output ?? stdout); @@ -112,9 +113,14 @@ export function wrapTextWithPrefix( }); const lines = wrapped .split('\n') - .map((line, index) => { + .map((line, index, arr) => { const lineString = lineFormatter ? lineFormatter(line, index) : line; - return `${index === 0 ? startPrefix : prefix}${lineString}`; + if (index === 0) { + return `${startPrefix}${lineString}`; + } else if (index === arr.length - 1) { + return `${endPrefix}${lineString}`; + } + return `${prefix}${lineString}`; }) .join('\n'); return lines; diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 80445453..d8df0bfb 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { GroupMultiSelectPrompt, settings } from '@clack/core'; +import { GroupMultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -41,44 +41,87 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const isItem = typeof option.group === 'string'; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); const isLast = isItem && next && next.group === true; - const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : ''; + let prefix = ''; + let prefixEnd = ''; + if (isItem) { + if (selectableGroups) { + prefix = isLast ? `${S_BAR_END} ` : `${S_BAR} `; + prefixEnd = isLast ? ` ` : `${S_BAR} `; + } else { + prefix = ' '; + } + } let spacingPrefix = ''; if (groupSpacing > 0 && !isItem) { spacingPrefix = '\n'.repeat(groupSpacing); } if (state === 'active') { - return `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${label}${ - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' - }`; + return wrapTextWithPrefix( + opts.output, + `${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} ` + ); } if (state === 'group-active') { - return `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${styleText('dim', label)}`; + return wrapTextWithPrefix( + opts.output, + label, + `${spacingPrefix}${prefix} `, + `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} `, + `${spacingPrefix}${prefixEnd} `, + (str) => styleText('dim', str) + ); } if (state === 'group-active-selected') { - return `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} ${styleText('dim', label)}`; + return wrapTextWithPrefix( + opts.output, + label, + `${spacingPrefix}${prefix} `, + `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} `, + `${spacingPrefix}${prefixEnd} `, + (str) => styleText('dim', str) + ); } if (state === 'selected') { const selectedCheckbox = isItem || selectableGroups ? styleText('green', S_CHECKBOX_SELECTED) : ''; - return `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} ${styleText('dim', label)}${ - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' - }`; + return wrapTextWithPrefix( + opts.output, + `${label}${option.hint ? ` (${option.hint})` : ''}`, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} `, + (str) => styleText('dim', str) + ); } if (state === 'cancelled') { return `${styleText(['strikethrough', 'dim'], label)}`; } if (state === 'active-selected') { - return `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} ${label}${ - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' - }`; + return wrapTextWithPrefix( + opts.output, + `${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} ` + ); } if (state === 'submitted') { return `${styleText('dim', label)}`; } const unselectedCheckbox = isItem || selectableGroups ? styleText('dim', S_CHECKBOX_INACTIVE) : ''; - return `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} ${styleText('dim', label)}`; + return wrapTextWithPrefix( + opts.output, + label, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} `, + (str) => styleText('dim', str) + ); }; const required = opts.required ?? true; diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index f9b63d3d..1eef403b 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -17,16 +17,22 @@ const trimLines = ( initialLineCount: number, startIndex: number, endIndex: number, - maxLines: number + maxLines: number, + fromEnd = false ) => { let lineCount = initialLineCount; let removals = 0; - for (let i = startIndex; i < endIndex; i++) { - const group = groups[i]; - lineCount = lineCount - group.length; - removals++; - if (lineCount <= maxLines) { - break; + if (fromEnd) { + for (let i = endIndex - 1; i >= startIndex; i--) { + lineCount -= groups[i].length; + removals++; + if (lineCount <= maxLines) break; + } + } else { + for (let i = startIndex; i < endIndex; i++) { + lineCount -= groups[i].length; + removals++; + if (lineCount <= maxLines) break; } } return { lineCount, removals }; @@ -94,30 +100,31 @@ export const limitOptions = ({ let followingRemovals = 0; let newLineCount = lineCount; const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis; - const trimLinesLocal = (startIndex: number, endIndex: number) => - trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems); + let adjustedMax = outputMaxItems; + const trimPreceding = () => + trimLines(lineGroups, newLineCount, 0, cursorGroupIndex, adjustedMax); + const trimFollowing = () => + trimLines( + lineGroups, + newLineCount, + cursorGroupIndex + 1, + lineGroups.length, + adjustedMax, + true + ); if (shouldRenderTopEllipsis) { - ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( - 0, - cursorGroupIndex - )); - if (newLineCount > outputMaxItems) { - ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( - cursorGroupIndex + 1, - lineGroups.length - )); + ({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding()); + if (newLineCount > adjustedMax) { + if (!shouldRenderBottomEllipsis) adjustedMax -= 1; + ({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing()); } } else { - ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( - cursorGroupIndex + 1, - lineGroups.length - )); - if (newLineCount > outputMaxItems) { - ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( - 0, - cursorGroupIndex - )); + if (!shouldRenderBottomEllipsis) adjustedMax -= 1; + ({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing()); + if (newLineCount > adjustedMax) { + adjustedMax -= 1; + ({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding()); } } diff --git a/packages/prompts/src/multi-line.ts b/packages/prompts/src/multi-line.ts index 4eac8d8b..4f984705 100644 --- a/packages/prompts/src/multi-line.ts +++ b/packages/prompts/src/multi-line.ts @@ -41,7 +41,7 @@ export const multiline = (opts: MultiLineOptions) => { case 'submit': { const submitPrefix = `${styleText('gray', S_BAR)} `; const lines = hasGuide - ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, (str) => + ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, undefined, (str) => styleText('dim', str) ) : value @@ -52,7 +52,7 @@ export const multiline = (opts: MultiLineOptions) => { case 'cancel': { const cancelPrefix = `${styleText('gray', S_BAR)} `; const lines = hasGuide - ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, (str) => + ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, undefined, (str) => styleText(['strikethrough', 'dim'], str) ) : value diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 4d046e4d..b213bff7 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -220,13 +220,12 @@ exports[`select (isCI = false) > handles mixed size re-renders 1`] = ` "│ ◆ Whatever │ ... -│ ○ Option 0 │ ○ Option 1 │ ○ Option 2 │ ● Option 3 └ ", - "", + "", "", "", "◇ Whatever @@ -700,13 +699,12 @@ exports[`select (isCI = true) > handles mixed size re-renders 1`] = ` "│ ◆ Whatever │ ... -│ ○ Option 0 │ ○ Option 1 │ ○ Option 2 │ ● Option 3 └ ", - "", + "", "", "", "◇ Whatever diff --git a/packages/prompts/test/limit-options.test.ts b/packages/prompts/test/limit-options.test.ts index 91fd8268..2cfc350b 100644 --- a/packages/prompts/test/limit-options.test.ts +++ b/packages/prompts/test/limit-options.test.ts @@ -142,8 +142,6 @@ describe('limitOptions', () => { 'Item 4', 'Item 5', 'Item 6', - 'Item 7', - 'Item 8', styleText('dim', '...'), ]); }); @@ -171,8 +169,6 @@ describe('limitOptions', () => { const result = limitOptions(options); expect(result).toEqual([ styleText('dim', '...'), - 'Item 2', - 'Item 3', 'Item 4', 'A long item that will take up a lot of space (line 0)', 'A long item that will take up a lot of space (line 1)', @@ -208,8 +204,6 @@ describe('limitOptions', () => { const result = limitOptions(options); expect(result).toEqual([ styleText('dim', '...'), - 'Item 4', - 'Item 5', 'Item 6', 'Item 7', 'A long item that will take up a lot of space (line 0)',