screen-manager.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. 'use strict';
  2. const util = require('./readline');
  3. const cliWidth = require('cli-width');
  4. const wrapAnsi = require('wrap-ansi');
  5. const stripAnsi = require('strip-ansi');
  6. const stringWidth = require('string-width');
  7. const ora = require('ora');
  8. function height(content) {
  9. return content.split('\n').length;
  10. }
  11. /** @param {string} content */
  12. function lastLine(content) {
  13. return content.split('\n').pop();
  14. }
  15. class ScreenManager {
  16. constructor(rl) {
  17. // These variables are keeping information to allow correct prompt re-rendering
  18. this.height = 0;
  19. this.extraLinesUnderPrompt = 0;
  20. this.rl = rl;
  21. }
  22. renderWithSpinner(content, bottomContent) {
  23. if (this.spinnerId) {
  24. clearInterval(this.spinnerId);
  25. }
  26. let spinner;
  27. let contentFunc;
  28. let bottomContentFunc;
  29. if (bottomContent) {
  30. spinner = ora(bottomContent);
  31. contentFunc = () => content;
  32. bottomContentFunc = () => spinner.frame();
  33. } else {
  34. spinner = ora(content);
  35. contentFunc = () => spinner.frame();
  36. bottomContentFunc = () => '';
  37. }
  38. this.spinnerId = setInterval(
  39. () => this.render(contentFunc(), bottomContentFunc(), true),
  40. spinner.interval
  41. );
  42. }
  43. render(content, bottomContent, spinning = false) {
  44. if (this.spinnerId && !spinning) {
  45. clearInterval(this.spinnerId);
  46. }
  47. this.rl.output.unmute();
  48. this.clean(this.extraLinesUnderPrompt);
  49. /**
  50. * Write message to screen and setPrompt to control backspace
  51. */
  52. const promptLine = lastLine(content);
  53. const rawPromptLine = stripAnsi(promptLine);
  54. // Remove the rl.line from our prompt. We can't rely on the content of
  55. // rl.line (mainly because of the password prompt), so just rely on it's
  56. // length.
  57. let prompt = rawPromptLine;
  58. if (this.rl.line.length) {
  59. prompt = prompt.slice(0, -this.rl.line.length);
  60. }
  61. this.rl.setPrompt(prompt);
  62. // SetPrompt will change cursor position, now we can get correct value
  63. const cursorPos = this.rl._getCursorPos();
  64. const width = this.normalizedCliWidth();
  65. content = this.forceLineReturn(content, width);
  66. if (bottomContent) {
  67. bottomContent = this.forceLineReturn(bottomContent, width);
  68. }
  69. // Manually insert an extra line if we're at the end of the line.
  70. // This prevent the cursor from appearing at the beginning of the
  71. // current line.
  72. if (rawPromptLine.length % width === 0) {
  73. content += '\n';
  74. }
  75. const fullContent = content + (bottomContent ? '\n' + bottomContent : '');
  76. this.rl.output.write(fullContent);
  77. /**
  78. * Re-adjust the cursor at the correct position.
  79. */
  80. // We need to consider parts of the prompt under the cursor as part of the bottom
  81. // content in order to correctly cleanup and re-render.
  82. const promptLineUpDiff = Math.floor(rawPromptLine.length / width) - cursorPos.rows;
  83. const bottomContentHeight =
  84. promptLineUpDiff + (bottomContent ? height(bottomContent) : 0);
  85. if (bottomContentHeight > 0) {
  86. util.up(this.rl, bottomContentHeight);
  87. }
  88. // Reset cursor at the beginning of the line
  89. util.left(this.rl, stringWidth(lastLine(fullContent)));
  90. // Adjust cursor on the right
  91. if (cursorPos.cols > 0) {
  92. util.right(this.rl, cursorPos.cols);
  93. }
  94. /**
  95. * Set up state for next re-rendering
  96. */
  97. this.extraLinesUnderPrompt = bottomContentHeight;
  98. this.height = height(fullContent);
  99. this.rl.output.mute();
  100. }
  101. clean(extraLines) {
  102. if (extraLines > 0) {
  103. util.down(this.rl, extraLines);
  104. }
  105. util.clearLine(this.rl, this.height);
  106. }
  107. done() {
  108. this.rl.setPrompt('');
  109. this.rl.output.unmute();
  110. this.rl.output.write('\n');
  111. }
  112. releaseCursor() {
  113. if (this.extraLinesUnderPrompt > 0) {
  114. util.down(this.rl, this.extraLinesUnderPrompt);
  115. }
  116. }
  117. normalizedCliWidth() {
  118. const width = cliWidth({
  119. defaultWidth: 80,
  120. output: this.rl.output,
  121. });
  122. return width;
  123. }
  124. /**
  125. * @param {string[]} lines
  126. */
  127. breakLines(lines, width = this.normalizedCliWidth()) {
  128. // Break lines who're longer than the cli width so we can normalize the natural line
  129. // returns behavior across terminals.
  130. // re: trim: false; by default, `wrap-ansi` trims whitespace, which
  131. // is not what we want.
  132. // re: hard: true; by default', `wrap-ansi` does soft wrapping
  133. return lines.map((line) =>
  134. wrapAnsi(line, width, { trim: false, hard: true }).split('\n')
  135. );
  136. }
  137. /**
  138. * @param {string} content
  139. */
  140. forceLineReturn(content, width = this.normalizedCliWidth()) {
  141. return this.breakLines(content.split('\n'), width).flat().join('\n');
  142. }
  143. }
  144. module.exports = ScreenManager;