Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@clack/core,@clack/prompts): multiline support #143

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/neat-birds-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clack/prompts': minor
'@clack/core': minor
---

Feat multiline support
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ export { default as SelectPrompt } from './prompts/select';
export { default as SelectKeyPrompt } from './prompts/select-key';
export { default as TextPrompt } from './prompts/text';
export type { ClackState as State } from './types';
export { block, isCancel, setGlobalAliases } from './utils';
export { block, isCancel, strLength, setGlobalAliases } from './utils';
182 changes: 181 additions & 1 deletion packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Readable, Writable } from 'node:stream';
import { WriteStream } from 'node:tty';
import { cursor, erase } from 'sisteransi';
import wrap from 'wrap-ansi';
import { strLength } from '../utils';

import { ALIASES, CANCEL_SYMBOL, KEYS, diffLines, hasAliasKey, setRawMode } from '../utils';

Expand All @@ -19,6 +20,88 @@ export interface PromptOptions<Self extends Prompt> {
debug?: boolean;
}

export type LineOption = 'firstLine' | 'newLine' | 'lastLine';

export interface FormatLineOptions {
/**
* Define the start of line
* @example
* format('foo', {
* line: {
* start: '-'
* }
* })
* //=> '- foo'
*/
start: string;
/**
* Define the end of line
* @example
* format('foo', {
* line: {
* end: '-'
* }
* })
* //=> 'foo -'
*/
end: string;
/**
* Define the sides of line
* @example
* format('foo', {
* line: {
* sides: '-'
* }
* })
* //=> '- foo -'
*/
sides: string;
/**
* Define the style of line
* @example
* format('foo', {
* line: {
* style: (line) => `(${line})`
* }
* })
* //=> '(foo)'
*/
style: (line: string) => string;
}

export interface FormatOptions extends Record<LineOption, Partial<FormatLineOptions>> {
/**
* Shorthand to define values for each line
* @example
* format('foo', {
* default: {
* start: '-'
* }
* // equals
* firstLine{
* start: '-'
* },
* newLine{
* start: '-'
* },
* lastLine{
* start: '-'
* },
* })
*/
default: Partial<FormatLineOptions>;
/**
* Define the max width of each line
* @example
* format('foo bar baz', {
* maxWidth: 7
* })
* //=> 'foo bar\nbaz'
*/
maxWidth: number;
minWidth: number;
}

export default class Prompt {
protected input: Readable;
protected output: Writable;
Expand Down Expand Up @@ -221,8 +304,105 @@ export default class Prompt {
this.output.write(cursor.move(-999, lines * -1));
}

public format(text: string, options?: Partial<FormatOptions>): string {
const getLineOption = <TLine extends LineOption, TKey extends keyof FormatLineOptions>(
line: TLine,
key: TKey
): NonNullable<FormatOptions[TLine][TKey]> => {
return (
key === 'style'
? (options?.[line]?.[key] ?? options?.default?.[key] ?? ((line) => line))
: (options?.[line]?.[key] ?? options?.[line]?.sides ?? options?.default?.[key] ?? '')
) as NonNullable<FormatOptions[TLine][TKey]>;
};
const getLineOptions = (line: LineOption): Omit<FormatLineOptions, 'sides'> => {
return {
start: getLineOption(line, 'start'),
end: getLineOption(line, 'end'),
style: getLineOption(line, 'style'),
};
};

const firstLine = getLineOptions('firstLine');
const newLine = getLineOptions('newLine');
const lastLine = getLineOptions('lastLine');

const emptySlots =
Math.max(
strLength(firstLine.start + firstLine.end),
strLength(newLine.start + newLine.end),
strLength(lastLine.start + lastLine.end)
) + 2;
const terminalWidth = process.stdout.columns || 80;
const maxWidth = options?.maxWidth ?? terminalWidth;
const minWidth = options?.minWidth ?? 1;

const formattedLines: string[] = [];
const paragraphs = text.split(/\n/g);

for (const paragraph of paragraphs) {
const words = paragraph.split(/\s/g);
let currentLine = '';

for (const word of words) {
if (strLength(currentLine + word) + emptySlots + 1 <= maxWidth) {
currentLine += ` ${word}`;
} else if (strLength(word) + emptySlots >= maxWidth) {
const splitIndex = maxWidth - strLength(currentLine) - emptySlots - 1;
formattedLines.push(`${currentLine} ${word.slice(0, splitIndex)}`);

const chunkLength = maxWidth - emptySlots;
let chunk = word.slice(splitIndex);
while (strLength(chunk) > chunkLength) {
formattedLines.push(chunk.slice(0, chunkLength));
chunk = chunk.slice(chunkLength);
}
currentLine = chunk;
} else {
formattedLines.push(currentLine);
currentLine = word;
}
}

formattedLines.push(currentLine);
}

return formattedLines
.map((line, i, ar) => {
const opt = <TPosition extends Exclude<keyof FormatLineOptions, 'sides'>>(
position: TPosition
): FormatLineOptions[TPosition] => {
return (
i === 0 && ar.length === 1
? (options?.firstLine?.[position] ??
options?.lastLine?.[position] ??
firstLine[position])
: i === 0
? firstLine[position]
: i + 1 === ar.length
? lastLine[position]
: newLine[position]
) as FormatLineOptions[TPosition];
};
const startLine = opt('start');
const endLine = opt('end');
const styleLine = opt('style');
// only format the line without the leading space.
const leadingSpaceRegex = /^\s/;
const styledLine = leadingSpaceRegex.test(line)
? ` ${styleLine(line.slice(1))}`
: styleLine(line);
const fullLine =
styledLine + ' '.repeat(Math.max(minWidth - strLength(styledLine) - emptySlots, 0));
return [startLine, fullLine, endLine].join(' ');
})
.join('\n');
}

private render() {
const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true });
const frame = wrap(this._render(this) ?? '', process.stdout.columns, {
hard: true,
});
if (frame === this._prevFrame) return;

if (this.state === 'initial') {
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,55 @@ export function block({
rl.close();
};
}

function ansiRegex(): RegExp {
const pattern = [
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))',
].join('|');

return new RegExp(pattern, 'g');
}

function stripAnsi(str: string): string {
return str.replace(ansiRegex(), '');
}

function isControlCharacter(code: number): boolean {
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
}

function isCombiningCharacter(code: number): boolean {
return code >= 0x300 && code <= 0x36f;
}

function isSurrogatePair(code: number): boolean {
return code >= 0xd800 && code <= 0xdbff;
}

export function strLength(str: string): number {
if (str === '') {
return 0;
}

// Remove ANSI escape codes from the input string.
const stripedStr = stripAnsi(str);

let length = 0;

for (let i = 0; i < stripedStr.length; i++) {
const code = stripedStr.codePointAt(i);

if (!code || isControlCharacter(code) || isCombiningCharacter(code)) {
continue;
}

if (isSurrogatePair(code)) {
i++; // Skip the next code unit.
}

length++;
}

return length;
}
Loading
Loading