Skip to content

Commit 814ab9a

Browse files
MacFJA43081j
andauthored
feat(@clack/core,@clack/prompts): Multiline text input (#240)
Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
1 parent aa488fc commit 814ab9a

14 files changed

Lines changed: 1820 additions & 4 deletions

File tree

.changeset/ninety-seals-teach.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": minor
3+
"@clack/core": minor
4+
---
5+
6+
Add new multiline prompt for multi-line text input.

examples/changesets/index.ts

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export type { DateFormat, DateOptions, DateParts } from './prompts/date.js';
66
export { default as DatePrompt } from './prompts/date.js';
77
export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js';
88
export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js';
9+
export type { MultiLineOptions } from './prompts/multi-line.js';
10+
export { default as MultiLinePrompt } from './prompts/multi-line.js';
911
export type { MultiSelectOptions } from './prompts/multi-select.js';
1012
export { default as MultiSelectPrompt } from './prompts/multi-select.js';
1113
export type { PasswordOptions } from './prompts/password.js';
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { Key } from 'node:readline';
2+
import { styleText } from 'node:util';
3+
import { findTextCursor } from '../utils/cursor.js';
4+
import { type Action, settings } from '../utils/index.js';
5+
import Prompt, { type PromptOptions } from './prompt.js';
6+
7+
export interface MultiLineOptions extends PromptOptions<string, MultiLinePrompt> {
8+
placeholder?: string;
9+
defaultValue?: string;
10+
showSubmit?: boolean;
11+
}
12+
13+
export default class MultiLinePrompt extends Prompt<string> {
14+
#lastKeyWasReturn = false;
15+
#showSubmit: boolean;
16+
public focused: 'editor' | 'submit' = 'editor';
17+
18+
get userInputWithCursor() {
19+
if (this.state === 'submit') {
20+
return this.userInput;
21+
}
22+
const userInput = this.userInput;
23+
if (this.cursor >= userInput.length) {
24+
return `${userInput}█`;
25+
}
26+
const s1 = userInput.slice(0, this.cursor);
27+
const s2 = userInput[this.cursor];
28+
const s3 = userInput.slice(this.cursor + 1);
29+
if (s2 === '\n') return `${s1}█\n${s3}`;
30+
return `${s1}${styleText('inverse', s2)}${s3}`;
31+
}
32+
get cursor() {
33+
return this._cursor;
34+
}
35+
#insertAtCursor(char: string) {
36+
if (this.userInput.length === 0) {
37+
this._setUserInput(char);
38+
return;
39+
}
40+
this._setUserInput(
41+
this.userInput.slice(0, this.cursor) + char + this.userInput.slice(this.cursor)
42+
);
43+
}
44+
#handleCursor(key?: Action) {
45+
const text = this.value ?? '';
46+
switch (key) {
47+
case 'up':
48+
this._cursor = findTextCursor(this._cursor, 0, -1, text);
49+
return;
50+
case 'down':
51+
this._cursor = findTextCursor(this._cursor, 0, 1, text);
52+
return;
53+
case 'left':
54+
this._cursor = findTextCursor(this._cursor, -1, 0, text);
55+
return;
56+
case 'right':
57+
this._cursor = findTextCursor(this._cursor, 1, 0, text);
58+
return;
59+
}
60+
}
61+
62+
protected override _shouldSubmit(_char: string | undefined, _key: Key): boolean {
63+
if (this.#showSubmit) {
64+
if (this.focused === 'submit') {
65+
return true;
66+
}
67+
this.#insertAtCursor('\n');
68+
this._cursor++;
69+
return false;
70+
}
71+
const wasReturn = this.#lastKeyWasReturn;
72+
this.#lastKeyWasReturn = true;
73+
if (wasReturn) {
74+
if (this.userInput[this.cursor - 1] === '\n') {
75+
this._setUserInput(
76+
this.userInput.slice(0, this.cursor - 1) + this.userInput.slice(this.cursor)
77+
);
78+
this._cursor--;
79+
}
80+
return true;
81+
}
82+
this.#insertAtCursor('\n');
83+
this._cursor++;
84+
return false;
85+
}
86+
87+
constructor(opts: MultiLineOptions) {
88+
super(opts, false);
89+
this.#showSubmit = opts.showSubmit ?? false;
90+
91+
this.on('key', (char, key) => {
92+
if (key?.name && settings.actions.has(key.name as Action)) {
93+
this.#handleCursor(key.name as Action);
94+
return;
95+
}
96+
if (char === '\t' && this.#showSubmit) {
97+
this.focused = this.focused === 'editor' ? 'submit' : 'editor';
98+
return;
99+
}
100+
if (key?.name === 'return') {
101+
return;
102+
}
103+
this.#lastKeyWasReturn = false;
104+
if (key?.name === 'backspace' && this.cursor > 0) {
105+
this._setUserInput(
106+
this.userInput.slice(0, this.cursor - 1) + this.userInput.slice(this.cursor)
107+
);
108+
this._cursor--;
109+
return;
110+
}
111+
if (key?.name === 'delete' && this.cursor < this.userInput.length) {
112+
this._setUserInput(
113+
this.userInput.slice(0, this.cursor) + this.userInput.slice(this.cursor + 1)
114+
);
115+
return;
116+
}
117+
if (char) {
118+
if (this.#showSubmit && this.focused === 'submit') {
119+
this.focused = 'editor';
120+
}
121+
this.#insertAtCursor(char ?? '');
122+
this._cursor++;
123+
}
124+
});
125+
126+
this.on('userInput', (input) => {
127+
this._setValue(input);
128+
});
129+
this.on('finalize', () => {
130+
if (!this.value) {
131+
this.value = opts.defaultValue;
132+
}
133+
if (this.value === undefined) {
134+
this.value = '';
135+
}
136+
});
137+
}
138+
}

