Skip to content

Commit d25f6d0

Browse files
authored
fix(note,box): handle cjk correctly (#391)
1 parent 2310b43 commit d25f6d0

File tree

9 files changed

+379
-128
lines changed

9 files changed

+379
-128
lines changed

.changeset/lucky-dragons-think.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": patch
3+
---
4+
5+
fix(note, box): handle CJK correctly

packages/prompts/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@
5858
"sisteransi": "^1.0.5"
5959
},
6060
"devDependencies": {
61+
"fast-string-width": "^1.1.0",
62+
"fast-wrap-ansi": "^0.1.3",
6163
"is-unicode-supported": "^1.3.0",
6264
"memfs": "^4.17.2",
6365
"vitest": "^3.2.4",
64-
"vitest-ansi-serializer": "^0.1.2",
65-
"fast-wrap-ansi": "^0.1.3"
66+
"vitest-ansi-serializer": "^0.1.2"
6667
}
6768
}

packages/prompts/src/box.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
S_CORNER_TOP_LEFT,
1515
S_CORNER_TOP_RIGHT,
1616
} from './common.js';
17+
import stringWidth from "fast-string-width";
1718

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

@@ -72,13 +73,15 @@ export const box = (message = '', title = '', opts?: BoxOptions) => {
7273
const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder);
7374
const hSymbol = formatBorder(S_BAR_H);
7475
const vSymbol = formatBorder(S_BAR);
75-
const maxBoxWidth = columns - linePrefix.length;
76-
let boxWidth = Math.floor(columns * width) - linePrefix.length;
76+
const linePrefixWidth = stringWidth(linePrefix);
77+
const titleWidth = stringWidth(title);
78+
const maxBoxWidth = columns - linePrefixWidth;
79+
let boxWidth = Math.floor(columns * width) - linePrefixWidth;
7780
if (opts?.width === 'auto') {
7881
const lines = message.split('\n');
79-
let longestLine = title.length + titlePadding * 2;
82+
let longestLine = titleWidth + titlePadding * 2;
8083
for (const line of lines) {
81-
const lineWithPadding = line.length + contentPadding * 2;
84+
const lineWithPadding = stringWidth(line) + contentPadding * 2;
8285
if (lineWithPadding > longestLine) {
8386
longestLine = lineWithPadding;
8487
}
@@ -98,9 +101,9 @@ export const box = (message = '', title = '', opts?: BoxOptions) => {
98101
const innerWidth = boxWidth - borderTotalWidth;
99102
const maxTitleLength = innerWidth - titlePadding * 2;
100103
const truncatedTitle =
101-
title.length > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
104+
titleWidth > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;
102105
const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine(
103-
truncatedTitle.length,
106+
stringWidth(truncatedTitle),
104107
innerWidth,
105108
titlePadding,
106109
opts?.titleAlign
@@ -115,7 +118,7 @@ export const box = (message = '', title = '', opts?: BoxOptions) => {
115118
const wrappedLines = wrappedMessage.split('\n');
116119
for (const line of wrappedLines) {
117120
const [leftLinePadding, rightLinePadding] = getPaddingForLine(
118-
line.length,
121+
stringWidth(line),
119122
innerWidth,
120123
contentPadding,
121124
opts?.contentAlign

packages/prompts/src/note.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import process from 'node:process';
22
import type { Writable } from 'node:stream';
3-
import { stripVTControlCharacters as strip } from 'node:util';
43
import { getColumns } from '@clack/core';
54
import { type Options as WrapAnsiOptions, wrapAnsi } from 'fast-wrap-ansi';
65
import color from 'picocolors';
@@ -13,6 +12,7 @@ import {
1312
S_CORNER_TOP_RIGHT,
1413
S_STEP_SUBMIT,
1514
} from './common.js';
15+
import stringWidth from "fast-string-width";
1616

1717
type FormatFn = (line: string) => string;
1818
export interface NoteOptions extends CommonOptions {
@@ -27,10 +27,10 @@ const wrapWithFormat = (message: string, width: number, format: FormatFn): strin
2727
trim: false,
2828
};
2929
const wrapMsg = wrapAnsi(message, width, opts).split('\n');
30-
const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(strip(ln).length, sum), 0);
30+
const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);
3131
const maxWidthFormat = wrapMsg
3232
.map(format)
33-
.reduce((sum, ln) => Math.max(strip(ln).length, sum), 0);
33+
.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);
3434
const wrapWidth = width - (maxWidthFormat - maxWidthNormal);
3535
return wrapAnsi(message, wrapWidth, opts);
3636
};
@@ -40,18 +40,18 @@ export const note = (message = '', title = '', opts?: NoteOptions) => {
4040
const format = opts?.format ?? defaultNoteFormatter;
4141
const wrapMsg = wrapWithFormat(message, getColumns(output) - 6, format);
4242
const lines = ['', ...wrapMsg.split('\n').map(format), ''];
43-
const titleLen = strip(title).length;
43+
const titleLen = stringWidth(title);
4444
const len =
4545
Math.max(
4646
lines.reduce((sum, ln) => {
47-
const line = strip(ln);
48-
return line.length > sum ? line.length : sum;
47+
const width = stringWidth(ln);
48+
return width > sum ? width : sum;
4949
}, 0),
5050
titleLen
5151
) + 2;
5252
const msg = lines
5353
.map(
54-
(ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}`
54+
(ln) => `${color.gray(S_BAR)} ${ln}${' '.repeat(len - stringWidth(ln))}${color.gray(S_BAR)}`
5555
)
5656
.join('\n');
5757
output.write(

packages/prompts/test/__snapshots__/box.test.ts.snap

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,38 @@ exports[`box (isCI = false) > renders truncated long titles 1`] = `
191191
]
192192
`;
193193

194+
exports[`box (isCI = false) > renders wide characters with auto width 1`] = `
195+
[
196+
"┌─这是标题─────────────────┐
197+
",
198+
"│ 이게 첫 번째 줄이에요 │
199+
",
200+
"│ これは次の行です │
201+
",
202+
"└──────────────────────────┘
203+
",
204+
]
205+
`;
206+
207+
exports[`box (isCI = false) > renders wide characters with specified width 1`] = `
208+
[
209+
"┌─这是标题─────┐
210+
",
211+
"│ 이게 첫 │
212+
",
213+
"│ 번째 │
214+
",
215+
"│ 줄이에요 │
216+
",
217+
"│ これは次の │
218+
",
219+
"│ 行です │
220+
",
221+
"└──────────────┘
222+
",
223+
]
224+
`;
225+
194226
exports[`box (isCI = false) > renders with formatBorder formatting 1`] = `
195227
[
196228
"┌─title──────┐
@@ -423,6 +455,38 @@ exports[`box (isCI = true) > renders truncated long titles 1`] = `
423455
]
424456
`;
425457

458+
exports[`box (isCI = true) > renders wide characters with auto width 1`] = `
459+
[
460+
"┌─这是标题─────────────────┐
461+
",
462+
"│ 이게 첫 번째 줄이에요 │
463+
",
464+
"│ これは次の行です │
465+
",
466+
"└──────────────────────────┘
467+
",
468+
]
469+
`;
470+
471+
exports[`box (isCI = true) > renders wide characters with specified width 1`] = `
472+
[
473+
"┌─这是标题─────┐
474+
",
475+
"│ 이게 첫 │
476+
",
477+
"│ 번째 │
478+
",
479+
"│ 줄이에요 │
480+
",
481+
"│ これは次の │
482+
",
483+
"│ 行です │
484+
",
485+
"└──────────────┘
486+
",
487+
]
488+
`;
489+
426490
exports[`box (isCI = true) > renders with formatBorder formatting 1`] = `
427491
[
428492
"┌─title──────┐

0 commit comments

Comments
 (0)