Skip to content

Commit 9b1ebc5

Browse files
authored
core rework/simplification (#308)
1 parent 74ffae6 commit 9b1ebc5

File tree

8 files changed

+308
-532
lines changed

8 files changed

+308
-532
lines changed
Lines changed: 159 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,193 @@
1+
import type { Key } from 'node:readline';
2+
import color from 'picocolors';
13
import Prompt, { type PromptOptions } from './prompt.js';
24

3-
export interface AutocompleteOptions<T extends { value: any }>
5+
interface OptionLike {
6+
value: unknown;
7+
label?: string;
8+
}
9+
10+
type FilterFunction<T extends OptionLike> = (search: string, opt: T) => boolean;
11+
12+
function getCursorForValue<T extends OptionLike>(
13+
selected: T['value'] | undefined,
14+
items: T[]
15+
): number {
16+
if (selected === undefined) {
17+
return 0;
18+
}
19+
20+
const currLength = items.length;
21+
22+
// If filtering changed the available options, update cursor
23+
if (currLength === 0) {
24+
return 0;
25+
}
26+
27+
// Try to maintain the same selected item
28+
const index = items.findIndex((item) => item.value === selected);
29+
return index !== -1 ? index : 0;
30+
}
31+
32+
function defaultFilter<T extends OptionLike>(input: string, option: T): boolean {
33+
const label = option.label ?? String(option.value);
34+
return label.toLowerCase().includes(input.toLowerCase());
35+
}
36+
37+
function normalisedValue<T>(multiple: boolean, values: T[] | undefined): T | T[] | undefined {
38+
if (!values) {
39+
return undefined;
40+
}
41+
if (multiple) {
42+
return values;
43+
}
44+
return values[0];
45+
}
46+
47+
export interface AutocompleteOptions<T extends OptionLike>
448
extends PromptOptions<AutocompletePrompt<T>> {
549
options: T[];
6-
initialValue?: T['value'];
7-
maxItems?: number;
8-
filterFn?: (input: string, option: T) => boolean;
50+
filter?: FilterFunction<T>;
51+
multiple?: boolean;
952
}
1053

11-
export default class AutocompletePrompt<T extends { value: any; label?: string }> extends Prompt {
54+
export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
1255
options: T[];
1356
filteredOptions: T[];
14-
cursor = 0;
15-
maxItems: number;
16-
filterFn: (input: string, option: T) => boolean;
17-
isNavigationMode = false; // Track if we're in navigation mode
18-
ignoreNextSpace = false; // Track if we should ignore the next space
19-
20-
private filterOptions() {
21-
const input = this.value?.toLowerCase() ?? '';
22-
// Remember the currently selected value before filtering
23-
const previousSelectedValue = this.filteredOptions[this.cursor]?.value;
24-
25-
// Filter options based on the current input
26-
this.filteredOptions = input
27-
? this.options.filter((option) => this.filterFn(input, option))
28-
: this.options;
29-
30-
// Reset cursor to 0 by default when filtering changes
31-
this.cursor = 0;
32-
33-
// Try to maintain the previously selected item if it still exists in filtered results
34-
if (previousSelectedValue !== undefined && this.filteredOptions.length > 0) {
35-
const newIndex = this.filteredOptions.findIndex((opt) => opt.value === previousSelectedValue);
36-
if (newIndex !== -1) {
37-
// Found the same item in new filtered results, keep it selected
38-
this.cursor = newIndex;
39-
}
40-
}
57+
multiple: boolean;
58+
isNavigating = false;
59+
selectedValues: Array<T['value']> = [];
60+
61+
#focusedValue: T['value'] | undefined;
62+
#cursor = 0;
63+
#lastValue: T['value'] | undefined;
64+
#filterFn: FilterFunction<T>;
65+
66+
get cursor(): number {
67+
return this.#cursor;
4168
}
4269

43-
// Store both the search input and the selected value
44-
public get selectedValue(): T['value'] | undefined {
45-
return this.filteredOptions[this.cursor]?.value;
70+
get valueWithCursor() {
71+
if (!this.value) {
72+
return color.inverse(color.hidden('_'));
73+
}
74+
if (this._cursor >= this.value.length) {
75+
return `${this.value}█`;
76+
}
77+
const s1 = this.value.slice(0, this._cursor);
78+
const [s2, ...s3] = this.value.slice(this._cursor);
79+
return `${s1}${color.inverse(s2)}${s3.join('')}`;
4680
}
4781

4882
constructor(opts: AutocompleteOptions<T>) {
49-
super(opts, true);
83+
super(opts);
5084

5185
this.options = opts.options;
5286
this.filteredOptions = [...this.options];
53-
this.maxItems = opts.maxItems ?? 10;
54-
this.filterFn = opts.filterFn ?? this.defaultFilterFn;
55-
56-
// Set initial value if provided
57-
if (opts.initialValue !== undefined) {
58-
const initialIndex = this.options.findIndex(({ value }) => value === opts.initialValue);
59-
if (initialIndex !== -1) {
60-
this.cursor = initialIndex;
87+
this.multiple = opts.multiple === true;
88+
this._usePlaceholderAsValue = false;
89+
this.#filterFn = opts.filter ?? defaultFilter;
90+
let initialValues: unknown[] | undefined;
91+
if (opts.initialValue && Array.isArray(opts.initialValue)) {
92+
if (this.multiple) {
93+
initialValues = opts.initialValue;
94+
} else {
95+
initialValues = opts.initialValue.slice(0, 1);
6196
}
6297
}
6398

64-
// Handle keyboard key presses
65-
this.on('key', (key) => {
66-
// Enter navigation mode with arrow keys
67-
if (key === 'up' || key === 'down') {
68-
this.isNavigationMode = true;
99+
if (initialValues) {
100+
this.selectedValues = initialValues;
101+
for (const selectedValue of initialValues) {
102+
const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue);
103+
if (selectedIndex !== -1) {
104+
this.toggleSelected(selectedValue);
105+
this.#cursor = selectedIndex;
106+
this.#focusedValue = this.options[this.#cursor]?.value;
107+
}
69108
}
109+
}
70110

71-
// Space key in navigation mode should be ignored for input
72-
if (key === ' ' && this.isNavigationMode) {
73-
this.ignoreNextSpace = true;
74-
return false; // Prevent propagation
111+
this.on('finalize', () => {
112+
if (!this.value) {
113+
this.value = normalisedValue(this.multiple, initialValues);
75114
}
76115

77-
// Exit navigation mode with non-navigation keys
78-
if (key !== 'up' && key !== 'down' && key !== 'return') {
79-
this.isNavigationMode = false;
116+
if (this.state === 'submit') {
117+
this.value = normalisedValue(this.multiple, this.selectedValues);
80118
}
81119
});
82120

83-
// Handle cursor movement
84-
this.on('cursor', (key) => {
85-
switch (key) {
86-
case 'up':
87-
this.isNavigationMode = true;
88-
this.cursor = this.cursor === 0 ? this.filteredOptions.length - 1 : this.cursor - 1;
89-
break;
90-
case 'down':
91-
this.isNavigationMode = true;
92-
this.cursor = this.cursor === this.filteredOptions.length - 1 ? 0 : this.cursor + 1;
93-
break;
94-
}
95-
});
121+
this.on('key', (char, key) => this.#onKey(char, key));
122+
this.on('value', (value) => this.#onValueChanged(value));
123+
}
96124

97-
// Update filtered options when input changes
98-
this.on('value', (value) => {
99-
// Check if we need to ignore a space
100-
if (this.ignoreNextSpace && value?.endsWith(' ')) {
101-
// Remove the space and reset the flag
102-
this.value = value.replace(/\s+$/, '');
103-
this.ignoreNextSpace = false;
104-
return;
105-
}
125+
protected override _isActionKey(char: string | undefined, key: Key): boolean {
126+
return (
127+
char === '\t' ||
128+
(this.multiple &&
129+
this.isNavigating &&
130+
key.name === 'space' &&
131+
char !== undefined &&
132+
char !== '')
133+
);
134+
}
106135

107-
// In navigation mode, strip out any spaces
108-
if (this.isNavigationMode && value?.includes(' ')) {
109-
this.value = value.replace(/\s+/g, '');
110-
return;
136+
#onKey(_char: string | undefined, key: Key): void {
137+
const isUpKey = key.name === 'up';
138+
const isDownKey = key.name === 'down';
139+
140+
// Start navigation mode with up/down arrows
141+
if (isUpKey || isDownKey) {
142+
this.#cursor = Math.max(
143+
0,
144+
Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1)
145+
);
146+
this.#focusedValue = this.filteredOptions[this.#cursor]?.value;
147+
if (!this.multiple) {
148+
this.selectedValues = [this.#focusedValue];
111149
}
150+
this.isNavigating = true;
151+
} else {
152+
if (
153+
this.multiple &&
154+
this.#focusedValue !== undefined &&
155+
(key.name === 'tab' || (this.isNavigating && key.name === 'space'))
156+
) {
157+
this.toggleSelected(this.#focusedValue);
158+
} else {
159+
this.isNavigating = false;
160+
}
161+
}
162+
}
112163

113-
// Normal filtering when not in navigation mode
114-
this.value = value;
115-
this.filterOptions();
116-
});
164+
toggleSelected(value: T['value']) {
165+
if (this.filteredOptions.length === 0) {
166+
return;
167+
}
168+
169+
if (this.multiple) {
170+
if (this.selectedValues.includes(value)) {
171+
this.selectedValues = this.selectedValues.filter((v) => v !== value);
172+
} else {
173+
this.selectedValues = [...this.selectedValues, value];
174+
}
175+
} else {
176+
this.selectedValues = [value];
177+
}
117178
}
118179

119-
// Default filtering function
120-
private defaultFilterFn(input: string, option: T): boolean {
121-
const label = option.label ?? String(option.value);
122-
return label.toLowerCase().includes(input.toLowerCase());
180+
#onValueChanged(value: string | undefined): void {
181+
if (value !== this.#lastValue) {
182+
this.#lastValue = value;
183+
184+
if (value) {
185+
this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt));
186+
} else {
187+
this.filteredOptions = [...this.options];
188+
}
189+
this.#cursor = getCursorForValue(this.#focusedValue, this.filteredOptions);
190+
this.#focusedValue = this.filteredOptions[this.#cursor]?.value;
191+
}
123192
}
124193
}

packages/core/src/prompts/password.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,27 @@ interface PasswordOptions extends PromptOptions<PasswordPrompt> {
55
mask?: string;
66
}
77
export default class PasswordPrompt extends Prompt {
8-
valueWithCursor = '';
98
private _mask = '•';
109
get cursor() {
1110
return this._cursor;
1211
}
1312
get masked() {
14-
return this.value.replaceAll(/./g, this._mask);
13+
return this.value?.replaceAll(/./g, this._mask) ?? '';
14+
}
15+
get valueWithCursor() {
16+
if (this.state === 'submit' || this.state === 'cancel') {
17+
return this.masked;
18+
}
19+
const value = this.value ?? '';
20+
if (this.cursor >= value.length) {
21+
return `${this.masked}${color.inverse(color.hidden('_'))}`;
22+
}
23+
const s1 = this.masked.slice(0, this.cursor);
24+
const s2 = this.masked.slice(this.cursor);
25+
return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`;
1526
}
1627
constructor({ mask, ...opts }: PasswordOptions) {
1728
super(opts);
1829
this._mask = mask ?? '•';
19-
20-
this.on('finalize', () => {
21-
this.valueWithCursor = this.masked;
22-
});
23-
this.on('value', () => {
24-
if (this.cursor >= this.value.length) {
25-
this.valueWithCursor = `${this.masked}${color.inverse(color.hidden('_'))}`;
26-
} else {
27-
const s1 = this.masked.slice(0, this.cursor);
28-
const s2 = this.masked.slice(this.cursor);
29-
this.valueWithCursor = `${s1}${color.inverse(s2[0])}${s2.slice(1)}`;
30-
}
31-
});
3230
}
3331
}

0 commit comments

Comments
 (0)