screen-manager.js 4.6 KB

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