Skip to content

Commit 76fd17f

Browse files
authored
feat: box prompt (#363)
1 parent dc1a86c commit 76fd17f

File tree

6 files changed

+840
-0
lines changed

6 files changed

+840
-0
lines changed

.changeset/shy-ideas-shout.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": minor
3+
---
4+
5+
Added new `box` prompt for rendering boxed text, similar a note.

packages/prompts/src/box.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Writable } from 'node:stream';
2+
import { getColumns } from '@clack/core';
3+
import wrap from 'wrap-ansi';
4+
import {
5+
type CommonOptions,
6+
S_BAR,
7+
S_BAR_END,
8+
S_BAR_END_RIGHT,
9+
S_BAR_H,
10+
S_BAR_START,
11+
S_BAR_START_RIGHT,
12+
S_CORNER_BOTTOM_LEFT,
13+
S_CORNER_BOTTOM_RIGHT,
14+
S_CORNER_TOP_LEFT,
15+
S_CORNER_TOP_RIGHT,
16+
} from './common.js';
17+
18+
export type BoxAlignment = 'left' | 'center' | 'right';
19+
20+
type BoxSymbols = [topLeft: string, topRight: string, bottomLeft: string, bottomRight: string];
21+
22+
const roundedSymbols: BoxSymbols = [
23+
S_CORNER_TOP_LEFT,
24+
S_CORNER_TOP_RIGHT,
25+
S_CORNER_BOTTOM_LEFT,
26+
S_CORNER_BOTTOM_RIGHT,
27+
];
28+
const squareSymbols: BoxSymbols = [S_BAR_START, S_BAR_START_RIGHT, S_BAR_END, S_BAR_END_RIGHT];
29+
30+
export interface BoxOptions extends CommonOptions {
31+
contentAlign?: BoxAlignment;
32+
titleAlign?: BoxAlignment;
33+
width?: number | 'auto';
34+
titlePadding?: number;
35+
contentPadding?: number;
36+
rounded?: boolean;
37+
includePrefix?: boolean;
38+
formatBorder?: (text: string) => string;
39+
}
40+
41+
function getPaddingForLine(
42+
lineLength: number,
43+
innerWidth: number,
44+
padding: number,
45+
contentAlign: BoxAlignment | undefined
46+
): [number, number] {
47+
let leftPadding = padding;
48+
let rightPadding = padding;
49+
if (contentAlign === 'center') {
50+
leftPadding = Math.floor((innerWidth - lineLength) / 2);
51+
} else if (contentAlign === 'right') {
52+
leftPadding = innerWidth - lineLength - padding;
53+
}
54+
55+
rightPadding = innerWidth - leftPadding - lineLength;
56+
57+
return [leftPadding, rightPadding];
58+
}
59+
60+
const defaultFormatBorder = (text: string) => text;
61+
62+
export const box = (message = '', title = '', opts?: BoxOptions) => {
63+
const output: Writable = opts?.output ?? process.stdout;
64+
const columns = getColumns(output);
65+
const borderWidth = 1;
66+
const borderTotalWidth = borderWidth * 2;
67+
const titlePadding = opts?.titlePadding ?? 1;
68+
const contentPadding = opts?.contentPadding ?? 2;
69+
const width = opts?.width === undefined || opts.width === 'auto' ? 1 : Math.min(1, opts.width);
70+
const linePrefix = opts?.includePrefix ? `${S_BAR} ` : '';
71+
const formatBorder = opts?.formatBorder ?? defaultFormatBorder;
72+
const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder);
73+
const hSymbol = formatBorder(S_BAR_H);
74+
const vSymbol = formatBorder(S_BAR);
75+
const maxBoxWidth = columns - linePrefix.length;
76+
let boxWidth = Math.floor(columns * width) - linePrefix.length;
77+
if (opts?.width === 'auto') {
78+
const lines = message.split('\n');
79+
let longestLine = title.length + titlePadding * 2;
80+
for (const line of lines) {
81+
const lineWithPadding = line.length + contentPadding * 2;
82+
if (lineWithPadding > longestLine) {
83+
longestLine = lineWithPadding;
84+
}
85+
}
86+
const longestLineWidth = longestLine + borderTotalWidth;
87+
if (longestLineWidth < boxWidth) {
88+
boxWidth = longestLineWidth;
89+
}
90+
}
91+
if (boxWidth % 2 !== 0) {
92+
if (boxWidth < maxBoxWidth) {
93+
boxWidth++;
94+
} else {
95+
boxWidth--;
96+
}
97+
}
98+
const innerWidth = boxWidth - borderTotalWidth;
99+
const maxTitleLength = innerWidth - titlePadding * 2;
100+
const truncatedTitle =
101+
title.length > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
102+
const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine(
103+
truncatedTitle.length,
104+
innerWidth,
105+
titlePadding,
106+
opts?.titleAlign
107+
);
108+
const wrappedMessage = wrap(message, innerWidth - contentPadding * 2, {
109+
hard: true,
110+
trim: false,
111+
});
112+
output.write(
113+
`${linePrefix}${symbols[0]}${hSymbol.repeat(titlePaddingLeft)}${truncatedTitle}${hSymbol.repeat(titlePaddingRight)}${symbols[1]}\n`
114+
);
115+
const wrappedLines = wrappedMessage.split('\n');
116+
for (const line of wrappedLines) {
117+
const [leftLinePadding, rightLinePadding] = getPaddingForLine(
118+
line.length,
119+
innerWidth,
120+
contentPadding,
121+
opts?.contentAlign
122+
);
123+
output.write(
124+
`${linePrefix}${vSymbol}${' '.repeat(leftLinePadding)}${line}${' '.repeat(rightLinePadding)}${vSymbol}\n`
125+
);
126+
}
127+
output.write(`${linePrefix}${symbols[2]}${hSymbol.repeat(innerWidth)}${symbols[3]}\n`);
128+
};

packages/prompts/src/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const S_STEP_SUBMIT = unicodeOr('◇', 'o');
1717
export const S_BAR_START = unicodeOr('┌', 'T');
1818
export const S_BAR = unicodeOr('│', '|');
1919
export const S_BAR_END = unicodeOr('└', '—');
20+
export const S_BAR_START_RIGHT = unicodeOr('┐', 'T');
21+
export const S_BAR_END_RIGHT = unicodeOr('┘', '—');
2022

2123
export const S_RADIO_ACTIVE = unicodeOr('●', '>');
2224
export const S_RADIO_INACTIVE = unicodeOr('○', ' ');
@@ -29,6 +31,8 @@ export const S_BAR_H = unicodeOr('─', '-');
2931
export const S_CORNER_TOP_RIGHT = unicodeOr('╮', '+');
3032
export const S_CONNECT_LEFT = unicodeOr('├', '+');
3133
export const S_CORNER_BOTTOM_RIGHT = unicodeOr('╯', '+');
34+
export const S_CORNER_BOTTOM_LEFT = unicodeOr('╰', '+');
35+
export const S_CORNER_TOP_LEFT = unicodeOr('╭', '+');
3236

3337
export const S_INFO = unicodeOr('●', '•');
3438
export const S_SUCCESS = unicodeOr('◆', '*');

packages/prompts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { type ClackSettings, isCancel, settings, updateSettings } from '@clack/core';
22

33
export * from './autocomplete.js';
4+
export * from './box.js';
45
export * from './common.js';
56
export * from './confirm.js';
67
export * from './group.js';

0 commit comments

Comments
 (0)