import cliWidth from 'cli-width'; import wrapAnsi from 'wrap-ansi'; import stripAnsi from 'strip-ansi'; import stringWidth from 'string-width'; import ora from 'ora'; import * as util from './readline.js'; function height(content) { return content.split('\n').length; } /** @param {string} content */ function lastLine(content) { return content.split('\n').pop(); } export default 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. // re: trim: false; by default, `wrap-ansi` trims whitespace, which // is not what we want. // re: hard: true; by default', `wrap-ansi` does soft wrapping return lines.map((line) => wrapAnsi(line, width, { trim: false, hard: true }).split('\n'), ); } /** * @param {string} content */ forceLineReturn(content, width = this.normalizedCliWidth()) { return this.breakLines(content.split('\n'), width).flat().join('\n'); } }