Skip to content

Commit

Permalink
feat(prompt): add auto suggestion support
Browse files Browse the repository at this point in the history
  • Loading branch information
c4spar committed Dec 28, 2020
1 parent c55d538 commit 7dd6660
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 38 deletions.
110 changes: 91 additions & 19 deletions prompt/_generic_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,41 @@ import {
GenericPromptOptions,
GenericPromptSettings,
} from "./_generic_prompt.ts";
import { stripColor, underline } from "./deps.ts";
import { dim, stripColor, underline } from "./deps.ts";

/** Input keys options. */
export interface GenericInputKeys {
moveCursorLeft?: string[];
moveCursorRight?: string[];
deleteCharLeft?: string[];
deleteCharRight?: string[];
complete?: string[];
selectNextHistory?: string[];
selectPreviousHistory?: string[];
submit?: string[];
}

/** Generic input prompt options. */
export interface GenericInputPromptOptions<T>
extends GenericPromptOptions<T, string> {
keys?: GenericInputKeys;
suggestions?: Array<string | number>;
}

/** Generic input prompt settings. */
export interface GenericInputPromptSettings<T>
extends GenericPromptSettings<T, string> {
keys?: GenericInputKeys;
suggestions?: Array<string | number>;
}

/** Generic input prompt representation. */
export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
extends GenericPrompt<T, string, S> {
protected inputValue = "";
protected inputIndex = 0;
protected suggestionsIndex = 0;
protected suggestions: Array<string | number> = [];

/**
* Inject prompt value. Can be used for unit tests or pre selections.
Expand All @@ -53,10 +60,17 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
moveCursorRight: ["right"],
deleteCharLeft: ["backspace"],
deleteCharRight: ["delete"],
complete: ["tab"],
selectNextHistory: ["up"],
selectPreviousHistory: ["down"],
submit: ["enter", "return"],
...(settings.keys ?? {}),
},
suggestions: settings.suggestions?.slice(),
});
this.settings.suggestions?.unshift(
this.settings.default ? this.format(this.settings.default) : "",
);
}

protected message(): string {
Expand All @@ -66,7 +80,38 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
}

protected input(): string {
return underline(this.inputValue);
return underline(this.inputValue) + dim(this.getSuggestion());
}

protected render(): Promise<void> {
this.match();
return super.render();
}

protected getSuggestion(needle: string = this.inputValue): string {
return this.suggestions[this.suggestionsIndex]?.toString()
.substr(
needle.length,
) ?? "";
}

protected match(needle: string = this.inputValue): void {
if (!this.settings.suggestions?.length) {
return;
}
this.suggestions = this.settings.suggestions
.filter((value: string | number) =>
value.toString().toLowerCase().startsWith(needle.toLowerCase())
);
this.suggestionsIndex = Math.min(
this.suggestions.length - 1,
Math.max(0, this.suggestionsIndex),
);
}

/** Get user input. */
protected getValue(): string {
return this.inputValue;
}

/**
Expand All @@ -85,18 +130,16 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
}
break;

// case "up": // scroll history?
// break;
//
// case "down": // scroll history?
// break;

case this.isKey(this.settings.keys, "moveCursorLeft", event):
this.moveCursorLeft();
break;

case this.isKey(this.settings.keys, "moveCursorRight", event):
this.moveCursorRight();
if (this.inputIndex < this.inputValue.length) {
this.moveCursorRight();
} else {
this.complete();
}
break;

case this.isKey(this.settings.keys, "deleteCharRight", event):
Expand All @@ -107,6 +150,18 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
this.deleteChar();
break;

case this.isKey(this.settings.keys, "selectNextHistory", event):
this.selectNextSuggestion();
break;

case this.isKey(this.settings.keys, "selectPreviousHistory", event):
this.selectPreviousSuggestion();
break;

case this.isKey(this.settings.keys, "complete", event):
this.complete();
break;

case this.isKey(this.settings.keys, "submit", event):
await this.submit();
break;
Expand All @@ -118,10 +173,7 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
}
}

/**
* Add character to current input.
* @param char Char to add.
*/
/** Add character to current input. */
protected addChar(char: string): void {
this.inputValue = this.inputValue.slice(0, this.inputIndex) + char +
this.inputValue.slice(this.inputIndex);
Expand All @@ -146,9 +198,8 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
protected deleteChar(): void {
if (this.inputIndex > 0) {
this.inputIndex--;
this.tty.cursorBackward(1);
this.inputValue = this.inputValue.slice(0, this.inputIndex) +
this.inputValue.slice(this.inputIndex + 1);
this.tty.cursorBackward();
this.deleteCharRight();
}
}

Expand All @@ -160,8 +211,29 @@ export abstract class GenericInput<T, S extends GenericInputPromptSettings<T>>
}
}

/** Get input input. */
protected getValue(): string {
return this.inputValue;
protected complete(): void {
if (this.suggestions.length) {
this.inputValue += this.getSuggestion();
this.inputIndex = this.inputValue.length;
this.suggestionsIndex = 0;
}
}

/** Select previous suggestion. */
protected selectPreviousSuggestion(): void {
if (this.settings.suggestions?.length) {
if (this.suggestionsIndex > 0) {
this.suggestionsIndex--;
}
}
}

/** Select next suggestion. */
protected selectNextSuggestion(): void {
if (this.settings.suggestions?.length) {
if (this.suggestionsIndex < this.suggestions.length - 1) {
this.suggestionsIndex++;
}
}
}
}
27 changes: 14 additions & 13 deletions prompt/_generic_prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ export abstract class GenericPrompt<
protected async read(): Promise<boolean> {
if (typeof GenericPrompt.injectedValue !== "undefined") {
const value: V = GenericPrompt.injectedValue as V;
return this.#validateValue(value);
await this.#validateValue(value);
return true;
}

const events: KeyEvent[] = await this.#readKey();
Expand All @@ -164,7 +165,7 @@ export abstract class GenericPrompt<
return typeof this.#value !== "undefined";
}

