Skip to content

Commit d89e67a

Browse files
authored
feat: add ascii fallbacks (#24)
2 parents e105324 + 15558e3 commit d89e67a

File tree

4 files changed

+93
-60
lines changed

4 files changed

+93
-60
lines changed

.changeset/sharp-badgers-hug.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+
Improved Windows/non-unicode support

packages/prompts/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
"sisteransi": "^1.0.5"
5858
},
5959
"devDependencies": {
60-
"unbuild": "^1.1.1"
61-
}
60+
"unbuild": "^1.1.1",
61+
"is-unicode-supported": "^1.3.0"
62+
},
63+
"bundledDependencies": [
64+
"is-unicode-supported"
65+
]
6266
}

packages/prompts/src/index.ts

Lines changed: 75 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,46 @@ import { State } from "@clack/core";
22
import { MultiSelectPrompt, TextPrompt, SelectPrompt, ConfirmPrompt, block } from "@clack/core";
33
import color from "picocolors";
44
import { cursor, erase } from "sisteransi";
5+
import isUnicodeSupported from "is-unicode-supported";
6+
57
export { isCancel } from "@clack/core";
68

9+
const unicode = isUnicodeSupported();
10+
const s = (c: string, fallback: string) => unicode ? c : fallback;
11+
const S_STEP_ACTIVE = s("◆", "*");
12+
const S_STEP_CANCEL = s("■", "x");
13+
const S_STEP_ERROR = s("▲", "x");
14+
const S_STEP_SUBMIT = s("◇", "o");
15+
16+
const S_BAR_START = s("┌", "T");
17+
const S_BAR = s("│", "|");
18+
const S_BAR_END = s("└", "—");
19+
20+
const S_RADIO_ACTIVE = s("●", ">");
21+
const S_RADIO_INACTIVE = s("○", " ");
22+
const S_CHECKBOX_ACTIVE = s("◻", "[•]");
23+
const S_CHECKBOX_SELECTED = s("◼", "[+]");
24+
const S_CHECKBOX_INACTIVE = s("◻", "[ ]");
25+
26+
const S_BAR_H = s('─', '-');
27+
const S_CORNER_TOP_RIGHT = s('╮', '+');
28+
const S_CONNECT_LEFT = s('├', '+');
29+
const S_CORNER_BOTTOM_RIGHT = s('╯', '+');
30+
731
const symbol = (state: State) => {
832
switch (state) {
933
case "initial":
1034
case "active":
11-
return color.cyan("●");
35+
return color.cyan(S_STEP_ACTIVE);
1236
case "cancel":
13-
return color.red("■");
37+
return color.red(S_STEP_CANCEL);
1438
case "error":
15-
return color.yellow("▲");
39+
return color.yellow(S_STEP_ERROR);
1640
case "submit":
17-
return color.green("○");
41+
return color.green(S_STEP_SUBMIT);
1842
}
1943
};
2044

