Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/shy-ideas-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

Added new `box` prompt for rendering boxed text, similar a note.
128 changes: 128 additions & 0 deletions packages/prompts/src/box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { Writable } from 'node:stream';
import { getColumns } from '@clack/core';
import wrap from 'wrap-ansi';
import {
type CommonOptions,
S_BAR,
S_BAR_END,
S_BAR_END_RIGHT,
S_BAR_H,
S_BAR_START,
S_BAR_START_RIGHT,
S_CORNER_BOTTOM_LEFT,
S_CORNER_BOTTOM_RIGHT,
S_CORNER_TOP_LEFT,
S_CORNER_TOP_RIGHT,
} from './common.js';

export type BoxAlignment = 'left' | 'center' | 'right';

type BoxSymbols = [topLeft: string, topRight: string, bottomLeft: string, bottomRight: string];

const roundedSymbols: BoxSymbols = [
S_CORNER_TOP_LEFT,
S_CORNER_TOP_RIGHT,
S_CORNER_BOTTOM_LEFT,
S_CORNER_BOTTOM_RIGHT,
];
const squareSymbols: BoxSymbols = [S_BAR_START, S_BAR_START_RIGHT, S_BAR_END, S_BAR_END_RIGHT];

export interface BoxOptions extends CommonOptions {
contentAlign?: BoxAlignment;
titleAlign?: BoxAlignment;
width?: number | 'auto';
titlePadding?: number;
contentPadding?: number;
rounded?: boolean;
includePrefix?: boolean;
formatBorder?: (text: string) => string;
}

function getPaddingForLine(
lineLength: number,
innerWidth: number,
padding: number,
contentAlign: BoxAlignment | undefined
): [number, number] {
let leftPadding = padding;
let rightPadding = padding;
if (contentAlign === 'center') {
leftPadding = Math.floor((innerWidth - lineLength) / 2);
} else if (contentAlign === 'right') {
leftPadding = innerWidth - lineLength - padding;
}

rightPadding = innerWidth - leftPadding - lineLength;

return [leftPadding, rightPadding];
}

const defaultFormatBorder = (text: string) => text;

export const box = (message = '', title = '', opts?: BoxOptions) => {
const output: Writable = opts?.output ?? process.stdout;
const columns = getColumns(output);
const borderWidth = 1;
const borderTotalWidth = borderWidth * 2;
const titlePadding = opts?.titlePadding ?? 1;
const contentPadding = opts?.contentPadding ?? 2;
const width = opts?.width === undefined || opts.width === 'auto' ? 1 : Math.min(1, opts.width);
const linePrefix = opts?.includePrefix ? `${S_BAR} ` : '';
const formatBorder = opts?.formatBorder ?? defaultFormatBorder;
const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder);
const hSymbol = formatBorder(S_BAR_H);
const vSymbol = formatBorder(S_BAR);
const maxBoxWidth = columns - linePrefix.length;
let boxWidth = Math.floor(columns * width) - linePrefix.length;
if (opts?.width === 'auto') {
const lines = message.split('\n');
let longestLine = title.length + titlePadding * 2;
for (const line of lines) {
const lineWithPadding = line.length + contentPadding * 2;
if (lineWithPadding > longestLine) {
longestLine = lineWithPadding;
}
}
const longestLineWidth = longestLine + borderTotalWidth;
if (longestLineWidth < boxWidth) {
boxWidth = longestLineWidth;
}
}
if (boxWidth % 2 !== 0) {
if (boxWidth < maxBoxWidth) {
boxWidth++;
} else {
boxWidth--;
}
}
const innerWidth = boxWidth - borderTotalWidth;
const maxTitleLength = innerWidth - titlePadding * 2;
const truncatedTitle =
title.length > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine(
truncatedTitle.length,
innerWidth,
titlePadding,
opts?.titleAlign
);
const wrappedMessage = wrap(message, innerWidth - contentPadding * 2, {
hard: true,
trim: false,
});
output.write(
`${linePrefix}${symbols[0]}${hSymbol.repeat(titlePaddingLeft)}${truncatedTitle}${hSymbol.repeat(titlePaddingRight)}${symbols[1]}\n`
);
const wrappedLines = wrappedMessage.split('\n');
for (const line of wrappedLines) {
const [leftLinePadding, rightLinePadding] = getPaddingForLine(
line.length,
innerWidth,
contentPadding,
opts?.contentAlign
);
output.write(
`${linePrefix}${vSymbol}${' '.repeat(leftLinePadding)}${line}${' '.repeat(rightLinePadding)}${vSymbol}\n`
);
}
output.write(`${linePrefix}${symbols[2]}${hSymbol.repeat(innerWidth)}${symbols[3]}\n`);
};
4 changes: 4 additions & 0 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const S_STEP_SUBMIT = unicodeOr('◇', 'o');
export const S_BAR_START = unicodeOr('┌', 'T');
export const S_BAR = unicodeOr('│', '|');
export const S_BAR_END = unicodeOr('└', '—');
export const S_BAR_START_RIGHT = unicodeOr('┐', 'T');
export const S_BAR_END_RIGHT = unicodeOr('┘', '—');

export const S_RADIO_ACTIVE = unicodeOr('●', '>');
export const S_RADIO_INACTIVE = unicodeOr('○', ' ');
Expand All @@ -29,6 +31,8 @@ export const S_BAR_H = unicodeOr('─', '-');
export const S_CORNER_TOP_RIGHT = unicodeOr('╮', '+');
export const S_CONNECT_LEFT = unicodeOr('├', '+');
export const S_CORNER_BOTTOM_RIGHT = unicodeOr('╯', '+');
export const S_CORNER_BOTTOM_LEFT = unicodeOr('╰', '+');
export const S_CORNER_TOP_LEFT = unicodeOr('╭', '+');

export const S_INFO = unicodeOr('●', '•');
export const S_SUCCESS = unicodeOr('◆', '*');
Expand Down
1 change: 1 addition & 0 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { type ClackSettings, isCancel, settings, updateSettings } from '@clack/core';

export * from './autocomplete.js';
export * from './box.js';
export * from './common.js';
export * from './confirm.js';
export * from './group.js';
Expand Down
Loading