You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

485 lines
12 KiB

4 years ago
  1. 'use strict';
  2. const Events = require('events');
  3. const colors = require('ansi-colors');
  4. const keypress = require('./keypress');
  5. const timer = require('./timer');
  6. const State = require('./state');
  7. const theme = require('./theme');
  8. const utils = require('./utils');
  9. const ansi = require('./ansi');
  10. /**
  11. * Base class for creating a new Prompt.
  12. * @param {Object} `options` Question object.
  13. */
  14. class Prompt extends Events {
  15. constructor(options = {}) {
  16. super();
  17. this.name = options.name;
  18. this.type = options.type;
  19. this.options = options;
  20. theme(this);
  21. timer(this);
  22. this.state = new State(this);
  23. this.initial = [options.initial, options.default].find(v => v != null);
  24. this.stdout = options.stdout || process.stdout;
  25. this.stdin = options.stdin || process.stdin;
  26. this.scale = options.scale || 1;
  27. this.term = this.options.term || process.env.TERM_PROGRAM;
  28. this.margin = margin(this.options.margin);
  29. this.setMaxListeners(0);
  30. setOptions(this);
  31. }
  32. async keypress(input, event = {}) {
  33. this.keypressed = true;
  34. let key = keypress.action(input, keypress(input, event), this.options.actions);
  35. this.state.keypress = key;
  36. this.emit('keypress', input, key);
  37. this.emit('state', this.state.clone());
  38. let fn = this.options[key.action] || this[key.action] || this.dispatch;
  39. if (typeof fn === 'function') {
  40. return await fn.call(this, input, key);
  41. }
  42. this.alert();
  43. }
  44. alert() {
  45. delete this.state.alert;
  46. if (this.options.show === false) {
  47. this.emit('alert');
  48. } else {
  49. this.stdout.write(ansi.code.beep);
  50. }
  51. }
  52. cursorHide() {
  53. this.stdout.write(ansi.cursor.hide());
  54. utils.onExit(() => this.cursorShow());
  55. }
  56. cursorShow() {
  57. this.stdout.write(ansi.cursor.show());
  58. }
  59. write(str) {
  60. if (!str) return;
  61. if (this.stdout && this.state.show !== false) {
  62. this.stdout.write(str);
  63. }
  64. this.state.buffer += str;
  65. }
  66. clear(lines = 0) {
  67. let buffer = this.state.buffer;
  68. this.state.buffer = '';
  69. if ((!buffer && !lines) || this.options.show === false) return;
  70. this.stdout.write(ansi.cursor.down(lines) + ansi.clear(buffer, this.width));
  71. }
  72. restore() {
  73. if (this.state.closed || this.options.show === false) return;
  74. let { prompt, after, rest } = this.sections();
  75. let { cursor, initial = '', input = '', value = '' } = this;
  76. let size = this.state.size = rest.length;
  77. let state = { after, cursor, initial, input, prompt, size, value };
  78. let codes = ansi.cursor.restore(state);
  79. if (codes) {
  80. this.stdout.write(codes);
  81. }
  82. }
  83. sections() {
  84. let { buffer, input, prompt } = this.state;
  85. prompt = colors.unstyle(prompt);
  86. let buf = colors.unstyle(buffer);
  87. let idx = buf.indexOf(prompt);
  88. let header = buf.slice(0, idx);
  89. let rest = buf.slice(idx);
  90. let lines = rest.split('\n');
  91. let first = lines[0];
  92. let last = lines[lines.length - 1];
  93. let promptLine = prompt + (input ? ' ' + input : '');
  94. let len = promptLine.length;
  95. let after = len < first.length ? first.slice(len + 1) : '';
  96. return { header, prompt: first, after, rest: lines.slice(1), last };
  97. }
  98. async submit() {
  99. this.state.submitted = true;
  100. this.state.validating = true;
  101. // this will only be called when the prompt is directly submitted
  102. // without initializing, i.e. when the prompt is skipped, etc. Otherwize,
  103. // "options.onSubmit" is will be handled by the "initialize()" method.
  104. if (this.options.onSubmit) {
  105. await this.options.onSubmit.call(this, this.name, this.value, this);
  106. }
  107. let result = this.state.error || await this.validate(this.value, this.state);
  108. if (result !== true) {
  109. let error = '\n' + this.symbols.pointer + ' ';
  110. if (typeof result === 'string') {
  111. error += result.trim();
  112. } else {
  113. error += 'Invalid input';
  114. }
  115. this.state.error = '\n' + this.styles.danger(error);
  116. this.state.submitted = false;
  117. await this.render();
  118. await this.alert();
  119. this.state.validating = false;
  120. this.state.error = void 0;
  121. return;
  122. }
  123. this.state.validating = false;
  124. await this.render();
  125. await this.close();
  126. this.value = await this.result(this.value);
  127. this.emit('submit', this.value);
  128. }
  129. async cancel(err) {
  130. this.state.cancelled = this.state.submitted = true;
  131. await this.render();
  132. await this.close();
  133. if (typeof this.options.onCancel === 'function') {
  134. await this.options.onCancel.call(this, this.name, this.value, this);
  135. }
  136. this.emit('cancel', await this.error(err));
  137. }
  138. async close() {
  139. this.state.closed = true;
  140. try {
  141. let sections = this.sections();
  142. let lines = Math.ceil(sections.prompt.length / this.width);
  143. if (sections.rest) {
  144. this.write(ansi.cursor.down(sections.rest.length));
  145. }
  146. this.write('\n'.repeat(lines));
  147. } catch (err) { /* do nothing */ }
  148. this.emit('close');
  149. }
  150. start() {
  151. if (!this.stop && this.options.show !== false) {
  152. this.stop = keypress.listen(this, this.keypress.bind(this));
  153. this.once('close', this.stop);
  154. }
  155. }
  156. async skip() {
  157. this.skipped = this.options.skip === true;
  158. if (typeof this.options.skip === 'function') {
  159. this.skipped = await this.options.skip.call(this, this.name, this.value);
  160. }
  161. return this.skipped;
  162. }
  163. async initialize() {
  164. let { format, options, result } = this;
  165. this.format = () => format.call(this, this.value);
  166. this.result = () => result.call(this, this.value);
  167. if (typeof options.initial === 'function') {
  168. this.initial = await options.initial.call(this, this);
  169. }
  170. if (typeof options.onRun === 'function') {
  171. await options.onRun.call(this, this);
  172. }
  173. // if "options.onSubmit" is defined, we wrap the "submit" method to guarantee
  174. // that "onSubmit" will always called first thing inside the submit
  175. // method, regardless of how it's handled in inheriting prompts.
  176. if (typeof options.onSubmit === 'function') {
  177. let onSubmit = options.onSubmit.bind(this);
  178. let submit = this.submit.bind(this);
  179. delete this.options.onSubmit;
  180. this.submit = async() => {
  181. await onSubmit(this.name, this.value, this);
  182. return submit();
  183. };
  184. }
  185. await this.start();
  186. await this.render();
  187. }
  188. render() {
  189. throw new Error('expected prompt to have a custom render method');
  190. }
  191. run() {
  192. return new Promise(async(resolve, reject) => {
  193. this.once('submit', resolve);
  194. this.once('cancel', reject);
  195. if (await this.skip()) {
  196. this.render = () => {};
  197. return this.submit();
  198. }
  199. await this.initialize();
  200. this.emit('run');
  201. });
  202. }
  203. async element(name, choice, i) {
  204. let { options, state, symbols, timers } = this;
  205. let timer = timers && timers[name];
  206. state.timer = timer;
  207. let value = options[name] || state[name] || symbols[name];
  208. let val = choice && choice[name] != null ? choice[name] : await value;
  209. if (val === '') return val;
  210. let res = await this.resolve(val, state, choice, i);
  211. if (!res && choice && choice[name]) {
  212. return this.resolve(value, state, choice, i);
  213. }
  214. return res;
  215. }
  216. async prefix() {
  217. let element = await this.element('prefix') || this.symbols;
  218. let timer = this.timers && this.timers.prefix;
  219. let state = this.state;
  220. state.timer = timer;
  221. if (utils.isObject(element)) element = element[state.status] || element.pending;
  222. if (!utils.hasColor(element)) {
  223. let style = this.styles[state.status] || this.styles.pending;
  224. return style(element);
  225. }
  226. return element;
  227. }
  228. async message() {
  229. let message = await this.element('message');
  230. if (!utils.hasColor(message)) {
  231. return this.styles.strong(message);
  232. }
  233. return message;
  234. }
  235. async separator() {
  236. let element = await this.element('separator') || this.symbols;
  237. let timer = this.timers && this.timers.separator;
  238. let state = this.state;
  239. state.timer = timer;
  240. let value = element[state.status] || element.pending || state.separator;
  241. let ele = await this.resolve(value, state);
  242. if (utils.isObject(ele)) ele = ele[state.status] || ele.pending;
  243. if (!utils.hasColor(ele)) {
  244. return this.styles.muted(ele);
  245. }
  246. return ele;
  247. }
  248. async pointer(choice, i) {
  249. let val = await this.element('pointer', choice, i);
  250. if (typeof val === 'string' && utils.hasColor(val)) {
  251. return val;
  252. }
  253. if (val) {
  254. let styles = this.styles;
  255. let focused = this.index === i;
  256. let style = focused ? styles.primary : val => val;
  257. let ele = await this.resolve(val[focused ? 'on' : 'off'] || val, this.state);
  258. let styled = !utils.hasColor(ele) ? style(ele) : ele;
  259. return focused ? styled : ' '.repeat(ele.length);
  260. }
  261. }
  262. async indicator(choice, i) {
  263. let val = await this.element('indicator', choice, i);
  264. if (typeof val === 'string' && utils.hasColor(val)) {
  265. return val;
  266. }
  267. if (val) {
  268. let styles = this.styles;
  269. let enabled = choice.enabled === true;
  270. let style = enabled ? styles.success : styles.dark;
  271. let ele = val[enabled ? 'on' : 'off'] || val;
  272. return !utils.hasColor(ele) ? style(ele) : ele;
  273. }
  274. return '';
  275. }
  276. body() {
  277. return null;
  278. }
  279. footer() {
  280. if (this.state.status === 'pending') {
  281. return this.element('footer');
  282. }
  283. }
  284. header() {
  285. if (this.state.status === 'pending') {
  286. return this.element('header');
  287. }
  288. }
  289. async hint() {
  290. if (this.state.status === 'pending' && !this.isValue(this.state.input)) {
  291. let hint = await this.element('hint');
  292. if (!utils.hasColor(hint)) {
  293. return this.styles.muted(hint);
  294. }
  295. return hint;
  296. }
  297. }
  298. error(err) {
  299. return !this.state.submitted ? (err || this.state.error) : '';
  300. }
  301. format(value) {
  302. return value;
  303. }
  304. result(value) {
  305. return value;
  306. }
  307. validate(value) {
  308. if (this.options.required === true) {
  309. return this.isValue(value);
  310. }
  311. return true;
  312. }
  313. isValue(value) {
  314. return value != null && value !== '';
  315. }
  316. resolve(value, ...args) {
  317. return utils.resolve(this, value, ...args);
  318. }
  319. get base() {
  320. return Prompt.prototype;
  321. }
  322. get style() {
  323. return this.styles[this.state.status];
  324. }
  325. get height() {
  326. return this.options.rows || utils.height(this.stdout, 25);
  327. }
  328. get width() {
  329. return this.options.columns || utils.width(this.stdout, 80);
  330. }
  331. get size() {
  332. return { width: this.width, height: this.height };
  333. }
  334. set cursor(value) {
  335. this.state.cursor = value;
  336. }
  337. get cursor() {
  338. return this.state.cursor;
  339. }
  340. set input(value) {
  341. this.state.input = value;
  342. }
  343. get input() {
  344. return this.state.input;
  345. }
  346. set value(value) {
  347. this.state.value = value;
  348. }
  349. get value() {
  350. let { input, value } = this.state;
  351. let result = [value, input].find(this.isValue.bind(this));
  352. return this.isValue(result) ? result : this.initial;
  353. }
  354. static get prompt() {
  355. return options => new this(options).run();
  356. }
  357. }
  358. function setOptions(prompt) {
  359. let isValidKey = key => {
  360. return prompt[key] === void 0 || typeof prompt[key] === 'function';
  361. };
  362. let ignore = [
  363. 'actions',
  364. 'choices',
  365. 'initial',
  366. 'margin',
  367. 'roles',
  368. 'styles',
  369. 'symbols',
  370. 'theme',
  371. 'timers',
  372. 'value'
  373. ];
  374. let ignoreFn = [
  375. 'body',
  376. 'footer',
  377. 'error',
  378. 'header',
  379. 'hint',
  380. 'indicator',
  381. 'message',
  382. 'prefix',
  383. 'separator',
  384. 'skip'
  385. ];
  386. for (let key of Object.keys(prompt.options)) {
  387. if (ignore.includes(key)) continue;
  388. if (/^on[A-Z]/.test(key)) continue;
  389. let option = prompt.options[key];
  390. if (typeof option === 'function' && isValidKey(key)) {
  391. if (!ignoreFn.includes(key)) {
  392. prompt[key] = option.bind(prompt);
  393. }
  394. } else if (typeof prompt[key] !== 'function') {
  395. prompt[key] = option;
  396. }
  397. }
  398. }
  399. function margin(value) {
  400. if (typeof value === 'number') {
  401. value = [value, value, value, value];
  402. }
  403. let arr = [].concat(value || []);
  404. let pad = i => i % 2 === 0 ? '\n' : ' ';
  405. let res = [];
  406. for (let i = 0; i < 4; i++) {
  407. let char = pad(i);
  408. if (arr[i]) {
  409. res.push(char.repeat(arr[i]));
  410. } else {
  411. res.push('');
  412. }
  413. }
  414. return res;
  415. }
  416. module.exports = Prompt;