protected submit(): Promise<boolean> {
protected submit(): Promise<void> {
return this.#validateValue(this.getValue());
}

Expand Down Expand Up @@ -269,16 +270,19 @@ export abstract class GenericPrompt<
};

/**
* Map input value to output value. If a default value is set, the default
* will be used as value without any validation. If a custom validation
* handler ist set, the custom handler will be executed, otherwise the default
* validation handler from the prompt will be executed.
* @param value
* Validate input value. Set error message if validation fails and transform
* output value on success.
* If a default value is set, the default will be used as value without any
* validation.
* If a custom validation handler ist set, the custom handler will
* be executed, otherwise a prompt specific default validation handler will be
* executed.
* @param value The value to validate.
*/
#validateValue = async (value: V): Promise<boolean> => {
#validateValue = async (value: V): Promise<void> => {
if (!value && typeof this.settings.default !== "undefined") {
this.#value = this.settings.default;
return true;
return;
}

this.#value = undefined;
Expand All @@ -296,8 +300,6 @@ export abstract class GenericPrompt<
} else {
this.#value = this.#transformValue(value);
}

return typeof this.#value !== "undefined";
};

/**
Expand All @@ -306,8 +308,7 @@ export abstract class GenericPrompt<
* @param name Key name.
* @param event Key event.
*/
// deno-lint-ignore no-explicit-any
protected isKey<K extends any, N extends keyof K>(
protected isKey<K extends unknown, N extends keyof K>(
keys: K | undefined,
name: N,
event: KeyEvent,
Expand Down
7 changes: 6 additions & 1 deletion prompt/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
export type ConfirmKeys = GenericInputKeys;

/** Confirm prompt options. */
export interface ConfirmOptions extends GenericInputPromptOptions<boolean> {
export interface ConfirmOptions
extends Omit<GenericInputPromptOptions<boolean>, "suggestions"> {
active?: string;
inactive?: string;
keys?: ConfirmKeys;
Expand Down Expand Up @@ -38,6 +39,10 @@ export class Confirm extends GenericInput<boolean, ConfirmSettings> {
inactive: "No",
pointer: blue(Figures.POINTER_SMALL),
...options,
suggestions: [
options.active ?? "Yes",
options.inactive ?? "No",
],
}).prompt();
}

Expand Down
17 changes: 15 additions & 2 deletions prompt/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { blue, stripColor, underline } from "./deps.ts";
import { blue, dim, stripColor, underline } from "./deps.ts";
import { Figures } from "./figures.ts";
import {
GenericInput,
Expand Down Expand Up @@ -62,7 +62,8 @@ export class List extends GenericInput<string[], ListSettings> {

return oldInputParts
.map((val: string) => underline(val))
.join(separator);
.join(separator) +
dim(this.getSuggestion());
}

/** Create list regex.*/
Expand All @@ -72,6 +73,18 @@ export class List extends GenericInput<string[], ListSettings> {
);
}

protected getSuggestion(
needle: string = this.inputValue.split(this.regexp()).pop() as string,
): string {
return super.getSuggestion(needle);
}

protected match(
needle: string = this.inputValue.split(this.regexp()).pop() as string,
): void {
super.match(needle);
}

/** Add char. */
protected addChar(char: string): void {
switch (char) {
Expand Down
20 changes: 19 additions & 1 deletion prompt/number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export class Number extends GenericInput<number, NumberSettings> {
}
break;

case this.settings.suggestions &&
this.isKey(this.settings.keys, "selectNextHistory", event):
this.selectNextSuggestion();
break;

case this.settings.suggestions &&
this.isKey(this.settings.keys, "selectPreviousHistory", event):
this.selectPreviousSuggestion();
break;

case this.isKey(this.settings.keys, "increaseValue", event):
this.increaseValue();
break;
Expand All @@ -88,7 +98,11 @@ export class Number extends GenericInput<number, NumberSettings> {
break;

case this.isKey(this.settings.keys, "moveCursorRight", event):
this.moveCursorRight();
if (this.inputIndex < this.input.length) {
this.moveCursorRight();
} else {
this.complete();
}
break;

case this.isKey(this.settings.keys, "deleteCharRight", event):
Expand All @@ -99,6 +113,10 @@ export class Number extends GenericInput<number, NumberSettings> {
this.deleteChar();
break;

case this.isKey(this.settings.keys, "complete", event):
this.complete();
break;

case this.isKey(this.settings.keys, "submit", event):
await this.submit();
break;
Expand Down
6 changes: 4 additions & 2 deletions prompt/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
export type SecretKeys = GenericInputKeys;

/** Secret prompt options. */
export interface SecretOptions extends GenericInputPromptOptions<string> {
export interface SecretOptions
extends Omit<GenericInputPromptOptions<string>, "suggestions"> {
label?: string;
hidden?: boolean;
minLength?: number;
Expand All @@ -28,7 +29,8 @@ export interface SecretOptions extends GenericInputPromptOptions<string> {
}

/** Secret prompt settings. */
interface SecretSettings extends GenericInputPromptSettings<string> {
interface SecretSettings
extends Omit<GenericInputPromptSettings<string>, "suggestions"> {
label: string;
hidden: boolean;
minLength: number;
Expand Down

0 comments on commit 7dd6660

Please sign in to comment.