'use strict'; const Events = require('events'); const colors = require('ansi-colors'); const keypress = require('./keypress'); const timer = require('./timer'); const State = require('./state'); const theme = require('./theme'); const utils = require('./utils'); const ansi = require('./ansi'); /** * Base class for creating a new Prompt. * @param {Object} `options` Question object. */ class Prompt extends Events { constructor(options = {}) { super(); this.name = options.name; this.type = options.type; this.options = options; theme(this); timer(this); this.state = new State(this); this.initial = [options.initial, options.default].find(v => v != null); this.stdout = options.stdout || process.stdout; this.stdin = options.stdin || process.stdin; this.scale = options.scale || 1; this.term = this.options.term || process.env.TERM_PROGRAM; this.margin = margin(this.options.margin); this.setMaxListeners(0); setOptions(this); } async keypress(input, event = {}) { this.keypressed = true; let key = keypress.action(input, keypress(input, event), this.options.actions); this.state.keypress = key; this.emit('keypress', input, key); this.emit('state', this.state.clone()); let fn = this.options[key.action] || this[key.action] || this.dispatch; if (typeof fn === 'function') { return fn.call(this, input, key); } this.alert(); } alert() { delete this.state.alert; if (this.options.show === false) { this.emit('alert'); } else { this.stdout.write(ansi.code.beep); } } cursorHide() { this.stdout.write(ansi.cursor.hide()); utils.onExit(() => this.cursorShow()); } cursorShow() { this.stdout.write(ansi.cursor.show()); } write(str) { if (!str) return; if (this.stdout && this.state.show !== false) { this.stdout.write(str); } this.state.buffer += str; } clear(lines = 0) { let buffer = this.state.buffer; this.state.buffer = ''; if ((!buffer && !lines) || this.options.show === false) return; this.stdout.write(ansi.cursor.down(lines) + ansi.clear(buffer, this.width)); } restore() { if (this.state.closed || this.options.show === false) return; let { prompt, after, rest } = this.sections(); let { cursor, initial = '', input = '', value = '' } = this; let size = this.state.size = rest.length; let state = { after, cursor, initial, input, prompt, size, value }; let codes = ansi.cursor.restore(state); if (codes) { this.stdout.write(codes); } } sections() { let { buffer, input, prompt } = this.state; prompt = colors.unstyle(prompt); let buf = colors.unstyle(buffer); let idx = buf.indexOf(prompt); let header = buf.slice(0, idx); let rest = buf.slice(idx); let lines = rest.split('\n'); let first = lines[0]; let last = lines[lines.length - 1]; let promptLine = prompt + (input ? ' ' + input : ''); let len = promptLine.length; let after = len < first.length ? first.slice(len + 1) : ''; return { header, prompt: first, after, rest: lines.slice(1), last }; } async submit() { this.state.submitted = true; this.state.validating = true; // this will only be called when the prompt is directly submitted // without initializing, i.e. when the prompt is skipped, etc. Otherwize, // "options.onSubmit" is will be handled by the "initialize()" method. if (this.options.onSubmit) { await this.options.onSubmit.call(this, this.name, this.value, this); } let result = this.state.error || await this.validate(this.value, this.state); if (result !== true) { let error = '\n' + this.symbols.pointer + ' '; if (typeof result === 'string') { error += result.trim(); } else { error += 'Invalid input'; } this.state.error = '\n' + this.styles.danger(error); this.state.submitted = false; await this.render(); await this.alert(); this.state.validating = false; this.state.error = void 0; return; } this.state.validating = false; await this.render(); await this.close(); this.value = await this.result(this.value); this.emit('submit', this.value); } async cancel(err) { this.state.cancelled = this.state.submitted = true; await this.render(); await this.close(); if (typeof this.options.onCancel === 'function') { await this.options.onCancel.call(this, this.name, this.value, this); } this.emit('cancel', await this.error(err)); } async close() { this.state.closed = true; try { let sections = this.sections(); let lines = Math.ceil(sections.prompt.length / this.width); if (sections.rest) { this.write(ansi.cursor.down(sections.rest.length)); } this.write('\n'.repeat(lines)); } catch (err) { /* do nothing */ } this.emit('close'); } start() { if (!this.stop && this.options.show !== false) { this.stop = keypress.listen(this, this.keypress.bind(this)); this.once('close', this.stop); } } async skip() { this.skipped = this.options.skip === true; if (typeof this.options.skip === 'function') { this.skipped = await this.options.skip.call(this, this.name, this.value); } return this.skipped; } async initialize() { let { format, options, result } = this; this.format = () => format.call(this, this.value); this.result = () => result.call(this, this.value); if (typeof options.initial === 'function') { this.initial = await options.initial.call(this, this); } if (typeof options.onRun === 'function') { await options.onRun.call(this, this); } // if "options.onSubmit" is defined, we wrap the "submit" method to guarantee // that "onSubmit" will always called first thing inside the submit // method, regardless of how it's handled in inheriting prompts. if (typeof options.onSubmit === 'function') { let onSubmit = options.onSubmit.bind(this); let submit = this.submit.bind(this); delete this.options.onSubmit; this.submit = async() => { await onSubmit(this.name, this.value, this); return submit(); }; } await this.start(); await this.render(); } render() { throw new Error('expected prompt to have a custom render method'); } run() { return new Promise(async(resolve, reject) => { this.once('submit', resolve); this.once('cancel', reject); if (await this.skip()) { this.render = () => {}; return this.submit(); } await this.initialize(); this.emit('run'); }); } async element(name, choice, i) { let { options, state, symbols, timers } = this; let timer = timers && timers[name]; state.timer = timer; let value = options[name] || state[name] || symbols[name]; let val = choice && choice[name] != null ? choice[name] : await value; if (val === '') return val; let res = await this.resolve(val, state, choice, i); if (!res && choice && choice[name]) { return this.resolve(value, state, choice, i); } return res; } async prefix() { let element = await this.element('prefix') || this.symbols; let timer = this.timers && this.timers.prefix; let state = this.state; state.timer = timer; if (utils.isObject(element)) element = element[state.status] || element.pending; if (!utils.hasColor(element)) { let style = this.styles[state.status] || this.styles.pending; return style(element); } return element; } async message() { let message = await this.element('message'); if (!utils.hasColor(message)) { return this.styles.strong(message); } return message; } async separator() { let element = await this.element('separator') || this.symbols; let timer = this.timers && this.timers.separator; let state = this.state; state.timer = timer; let value = element[state.status] || element.pending || state.separator; let ele = await this.resolve(value, state); if (utils.isObject(ele)) ele = ele[state.status] || ele.pending; if (!utils.hasColor(ele)) { return this.styles.muted(ele); } return ele; } async pointer(choice, i) { let val = await this.element('pointer', choice, i); if (typeof val === 'string' && utils.hasColor(val)) { return val; } if (val) { let styles = this.styles; let focused = this.index === i; let style = focused ? styles.primary : val => val; let ele = await this.resolve(val[focused ? 'on' : 'off'] || val, this.state); let styled = !utils.hasColor(ele) ? style(ele) : ele; return focused ? styled : ' '.repeat(ele.length); } } async indicator(choice, i) { let val = await this.element('indicator', choice, i); if (typeof val === 'string' && utils.hasColor(val)) { return val; } if (val) { let styles = this.styles; let enabled = choice.enabled === true; let style = enabled ? styles.success : styles.dark; let ele = val[enabled ? 'on' : 'off'] || val; return !utils.hasColor(ele) ? style(ele) : ele; } return ''; } body() { return null; } footer() { if (this.state.status === 'pending') { return this.element('footer'); } } header() { if (this.state.status === 'pending') { return this.element('header'); } } async hint() { if (this.state.status === 'pending' && !this.isValue(this.state.input)) { let hint = await this.element('hint'); if (!utils.hasColor(hint)) { return this.styles.muted(hint); } return hint; } } error(err) { return !this.state.submitted ? (err || this.state.error) : ''; } format(value) { return value; } result(value) { return value; } validate(value) { if (this.options.required === true) { return this.isValue(value); } return true; } isValue(value) { return value != null && value !== ''; } resolve(value, ...args) { return utils.resolve(this, value, ...args); } get base() { return Prompt.prototype; } get style() { return this.styles[this.state.status]; } get height() { return this.options.rows || utils.height(this.stdout, 25); } get width() { return this.options.columns || utils.width(this.stdout, 80); } get size() { return { width: this.width, height: this.height }; } set cursor(value) { this.state.cursor = value; } get cursor() { return this.state.cursor; } set input(value) { this.state.input = value; } get input() { return this.state.input; } set value(value) { this.state.value = value; } get value() { let { input, value } = this.state; let result = [value, input].find(this.isValue.bind(this)); return this.isValue(result) ? result : this.initial; } static get prompt() { return options => new this(options).run(); } } function setOptions(prompt) { let isValidKey = key => { return prompt[key] === void 0 || typeof prompt[key] === 'function'; }; let ignore = [ 'actions', 'choices', 'initial', 'margin', 'roles', 'styles', 'symbols', 'theme', 'timers', 'value' ]; let ignoreFn = [ 'body', 'footer', 'error', 'header', 'hint', 'indicator', 'message', 'prefix', 'separator', 'skip' ]; for (let key of Object.keys(prompt.options)) { if (ignore.includes(key)) continue; if (/^on[A-Z]/.test(key)) continue; let option = prompt.options[key]; if (typeof option === 'function' && isValidKey(key)) { if (!ignoreFn.includes(key)) { prompt[key] = option.bind(prompt); } } else if (typeof prompt[key] !== 'function') { prompt[key] = option; } } } function margin(value) { if (typeof value === 'number') { value = [value, value, value, value]; } let arr = [].concat(value || []); let pad = i => i % 2 === 0 ? '\n' : ' '; let res = []; for (let i = 0; i < 4; i++) { let char = pad(i); if (arr[i]) { res.push(char.repeat(arr[i])); } else { res.push(''); } } return res; } module.exports = Prompt;