Skip to content

Commit be0f5ca

Browse files
committed
feat(@clack/core,@clack/prompts): multiline support
1 parent 5529c89 commit be0f5ca

File tree

4 files changed

+576
-164
lines changed

4 files changed

+576
-164
lines changed

packages/core/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ export { default as Prompt } from './prompts/prompt';
99
export { default as SelectPrompt } from './prompts/select';
1010
export { default as SelectKeyPrompt } from './prompts/select-key';
1111
export { default as TextPrompt } from './prompts/text';
12-
export { block, isCancel } from './utils';
12+
export { block, isCancel, strLength } from './utils';
1313
export { updateSettings } from './utils/settings';

packages/core/src/prompts/prompt.ts

+181-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Readable, Writable } from 'node:stream';
44
import { WriteStream } from 'node:tty';
55
import { cursor, erase } from 'sisteransi';
66
import wrap from 'wrap-ansi';
7+
import { strLength } from '../utils';
78

89
import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils';
910

@@ -21,6 +22,88 @@ export interface PromptOptions<Self extends Prompt> {
2122
signal?: AbortSignal;
2223
}
2324

25+
export type LineOption = 'firstLine' | 'newLine' | 'lastLine';
26+
27+
export interface FormatLineOptions {
28+
/**
29+
* Define the start of line
30+
* @example
31+
* format('foo', {
32+
* line: {
33+
* start: '-'
34+
* }
35+
* })
36+
* //=> '- foo'
37+
*/
38+
start: string;
39+
/**
40+
* Define the end of line
41+
* @example
42+
* format('foo', {
43+
* line: {
44+
* end: '-'
45+
* }
46+
* })
47+
* //=> 'foo -'
48+
*/
49+
end: string;
50+
/**
51+
* Define the sides of line
52+
* @example
53+
* format('foo', {
54+
* line: {
55+
* sides: '-'
56+
* }
57+
* })
58+
* //=> '- foo -'
59+
*/
60+
sides: string;
61+
/**
62+
* Define the style of line
63+
* @example
64+
* format('foo', {
65+
* line: {
66+
* style: (line) => `(${line})`
67+
* }
68+
* })
69+
* //=> '(foo)'
70+
*/
71+
style: (line: string) => string;
72+
}
73+
74+
export interface FormatOptions extends Record<LineOption, Partial<FormatLineOptions>> {
75+
/**
76+
* Shorthand to define values for each line
77+
* @example
78+
* format('foo', {
79+
* default: {
80+
* start: '-'
81+
* }
82+
* // equals
83+
* firstLine{
84+
* start: '-'
85+
* },
86+
* newLine{
87+
* start: '-'
88+
* },
89+
* lastLine{
90+
* start: '-'
91+
* },
92+
* })
93+
*/
94+
default: Partial<FormatLineOptions>;
95+
/**
96+
* Define the max width of each line
97+
* @example
98+
* format('foo bar baz', {
99+
* maxWidth: 7
100+
* })
101+
* //=> 'foo bar\nbaz'
102+
*/
103+
maxWidth: number;
104+
minWidth: number;
105+
}
106+
24107
export default class Prompt {
25108
protected input: Readable;
26109
protected output: Writable;
@@ -246,8 +329,105 @@ export default class Prompt {
246329
this.output.write(cursor.move(-999, lines * -1));
247330
}
248331

332+
public format(text: string, options?: Partial<FormatOptions>): string {
333+
const getLineOption = <TLine extends LineOption, TKey extends keyof FormatLineOptions>(
334+
line: TLine,
335+
key: TKey
336+
): NonNullable<FormatOptions[TLine][TKey]> => {
337+
return (
338+
key === 'style'
339+
? (options?.[line]?.[key] ?? options?.default?.[key] ?? ((line) => line))
340+
: (options?.[line]?.[key] ?? options?.[line]?.sides ?? options?.default?.[key] ?? '')
341+
) as NonNullable<FormatOptions[TLine][TKey]>;
342+
};
343+
const getLineOptions = (line: LineOption): Omit<FormatLineOptions, 'sides'> => {
344+
return {
345+
start: getLineOption(line, 'start'),
346+
end: getLineOption(line, 'end'),
347+
style: getLineOption(line, 'style'),
348+
};
349+
};
350+
351+
const firstLine = getLineOptions('firstLine');
352+
const newLine = getLineOptions('newLine');
353+
const lastLine = getLineOptions('lastLine');
354+
355+
const emptySlots =
356+
Math.max(
357+
strLength(firstLine.start + firstLine.end),
358+
strLength(newLine.start + newLine.end),
359+
strLength(lastLine.start + lastLine.end)
360+
) + 2;
361+
const terminalWidth = process.stdout.columns || 80;
362+
const maxWidth = options?.maxWidth ?? terminalWidth;
363+
const minWidth = options?.minWidth ?? 1;
364+
365+
const formattedLines: string[] = [];
366+
const paragraphs = text.split(/\n/g);
367+
368+
for (const paragraph of paragraphs) {
369+
const words = paragraph.split(/\s/g);
370+
let currentLine = '';
371+
372+
for (const word of words) {
373+
if (strLength(currentLine + word) + emptySlots + 1 <= maxWidth) {
374+
currentLine += ` ${word}`;
375+
} else if (strLength(word) + emptySlots >= maxWidth) {
376+
const splitIndex = maxWidth - strLength(currentLine) - emptySlots - 1;
377+
formattedLines.push(`${currentLine} ${word.slice(0, splitIndex)}`);
378+
379+
const chunkLength = maxWidth - emptySlots;
380+
let chunk = word.slice(splitIndex);
381+
while (strLength(chunk) > chunkLength) {
382+
formattedLines.push(chunk.slice(0, chunkLength));
383+
chunk = chunk.slice(chunkLength);
384+
}
385+
currentLine = chunk;
386+
} else {
387+
formattedLines.push(currentLine);
388+
currentLine = word;
389+
}
390+
}
391+
392+
formattedLines.push(currentLine);
393+
}
394+
395+
return formattedLines
396+
.map((line, i, ar) => {
397+
const opt = <TPosition extends Exclude<keyof FormatLineOptions, 'sides'>>(
398+
position: TPosition
399+
): FormatLineOptions[TPosition] => {
400+
return (
401+
i === 0 && ar.length === 1
402+
? (options?.firstLine?.[position] ??
403+
options?.lastLine?.[position] ??
404+
firstLine[position])
405+
: i === 0
406+
? firstLine[position]
407+
: i + 1 === ar.length
408+
? lastLine[position]
409+
: newLine[position]
410+
) as FormatLineOptions[TPosition];
411+
};
412+
const startLine = opt('start');
413+
const endLine = opt('end');
414+
const styleLine = opt('style');
415+
// only format the line without the leading space.
416+
const leadingSpaceRegex = /^\s/;
417+
const styledLine = leadingSpaceRegex.test(line)
418+
? ` ${styleLine(line.slice(1))}`
419+
: styleLine(line);
420+
const fullLine =
421+
styledLine + ' '.repeat(Math.max(minWidth - strLength(styledLine) - emptySlots, 0));
422+
return [startLine, fullLine, endLine].join(' ');
423+
})
424+
.join('\n');
425+
}
426+
249427
private render() {
250-
const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true });
428+
const frame = wrap(this._render(this) ?? '', process.stdout.columns, {
429+
hard: true,
430+
});
251431
if (frame === this._prevFrame) return;
252432

