Skip to content

Commit 1993b93

Browse files
committed
feat: Multiline text
1 parent f9db1bd commit 1993b93

File tree

7 files changed

+172
-3
lines changed

7 files changed

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

packages/core/src/prompts/prompt.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ 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/index.js';
8+
import {
9+
CANCEL_SYMBOL,
10+
diffLines,
11+
isActionKey,
12+
isSameKey,
13+
setRawMode,
14+
settings,
15+
} from '../utils/index.js';
916

1017
import type { ClackEvents, ClackState } from '../types.js';
1118
import type { Action } from '../utils/index.js';
@@ -19,6 +26,7 @@ export interface PromptOptions<Self extends Prompt> {
1926
output?: Writable;
2027
debug?: boolean;
2128
signal?: AbortSignal;
29+
submitKey?: Key;
2230
}
2331

2432
export default class Prompt {
@@ -33,6 +41,7 @@ export default class Prompt {
3341
private _prevFrame = '';
3442
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
3543
protected _cursor = 0;
44+
private _submitKey: Key;
3645

3746
public state: ClackState = 'initial';
3847
public error = '';
@@ -48,6 +57,7 @@ export default class Prompt {
4857
this._render = render.bind(this);
4958
this._track = trackValue;
5059
this._abortSignal = signal;
60+
this._submitKey = options.submitKey ?? { name: 'return' };
5161

5262
this.input = input;
5363
this.output = output;
@@ -202,8 +212,9 @@ export default class Prompt {
202212
if (char) {
203213
this.emit('key', char.toLowerCase());
204214
}
215+
this.emit('rawKey', char, key);
205216

206-
if (key?.name === 'return') {
217+
if (isSameKey(key, this._submitKey)) {
207218
if (this.opts.validate) {
208219
const problem = this.opts.validate(this.value);
209220
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)