import * as chalk from 'chalk'; import {stringWidth, terminal, truncateString} from 'terminal-kit'; export interface MenuOptions { layout?: 'grid' | 'row'; clearAfter?: boolean; } export interface MenuChoice { text: string; id?: string | number; } interface Layout { x: number; y: number; index: number; text: string; } export class TerminalMenu { public static ask(question: string, choices: MenuChoice[] | string[], options?: MenuOptions): Promise { return new Promise(async (resolve, _reject) => { await new TerminalMenu(question, choices, options || {}, (sel) => resolve(sel)).start(); }); } private choices: MenuChoice[]; private y!: number; private layout!: Layout[][]; // represented as a grid[x][y] private questionHeight: number = 1; private selected: number = 0; private scrollOffset: number = 0; constructor( private question: string, choices: MenuChoice[] | string[], private options: MenuOptions, private onSelect: (selection: MenuChoice) => void ) { if (choices.length > 0 && typeof choices[0] === 'string') { this.choices = (choices as string[]).map((text, id) => ({text, id})); } else { this.choices = choices as MenuChoice[]; } } 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.layoutChoices(); this.render(); } private end() { terminal.grabInput(false); terminal.off('key', this.onKeyPress); terminal.off('resize', this.onResize); if (this.options.clearAfter === false) { const {y} = this.getCoordinates(this.choices.length - 1); terminal.moveTo(1, y).nextLine(1).eraseDisplayBelow(); } else { terminal.moveTo(1, this.y).eraseDisplayBelow(); } } private get menuTop() { return this.y + this.questionHeight; } private scrollSelectionIntoView() { const previous = this.scrollOffset; let {y} = this.getCoordinates(this.selected); while (y < this.menuTop) { y++; this.scrollOffset--; } while (y > terminal.height) { y--; this.scrollOffset++; } if (previous !== this.scrollOffset) { this.render(); } } private setSelection(index: number) { const previous = this.selected; this.selected = index; this.scrollSelectionIntoView(); this.rerender(previous); this.rerender(this.selected); this.cursorToSelected(); } private onResize = async () => { await this.layoutChoices(); this.render(); } private onKeyPress = (name: string, _matches: string[], _data: {code: string}) => { const {x, y} = this.getOffset(this.selected); switch (name) { case 'UP': if (y > 0) { this.setSelection(this.layout[x][y - 1].index); } break; case 'DOWN': if (y < this.layout[x].length - 1) { this.setSelection(this.layout[x][y + 1].index); } break; case 'LEFT': if (x > 0) { this.setSelection(this.layout[x - 1][y].index); } break; case 'RIGHT': if (this.layout[x + 1] && this.layout[x + 1][y]) { this.setSelection(this.layout[x + 1][y].index); } break; case 'ENTER': case 'KP_ENTER': this.end(); this.onSelect(this.choices[this.selected]); break; } } private getOffset(index: number) { for (let x = 0; x < this.layout.length; x++) { for (let y = 0; y < this.layout[x].length; y++) { if (this.layout[x][y].index === index) { return {x, y}; } } } return {x: 0, y: 0}; } private getCoordinates(index: number) { const {x, y} = this.getOffset(index); const layout = this.layout[x][y]; return { x: layout.x, y: this.menuTop + layout.y - this.scrollOffset }; } private render() { terminal.moveTo(1, this.y).eraseDisplayBelow(); this.questionHeight = Math.floor(stringWidth(this.question) / terminal.width) + 1; terminal.moveTo(0, terminal.height); while (this.y > 1 && this.menuTop + this.layout[0].length > terminal.height) { this.y--; console.log(''); } terminal.moveTo(1, this.y).eraseDisplayBelow(); terminal(this.question); for (const layouts of this.layout) { for (const layout of layouts) { const y = this.menuTop + layout.y - this.scrollOffset; if (y >= this.menuTop && y <= terminal.height) { terminal.moveTo(layout.x, y); if (layout.index === this.selected) { terminal(chalk.bgWhite.black(` ${layout.text} `)); } else { terminal(` ${layout.text} `); } } } } this.cursorToSelected(); } private rerender(index: number) { const {x, y} = this.getOffset(index); const layout = this.layout[x][y]; terminal.moveTo(layout.x, this.menuTop + layout.y - this.scrollOffset); if (layout.index === this.selected) { terminal(chalk.bgWhite.black(` ${layout.text} `)); } else { terminal(` ${layout.text} `); } } private cursorToSelected() { const {x, y} = this.getCoordinates(this.selected); terminal.moveTo(x, y); } private async layoutChoices() { const width = terminal.width; const maxLength = this.choices.reduce((max, c) => Math.max(max, stringWidth(c.text)), 0); const perRow = this.options.layout === 'row' ? 1 : Math.floor(width / (maxLength + 2)); this.layout = []; this.choices.forEach((choice, index) => { const x = index % perRow; const y = Math.floor(index / perRow); if (!this.layout[x]) { this.layout[x] = []; } this.layout[x][y] = { x: 1 + (maxLength + 2) * x, y, index, text: stringWidth(choice.text) + 2 > width ? truncateString(choice.text, width - 3) + '…' : choice.text }; }); } }