21-
const barStart = "┌";
22-
const bar = "│";
23-
const barEnd = "└";
24-
2545
export interface TextOptions {
2646
message: string;
2747
placeholder?: string;
@@ -34,7 +54,7 @@ export const text = (opts: TextOptions) => {
3454
placeholder: opts.placeholder,
3555
initialValue: opts.initialValue,
3656
render() {
37-
const title = `${color.gray(bar)}\n${symbol(this.state)} ${
57+
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${
3858
opts.message
3959
}\n`;
4060
const placeholder = opts.placeholder
@@ -46,17 +66,17 @@ export const text = (opts: TextOptions) => {
4666
switch (this.state) {
4767
case "error":
4868
return `${title.trim()}\n${color.yellow(
49-
bar
50-
)} ${value}\n${color.yellow(barEnd)} ${color.yellow(this.error)}\n`;
69+
S_BAR
70+
)} ${value}\n${color.yellow(S_BAR_END)} ${color.yellow(this.error)}\n`;
5171
case "submit":
52-
return `${title}${color.gray(bar)} ${color.dim(this.value)}`;
72+
return `${title}${color.gray(S_BAR)} ${color.dim(this.value)}`;
5373
case "cancel":
54-
return `${title}${color.gray(bar)} ${color.strikethrough(
74+
return `${title}${color.gray(S_BAR)} ${color.strikethrough(
5575
color.dim(this.value)
56-
)}${this.value.trim() ? "\n" + color.gray(bar) : ""}`;
76+
)}${this.value.trim() ? "\n" + color.gray(S_BAR) : ""}`;
5777
default:
58-
return `${title}${color.cyan(bar)} ${value}\n${color.cyan(
59-
barEnd
78+
return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(
79+
S_BAR_END
6080
)}\n`;
6181
}
6282
},
@@ -77,28 +97,28 @@ export const confirm = (opts: ConfirmOptions) => {
7797
inactive,
7898
initialValue: opts.initialValue ?? true,
7999
render() {
80-
const title = `${color.gray(bar)}\n${symbol(this.state)} ${
100+
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${
81101
opts.message
82102
}\n`;
83103
const value = this.value ? active : inactive;
84104

85105
switch (this.state) {
86106
case "submit":
87-
return `${title}${color.gray(bar)} ${color.dim(value)}`;
107+
return `${title}${color.gray(S_BAR)} ${color.dim(value)}`;
88108
case "cancel":
89-
return `${title}${color.gray(bar)} ${color.strikethrough(
109+
return `${title}${color.gray(S_BAR)} ${color.strikethrough(
90110
color.dim(value)
91-
)}\n${color.gray(bar)}`;
111+
)}\n${color.gray(S_BAR)}`;
92112
default: {
93-
return `${title}${color.cyan(bar)} ${
113+
return `${title}${color.cyan(S_BAR)} ${
94114
this.value
95-
? `${color.green("●")} ${active}`
96-
: `${color.dim("○")} ${color.dim(active)}`
115+
? `${color.green(S_RADIO_ACTIVE)} ${active}`
116+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}`
97117
} ${color.dim("/")} ${
98118
!this.value
99-
? `${color.green("●")} ${inactive}`
100-
: `${color.dim("○")} ${color.dim(inactive)}`
101-
}\n${color.cyan(barEnd)}\n`;
119+
? `${color.green(S_RADIO_ACTIVE)} ${inactive}`
120+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}`
121+
}\n${color.cyan(S_BAR_END)}\n`;
102122
}
103123
}
104124
},
@@ -133,42 +153,42 @@ export const select = <Options extends Option<Value>[], Value extends Primitive>
133153
) => {
134154
const label = option.label ?? String(option.value);
135155
if (state === "active") {
136-
return `${color.green("●")} ${label} ${
156+
return `${color.green(S_RADIO_ACTIVE)} ${label} ${
137157
option.hint ? color.dim(`(${option.hint})`) : ""
138158
}`;
139159
} else if (state === "selected") {
140160
return `${color.dim(label)}`;
141161
} else if (state === "cancelled") {
142162
return `${color.strikethrough(color.dim(label))}`;
143163
}
144-
return `${color.dim("○")} ${color.dim(label)}`;
164+
return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`;
145165
};
146166

147167
return new SelectPrompt({
148168
options: opts.options,
149169
initialValue: opts.initialValue,
150170
render() {
151-
const title = `${color.gray(bar)}\n${symbol(this.state)} ${
171+
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${
152172
opts.message
153173
}\n`;
154174

155175
switch (this.state) {
156176
case "submit":
157-
return `${title}${color.gray(bar)} ${opt(
177+
return `${title}${color.gray(S_BAR)} ${opt(
158178
this.options[this.cursor],
159179
"selected"
160180
)}`;
161181
case "cancel":
162-
return `${title}${color.gray(bar)} ${opt(
182+
return `${title}${color.gray(S_BAR)} ${opt(
163183
this.options[this.cursor],
164184
"cancelled"
165-
)}\n${color.gray(bar)}`;
185+
)}\n${color.gray(S_BAR)}`;
166186
default: {
167-
return `${title}${color.cyan(bar)} ${this.options
187+
return `${title}${color.cyan(S_BAR)} ${this.options
168188
.map((option, i) =>
169189
opt(option, i === this.cursor ? "active" : "inactive")
170190
)
171-
.join(`\n${color.cyan(bar)} `)}\n${color.cyan(barEnd)}\n`;
191+
.join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
172192
}
173193
}
174194
},
@@ -179,39 +199,39 @@ export const multiselect = <Options extends Option<Value>[], Value extends Primi
179199
const opt = (option: Options[number], state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled') => {
180200
const label = option.label ?? String(option.value);
181201
if (state === 'active') {
182-
return `${color.cyan('◻')} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
202+
return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
183203
} else if (state === 'selected') {
184-
return `${color.green('◼')} ${color.dim(label)}`
204+
return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`
185205
} else if (state === 'cancelled') {
186206
return `${color.strikethrough(color.dim(label))}`;
187207
} else if (state === 'active-selected') {
188-
return `${color.green('◼')} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
208+
return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
189209
} else if (state === 'submitted') {
190210
return `${color.dim(label)}`;
191211
}
192-
return `${color.dim('◻')} ${color.dim(label)}`;
212+
return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`;
193213
}
194214

195215
return new MultiSelectPrompt({
196216
options: opts.options,
197217
initialValue: opts.initialValue,
198218
cursorAt: opts.cursorAt,
199219
render() {
200-
let title = `${color.gray(bar)}\n${symbol(this.state)} ${opts.message}\n`;
220+
let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
201221

202222
switch (this.state) {
203223
case 'submit': {
204224
const selectedOptions = this.options.filter(option => this.selectedValues.some(selectedValue => selectedValue === option.value as any));
205-
return `${title}${color.gray(bar)} ${selectedOptions.map((option, i) => opt(option, 'submitted')).join(color.dim(", "))}`;
225+
return `${title}${color.gray(S_BAR)} ${selectedOptions.map((option, i) => opt(option, 'submitted')).join(color.dim(", "))}`;
206226
};
207227
case 'cancel': {
208228
const selectedOptions = this.options.filter(option => this.selectedValues.some(selectedValue => selectedValue === option.value as any));
209229
const label = selectedOptions.map((option, i) => opt(option, 'cancelled')).join(color.dim(", "));
210-
return `${title}${color.gray(bar)} ${label.trim() ? `${label}\n${color.gray(bar)}` : ''}`
230+
return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : ''}`
211231
};
212232
case 'error': {
213-
const footer = this.error.split('\n').map((ln, i) => i === 0 ? `${color.yellow(barEnd)} ${color.yellow(ln)}` : ` ${ln}`).join('\n');
214-
return `${title}${color.yellow(bar)} ${this.options.map((option, i) => {
233+
const footer = this.error.split('\n').map((ln, i) => i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`).join('\n');
234+
return `${title}${color.yellow(S_BAR)} ${this.options.map((option, i) => {
215235
const isOptionSelected = this.selectedValues.includes(option.value as any);
216236
const isOptionHovered = i === this.cursor;
217237
if(isOptionHovered && isOptionSelected) {
@@ -221,10 +241,10 @@ export const multiselect = <Options extends Option<Value>[], Value extends Primi
221241
return opt(option, 'selected');
222242
}
223243
return opt(option, isOptionHovered ? 'active' : 'inactive');
224-
}).join(`\n${color.yellow(bar)} `)}\n${footer}\n`;
244+
}).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`;
225245
}
226246
default: {
227-
return `${title}${color.cyan(bar)} ${this.options.map((option, i) => {
247+
return `${title}${color.cyan(S_BAR)} ${this.options.map((option, i) => {
228248
const isOptionSelected = this.selectedValues.includes(option.value as any);
229249
const isOptionHovered = i === this.cursor;
230250
if(isOptionHovered && isOptionSelected) {
@@ -234,7 +254,7 @@ export const multiselect = <Options extends Option<Value>[], Value extends Primi
234254
return opt(option, 'selected');
235255
}
236256
return opt(option, isOptionHovered ? 'active' : 'inactive');
237-
}).join(`\n${color.cyan(bar)} `)}\n${color.cyan(barEnd)}\n`;
257+
}).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
238258
}
239259
}
240260
}
@@ -248,39 +268,36 @@ export const note = (message = "", title = '') => {
248268
ln = strip(ln);
249269
return ln.length > sum ? ln.length : sum
250270
}, 0) + 2;
251-
const msg = lines.map((ln) => `${color.gray(bar)} ${color.dim(ln)}${' '.repeat(len - strip(ln).length)}${color.gray(bar)}`).join('\n');
252-
process.stdout.write(`${color.gray(bar)}\n${color.green('○')} ${color.reset(title)} ${color.gray('─'.repeat(len - title.length - 1) + '╮')}\n${msg}\n${color.gray('├' + '─'.repeat(len + 2) + '╯')}\n`);
271+
const msg = lines.map((ln) => `${color.gray(S_BAR)} ${color.dim(ln)}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}`).join('\n');
272+
process.stdout.write(`${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray(S_BAR_H.repeat(len - title.length - 1) + S_CORNER_TOP_RIGHT)}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n`);
253273
};
254274

