'use strict'; const util = require('./readline'); const cliWidth = require('cli-width'); const stripAnsi = require('strip-ansi'); const stringWidth = require('string-width'); const ora = require('ora'); function height(content) { return content.split('\n').length; } /** @param {string} content */ function lastLine(content) { return content.split('\n').pop(); } class ScreenManager { constructor(rl) { // These variables are keeping information to allow correct prompt re-rendering this.height = 0; this.extraLinesUnderPrompt = 0; this.rl = rl; } renderWithSpinner(content, bottomContent) { if (this.spinnerId) { clearInterval(this.spinnerId); } let spinner; let contentFunc; let bottomContentFunc; if (bottomContent) { spinner = ora(bottomContent); contentFunc = () => content; bottomContentFunc = () => spinner.frame(); } else { spinner = ora(content); contentFunc = () => spinner.frame(); bottomContentFunc = () => ''; } this.spinnerId = setInterval( () => this.render(contentFunc(), bottomContentFunc(), true), spinner.interval ); } render(content, bottomContent, spinning = false) { if (this.spinnerId && !spinning) { clearInterval(this.spinnerId); } this.rl.output.unmute(); this.clean(this.extraLinesUnderPrompt); /** * Write message to screen and setPrompt to control backspace */ const promptLine = lastLine(content); const rawPromptLine = stripAnsi(promptLine); // Remove the rl.line from our prompt. We can't rely on the content of // rl.line (mainly because of the password prompt), so just rely on it's // length. let prompt = rawPromptLine; if (this.rl.line.length) { prompt = prompt.slice(0, -this.rl.line.length); } this.rl.setPrompt(prompt); // SetPrompt will change cursor position, now we can get correct value const cursorPos = this.rl._getCursorPos(); const width = this.normalizedCliWidth(); content = this.forceLineReturn(content, width); if (bottomContent) { bottomContent = this.forceLineReturn(bottomContent, width); } // Manually insert an extra line if we're at the end of the line. // This prevent the cursor from appearing at the beginning of the // current line. if (rawPromptLine.length % width === 0) { content += '\n'; } const fullContent = content + (bottomContent ? '\n' + bottomContent : ''); this.rl.output.write(fullContent); /** * Re-adjust the cursor at the correct position. */ // We need to consider parts of the prompt under the cursor as part of the bottom // content in order to correctly cleanup and re-render. const promptLineUpDiff = Math.floor(rawPromptLine.length / width) - cursorPos.rows; const bottomContentHeight = promptLineUpDiff + (bottomContent ? height(bottomContent) : 0); if (bottomContentHeight > 0) { util.up(this.rl, bottomContentHeight); } // Reset cursor at the beginning of the line util.left(this.rl, stringWidth(lastLine(fullContent))); // Adjust cursor on the right if (cursorPos.cols > 0) { util.right(this.rl, cursorPos.cols); } /** * Set up state for next re-rendering */ this.extraLinesUnderPrompt = bottomContentHeight; this.height = height(fullContent); this.rl.output.mute(); } clean(extraLines) { if (extraLines > 0) { util.down(this.rl, extraLines); } util.clearLine(this.rl, this.height); } done() { this.rl.setPrompt(''); this.rl.output.unmute(); this.rl.output.write('\n'); } releaseCursor() { if (this.extraLinesUnderPrompt > 0) { util.down(this.rl, this.extraLinesUnderPrompt); } } normalizedCliWidth() { const width = cliWidth({ defaultWidth: 80, output: this.rl.output, }); return width; } /** * @param {string[]} lines */ breakLines(lines, width = this.normalizedCliWidth()) { // Break lines who're longer than the cli width so we can normalize the natural line // returns behavior across terminals. const regex = new RegExp(`(?:(?:\\033[[0-9;]*m)*.?){1,${width}}`, 'g'); return lines.map((line) => { const chunk = line.match(regex); // Last match is always empty chunk.pop(); return chunk || ''; }); } /** * @param {string} content */ forceLineReturn(content, width = this.normalizedCliWidth()) { return this.breakLines(content.split('\n'), width).flat().join('\n'); } } module.exports = ScreenManager;