253433
if (this.state === 'initial') {

packages/core/src/utils/index.ts

+52
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,55 @@ export function block({
6969
rl.close();
7070
};
7171
}
72+
73+
function ansiRegex(): RegExp {
74+
const pattern = [
75+
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
76+
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))',
77+
].join('|');
78+
79+
return new RegExp(pattern, 'g');
80+
}
81+
82+
function stripAnsi(str: string): string {
83+
return str.replace(ansiRegex(), '');
84+
}
85+
86+
function isControlCharacter(code: number): boolean {
87+
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
88+
}
89+
90+
function isCombiningCharacter(code: number): boolean {
91+
return code >= 0x300 && code <= 0x36f;
92+
}
93+
94+
function isSurrogatePair(code: number): boolean {
95+
return code >= 0xd800 && code <= 0xdbff;
96+
}
97+
98+
export function strLength(str: string): number {
99+
if (str === '') {
100+
return 0;
101+
}
102+
103+
// Remove ANSI escape codes from the input string.
104+
const stripedStr = stripAnsi(str);
105+
106+
let length = 0;
107+
108+
for (let i = 0; i < stripedStr.length; i++) {
109+
const code = stripedStr.codePointAt(i);
110+
111+
if (!code || isControlCharacter(code) || isCombiningCharacter(code)) {
112+
continue;
113+
}
114+
115+
if (isSurrogatePair(code)) {
116+
i++; // Skip the next code unit.
117+
}
118+
119+
length++;
120+
}
121+
122+
return length;
123+
}

0 commit comments

Comments
 (0)