255275
export const cancel = (message = "") => {
256-
process.stdout.write(`${color.gray(barEnd)} ${color.red(message)}\n\n`);
276+
process.stdout.write(`${color.gray(S_BAR_END)} ${color.red(message)}\n\n`);
257277
};
258278

259279
export const intro = (title = "") => {
260-
process.stdout.write(`${color.gray(barStart)} ${title}\n`);
280+
process.stdout.write(`${color.gray(S_BAR_START)} ${title}\n`);
261281
};
262282

263283
export const outro = (message = "") => {
264284
process.stdout.write(
265-
`${color.gray(bar)}\n${color.gray(barEnd)} ${message}\n\n`
285+
`${color.gray(S_BAR)}\n${color.gray(S_BAR_END)} ${message}\n\n`
266286
);
267287
};
268288

269-
const arc = [
270-
'◒', '◐', '◓', '◑'
271-
]
289+
const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•','o','O','0']
272290

273291
export const spinner = () => {
274292
let unblock: () => void;
275293
let loop: NodeJS.Timer;
276-
const frames = arc;
277-
const delay = 80;
294+
const delay = unicode ? 80 : 120;
278295
return {
279296
start(message = "") {
280297
message = message.replace(/\.?\.?\.$/, "");
281298
unblock = block();
282299
process.stdout.write(
283-
`${color.gray(bar)}\n${color.magenta("○")} ${message}\n`
300+
`${color.gray(S_BAR)}\n${color.magenta("○")} ${message}\n`
284301
);
285302
let i = 0;
286303
let dot = 0;
@@ -299,7 +316,7 @@ export const spinner = () => {
299316
process.stdout.write(erase.down(2));
300317
clearInterval(loop);
301318
process.stdout.write(
302-
`${color.gray(bar)}\n${color.green("○")} ${message}\n`
319+
`${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${message}\n`
303320
);
304321
unblock();
305322
},

pnpm-lock.yaml

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)