import * as chalk from 'chalk'; import {stringWidth, terminal} from 'terminal-kit'; interface Coordinates { x: number; y: number; } export interface InputOptions { getHint?: (value: string) => string; validate?: (value: string) => boolean; clearAfter?: boolean; defaultValue?: string; clearOnInvalidInput?: boolean; } export class TerminalInput { public static ask(question: string, options?: InputOptions): Promise { return new Promise(async (resolve, _reject) => { // tslint:disable-next-line:no-unused-expression await new TerminalInput(question, options || {}, (value) => resolve(value)).start(); }); } private input = ''; private y = 0; private inputCoords!: Coordinates; private cursorOffset = 0; private hint = ''; private hintY = 0; private isValid = true; constructor( private question: string, private options: InputOptions, private onEnter: (value: string) => void, ) { this.hint = this.getHint(); if (this.options.validate) { this.isValid = this.options.validate(options.defaultValue || ''); } } private async start() { terminal.grabInput(true); terminal.on('key', this.onKeyPress); terminal.on('resize', this.onResize); this.y = (await (terminal as any).getCursorLocation()).y; await this.render(); this.placeCursor(); } private async end() { terminal.grabInput(false); terminal.off('key', this.onKeyPress); terminal.off('resize', this.onResize); if (this.options.clearAfter === false) { // use render to place cursor at end await this.render(); terminal('\n').eraseDisplayBelow(); } else { terminal.moveTo(1, this.y).eraseDisplayBelow(); } } private get defaultHint() { if (!this.input && this.options.defaultValue) { return `[Enter] to accept ${this.options.defaultValue}`; } return ''; } private getHint() { if (this.options.getHint) { return this.options.getHint(this.input || this.options.defaultValue || '') || this.defaultHint; } return this.defaultHint; } private async updateInput(input: string, resetHint: boolean = true) { if (this.input !== input) { this.input = input; let needsRerender = false; if (resetHint) { const hint = this.getHint(); if (hint !== this.hint) { this.hint = hint; needsRerender = true; } } if (this.options.validate) { const valid = this.options.validate(input || this.options.defaultValue || ''); if (valid !== this.isValid) { needsRerender = true; this.isValid = valid; } } if (needsRerender) { await this.render(); } else { await this.renderPartial(this.cursorOffset); } } } private spliceInput(index: number, del: number, insert: string = '') { return this.input.slice(0, index) + insert + this.input.slice(index + del); } private onKeyPress = async (key: string, _matches: string[], data: {isCharacter: boolean}) => { if (data.isCharacter) { await this.updateInput(this.spliceInput(this.cursorOffset, 0, key)); this.cursorOffset += stringWidth(key); this.placeCursor(); } else { switch (key) { case 'LEFT': this.cursorOffset = Math.max(0, this.cursorOffset - 1); this.placeCursor(); break; case 'RIGHT': this.cursorOffset = Math.min(stringWidth(this.input), this.cursorOffset + 1); this.placeCursor(); break; case 'HOME': this.cursorOffset = 0; this.placeCursor(); break; case 'END': this.cursorOffset = stringWidth(this.input); this.placeCursor(); break; case 'DELETE': await this.updateInput(this.spliceInput(this.cursorOffset, 1)); this.placeCursor(); break; case 'BACKSPACE': if (this.cursorOffset > 0) { this.cursorOffset--; await this.updateInput(this.spliceInput(this.cursorOffset, 1)); this.placeCursor(); } break; case 'ENTER': case 'KP_ENTER': if (this.isValid) { await this.end(); this.onEnter(this.input || this.options.defaultValue || ''); } else if (this.options.clearOnInvalidInput) { this.cursorOffset = 0; await this.updateInput('', false); this.placeCursor(); } break; case 'CTRL_D': case 'CTRL_C': terminal('\Bye...'); terminal.processExit(1); } } } private onResize = async () => { terminal.moveTo(1, this.y).eraseDisplayBelow(); await this.render(); this.placeCursor(); } private async render() { const hint = ' ' + this.hint; const question = this.question + ' '; // compute where we should be after rendering this.inputCoords = { x: 1 + stringWidth(question) % terminal.width, y: this.y + Math.floor((stringWidth(question) - 1) / terminal.width) }; this.hintY = this.y + Math.floor((stringWidth(question) + stringWidth(this.input) - 1) / terminal.width) + 1; let expectedY = this.hintY + Math.floor((stringWidth(hint) - 1) / terminal.width); const inputEndX = (this.inputCoords.x - 1 + stringWidth(this.input)) % terminal.width; // offset our start position to account for scrolling due to long input or hint while (expectedY > terminal.height) { terminal.moveTo(terminal.width, terminal.height); terminal('\n'); expectedY--; this.hintY--; this.inputCoords.y--; this.y--; } // render input and hint terminal.moveTo(terminal.width, this.y - 1).defaultColor('\n'); terminal(question + (this.isValid ? this.input : chalk.red(this.input))); if (inputEndX !== 0) { terminal.eraseLineAfter(); } terminal('\n').gray(hint).eraseDisplayBelow(); } private async renderPartial(offset: number) { const endY = this.inputCoords.y + Math.floor((stringWidth(this.input) + this.inputCoords.x - 1) / terminal.width) + 1; const endX = (this.inputCoords.x - 1 + stringWidth(this.input)) % terminal.width; if (endY >= this.hintY) { await this.render(); } else { const content = this.input.slice(offset); terminal((this.isValid ? content : chalk.red(content))); if (endX !== 0) { terminal.eraseLineAfter(); } } } private placeCursor() { if (this.inputCoords.x + this.cursorOffset < terminal.width) { terminal.moveTo(this.inputCoords.x + this.cursorOffset, this.inputCoords.y); } else { const x = 1 + (this.inputCoords.x - 1 + this.cursorOffset) % terminal.width; const y = this.inputCoords.y + Math.floor((this.inputCoords.x - 1 + this.cursorOffset) / terminal.width); terminal.moveTo(x, y); } } }