packages/core/src/prompts/prompt.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export default class Prompt<TValue> {
179179
return char === '\t';
180180
}
181181

182+
protected _shouldSubmit(_char: string | undefined, _key: Key): boolean {
183+
return true;
184+
}
185+
182186
protected _setValue(value: TValue | undefined): void {
183187
this.value = value;
184188
this.emit('value', this.value);
@@ -225,7 +229,7 @@ export default class Prompt<TValue> {
225229
// Call the key event handler and emit the key event
226230
this.emit('key', char?.toLowerCase(), key);
227231

228-
if (key?.name === 'return') {
232+
if (key?.name === 'return' && this._shouldSubmit(char, key)) {
229233
if (this.opts.validate) {
230234
const problem = this.opts.validate(this.value);
231235
if (problem) {

packages/core/src/utils/cursor.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,41 @@ export function findCursor<T extends { disabled?: boolean }>(
1616
}
1717
return clampedCursor;
1818
}
19+
20+
export function findTextCursor(
21+
cursor: number,
22+
deltaX: number,
23+
deltaY: number,
24+
value: string
25+
): number {
26+
const lines = value.split('\n');
27+
let cursorY = 0;
28+
let cursorX = cursor;
29+
30+
for (const line of lines) {
31+
if (cursorX <= line.length) {
32+
break;
33+
}
34+
cursorX -= line.length + 1;
35+
cursorY++;
36+
}
37+
38+
cursorY = Math.max(0, Math.min(lines.length - 1, cursorY + deltaY));
39+
40+
cursorX = Math.min(cursorX, lines[cursorY].length) + deltaX;
41+
while (cursorX < 0 && cursorY > 0) {
42+
cursorY--;
43+
cursorX += lines[cursorY].length + 1;
44+
}
45+
while (cursorX > lines[cursorY].length && cursorY < lines.length - 1) {
46+
cursorX -= lines[cursorY].length + 1;
47+
cursorY++;
48+
}
49+
cursorX = Math.max(0, Math.min(lines[cursorY].length, cursorX));
50+
51+
let newCursor = 0;
52+
for (let i = 0; i < cursorY; i++) {
53+
newCursor += lines[i].length + 1;
54+
}
55+
return newCursor + cursorX;
56+
}

packages/core/src/utils/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ export function wrapTextWithPrefix(
102102
output: Writable | undefined,
103103
text: string,
104104
prefix: string,
105-
startPrefix: string = prefix
105+
startPrefix: string = prefix,
106+
lineFormatter?: (line: string, index: number) => string
106107
): string {
107108
const columns = getColumns(output ?? stdout);
108109
const wrapped = wrapAnsi(text, columns - prefix.length, {
@@ -112,7 +113,8 @@ export function wrapTextWithPrefix(
112113
const lines = wrapped
113114
.split('\n')
114115
.map((line, index) => {
115-
return `${index === 0 ? startPrefix : prefix}${line}`;
116+
const lineString = lineFormatter ? lineFormatter(line, index) : line;
117+
return `${index === 0 ? startPrefix : prefix}${lineString}`;
116118
})
117119
.join('\n');
118120
return lines;

0 commit comments

Comments
 (0)