Skip to content

Commit 83b63b2

Browse files
committed
feat: Multiline text
1 parent d6d9ce7 commit 83b63b2

File tree

7 files changed

+166
-3
lines changed

7 files changed

+166
-3
lines changed

examples/changesets/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async function main() {
7070
}
7171
);
7272

73-
const message = await p.text({
73+
const message = await p.multiline({
7474
placeholder: 'Summary',
7575
message: 'Please enter a summary for this change',
7676
});

packages/core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export { default as Prompt } from './prompts/prompt';
99
export { default as SelectPrompt } from './prompts/select';
1010
export { default as SelectKeyPrompt } from './prompts/select-key';
1111
export { default as TextPrompt } from './prompts/text';
12+
export { default as MultiLinePrompt } from './prompts/multi-line';
1213
export { block, isCancel } from './utils';
1314
export { updateSettings } from './utils/settings';
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { Key } from 'node:readline';
2+
import color from 'picocolors';
3+
import { type Action, settings } from '../utils';
4+
import Prompt, { type PromptOptions } from './prompt';
5+
import type { TextOptions } from './text';
6+
7+
export default class MultiLinePrompt extends Prompt {
8+
get valueWithCursor() {
9+
if (this.state === 'submit') {
10+
return this.value;
11+
}
12+
if (this.cursor >= this.value.length) {
13+
return `${this.value}█`;
14+
}
15+
const s1 = this.value.slice(0, this.cursor);
16+
const [s2, ...s3] = this.value.slice(this.cursor);
17+
return `${s1}${color.inverse(s2)}${s3.join('')}`;
18+
}
19+
get cursor() {
20+
return this._cursor;
21+
}
22+
insertAtCursor(char: string) {
23+
if (!this.value || this.value.length === 0) {
24+
this.value = char;
25+
return;
26+
}
27+
this.value = this.value.substr(0, this.cursor) + char + this.value.substr(this.cursor);
28+
}
29+
handleCursor(key?: Action) {
30+
const text = this.value ?? '';
31+
const lines = text.split('\n');
32+
const beforeCursor = text.substr(0, this.cursor);
33+
const currentLine = beforeCursor.split('\n').length - 1;
34+
const lineStart = beforeCursor.lastIndexOf('\n');
35+
const cursorOffet = this.cursor - lineStart;
36+
switch (key) {
37+
case 'up':
38+
if (currentLine === 0) {
39+
this._cursor = 0;
40+
return;
41+
}
42+
this._cursor +=
43+
-cursorOffet -
44+
lines[currentLine - 1].length +
45+
Math.min(lines[currentLine - 1].length, cursorOffet - 1);
46+
return;
47+
case 'down':
48+
if (currentLine === lines.length - 1) {
49+
this._cursor = text.length;
50+
return;
51+
}
52+
this._cursor +=
53+
-cursorOffet +
54+
1 +
55+
lines[currentLine].length +
56+
Math.min(lines[currentLine + 1].length + 1, cursorOffet);
57+
return;
58+
case 'left':
59+
this._cursor = Math.max(0, this._cursor - 1);
60+
return;
61+
case 'right':
62+
this._cursor = Math.min(text.length, this._cursor + 1);
63+
return;
64+
}
65+
}
66+
constructor(opts: TextOptions) {
67+
super(opts, false);
68+
69+
this.on('rawKey', (char, key) => {
70+
if (settings.actions.has(key?.name)) {
71+
this.handleCursor(key?.name);
72+
}
73+
if (char === '\r') {
74+
this.insertAtCursor('\n');
75+
this._cursor++;
76+
return;
77+
}
78+
if (char === '\u0004') {
79+
return;
80+
}
81+
if (key?.name === 'backspace' && this.cursor > 0) {
82+
this.value = this.value.substr(0, this.cursor - 1) + this.value.substr(this.cursor);
83+
this._cursor--;
84+
return;
85+
}
86+
if (key?.name === 'delete' && this.cursor < this.value.length) {
87+
this.value = this.value.substr(0, this.cursor) + this.value.substr(this.cursor + 1);
88+
return;
89+
}
90+
if (char) {
91+
this.insertAtCursor(char ?? '');
92+
this._cursor++;
93+
}
94+
});
95+
this.on('finalize', () => {
96+
if (!this.value) {
97+
this.value = opts.defaultValue;
98+
}
99+
});
100+
}
101+
}

packages/core/src/prompts/prompt.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { WriteStream } from 'node:tty';
55
import { cursor, erase } from 'sisteransi';
66
import wrap from 'wrap-ansi';
77

8-
import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils';
8+
import { CANCEL_SYMBOL, diffLines, isActionKey, isSameKey, setRawMode, settings } from '../utils';
99

1010
import type { ClackEvents, ClackState } from '../types';
1111
import type { Action } from '../utils';
@@ -19,6 +19,7 @@ export interface PromptOptions<Self extends Prompt> {
1919
output?: Writable;
2020
debug?: boolean;
2121
signal?: AbortSignal;
22+
submitKey?: Key;
2223
}
2324

2425
export default class Prompt {
@@ -33,6 +34,7 @@ export default class Prompt {
3334
private _prevFrame = '';
3435
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
3536
protected _cursor = 0;
37+
private _submitKey: Key;
3638

3739
public state: ClackState = 'initial';
3840
public error = '';
@@ -48,6 +50,7 @@ export default class Prompt {
4850
this._render = render.bind(this);
4951
this._track = trackValue;
5052
this._abortSignal = signal;
53+
this._submitKey = options.submitKey ?? { name: 'return' };
5154

5255
this.input = input;
5356
this.output = output;
@@ -202,8 +205,9 @@ export default class Prompt {
202205
if (char) {
203206
this.emit('key', char.toLowerCase());
204207
}
208+
this.emit('rawKey', char, key);
205209

206-
if (key?.name === 'return') {
210+
if (isSameKey(key, this._submitKey)) {
207211
if (this.opts.validate) {
208212
const problem = this.opts.validate(this.value);
209213
if (problem) {

packages/core/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface ClackEvents {
1616
error: (value?: any) => void;
1717
cursor: (key?: Action) => void;
1818
key: (key?: string) => void;
19+
rawKey: (char?: string, key?: any) => void;
1920
value: (value?: string) => void;
2021
confirm: (value?: boolean) => void;
2122
finalize: () => void;

packages/core/src/utils/settings.ts

+14
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Key } from 'node:readline';
2+
13
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
24
export type Action = (typeof actions)[number];
35

@@ -71,3 +73,15 @@ export function isActionKey(key: string | Array<string | undefined>, action: Act
7173
}
7274
return false;
7375
}
76+
77+
export function isSameKey(actual: Key | undefined, expected: Key): boolean {
78+
if (actual === undefined) {
79+
return false;
80+
}
81+
return (
82+
actual.name === expected.name &&
83+
(actual.ctrl ?? false) === (expected.ctrl ?? false) &&
84+
(actual.meta ?? false) === (expected.meta ?? false) &&
85+
(actual.shift ?? false) === (expected.shift ?? false)
86+
);
87+
}

packages/prompts/src/index.ts

+42
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { Key } from 'node:readline';
12
import { stripVTControlCharacters as strip } from 'node:util';
23
import {
34
ConfirmPrompt,
45
GroupMultiSelectPrompt,
6+
MultiLinePrompt,
57
MultiSelectPrompt,
68
PasswordPrompt,
79
SelectKeyPrompt,
@@ -135,6 +137,46 @@ export const text = (opts: TextOptions) => {
135137
}).prompt() as Promise<string | symbol>;
136138
};
137139

140+
export const multiline = (opts: TextOptions) => {
141+
function wrap(
142+
text: string,
143+
barStyle: (v: string) => string,
144+
textStyle: (v: string) => string
145+
): string {
146+
return `${barStyle(S_BAR)} ${text
147+
.split('\n')
148+
.map(textStyle)
149+
.join(`\n${barStyle(S_BAR)} `)}`;
150+
}
151+
return new MultiLinePrompt({
152+
validate: opts.validate,
153+
placeholder: opts.placeholder,
154+
defaultValue: opts.defaultValue,
155+
initialValue: opts.initialValue,
156+
submitKey: { name: 'd', ctrl: true },
157+
render() {
158+
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
159+
const placeholder = opts.placeholder
160+
? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1))
161+
: color.inverse(color.hidden('_'));
162+
const value: string = `${!this.value ? placeholder : this.valueWithCursor}`;
163+
switch (this.state) {
164+
case 'error':
165+
return `${title.trim()}${wrap(value, color.yellow, color.yellow)}\n${color.yellow(
166+
S_BAR_END
167+
)} ${color.yellow(this.error)}\n`;
168+
case 'submit':
169+
return `${title}${wrap(this.value || opts.placeholder, color.gray, color.dim)}`;
170+
case 'cancel':
171+
return `${title}${wrap(this.value ?? '', color.gray, (v) =>
172+
color.strikethrough(color.dim(v))
173+
)}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`;
174+
default:
175+
return `${title}${wrap(value, color.cyan, (v) => v)}\n${color.cyan(S_BAR_END)}\n`;
176+
}
177+
},
178+
}).prompt() as Promise<string | symbol>;
179+
};
138180
export interface PasswordOptions {
139181
message: string;
140182
mask?: string;

0 commit comments

Comments
 (0)