ink
Version:
React for CLI
266 lines • 11.8 kB
JavaScript
import ansiEscapes from 'ansi-escapes';
import cliCursor from 'cli-cursor';
import lineUpdate from './line-update.js';
import { cursorPositionChanged, buildCursorSuffix, buildCursorOnlySequence, buildReturnToBottomPrefix, buildReturnToBottom, buildEraseFrame, hideCursorEscape, } from './cursor-helpers.js';
// Count visible lines in a string, ignoring the trailing empty element
// that `split('\n')` produces when the string ends with '\n'.
const visibleLineCount = (lines, text) => text.endsWith('\n') ? lines.length - 1 : lines.length;
const createStandard = (stream, { showCursor = false } = {}) => {
let previousLineCount = 0;
let previousOutput = '';
let hasHiddenCursor = false;
let cursorPosition;
let isCursorDirty = false;
let previousCursorPosition;
let wasCursorShown = false;
const getActiveCursor = () => (isCursorDirty ? cursorPosition : undefined);
const hasChanges = (text, activeCursor) => {
const hasCursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
return text !== previousOutput || hasCursorChanged;
};
const render = (text) => {
if (!showCursor && !hasHiddenCursor) {
cliCursor.hide(stream);
hasHiddenCursor = true;
}
// Only use cursor if setCursorPosition was called since last render.
// This ensures stale positions don't persist after component unmount.
const activeCursor = getActiveCursor();
isCursorDirty = false;
const hasCursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
if (!hasChanges(text, activeCursor)) {
return false;
}
const lines = text.split('\n');
const cursorSuffix = buildCursorSuffix(lines.length - 1, activeCursor);
if (text === previousOutput && hasCursorChanged) {
stream.write(buildCursorOnlySequence({
cursorWasShown: wasCursorShown,
previousLineCount,
previousCursorPosition,
cursorPosition: activeCursor,
}));
}
else {
previousOutput = text;
const returnPrefix = buildReturnToBottomPrefix(wasCursorShown, previousLineCount, previousCursorPosition);
stream.write(returnPrefix +
ansiEscapes.eraseLines(previousLineCount) +
text +
cursorSuffix);
previousLineCount = lines.length;
}
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
wasCursorShown = activeCursor !== undefined;
return true;
};
render.clear = () => {
stream.write(buildEraseFrame(previousLineCount, previousCursorPosition));
previousOutput = '';
previousLineCount = 0;
previousCursorPosition = undefined;
wasCursorShown = false;
};
render.done = () => {
if (previousCursorPosition) {
// Leave the terminal at the output bottom before discarding the cursor position.
stream.write(buildReturnToBottom(previousLineCount, previousCursorPosition));
}
previousOutput = '';
previousLineCount = 0;
previousCursorPosition = undefined;
wasCursorShown = false;
if (showCursor) {
return;
}
cliCursor.show(stream);
hasHiddenCursor = false;
};
render.reset = () => {
previousOutput = '';
previousLineCount = 0;
previousCursorPosition = undefined;
wasCursorShown = false;
};
render.sync = (text) => {
const activeCursor = isCursorDirty ? cursorPosition : undefined;
isCursorDirty = false;
const lines = text.split('\n');
previousOutput = text;
previousLineCount = lines.length;
if (!activeCursor && wasCursorShown) {
stream.write(hideCursorEscape);
}
if (activeCursor) {
stream.write(buildCursorSuffix(lines.length - 1, activeCursor));
}
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
wasCursorShown = activeCursor !== undefined;
};
render.setCursorPosition = (position) => {
cursorPosition = position;
isCursorDirty = true;
};
render.isCursorDirty = () => isCursorDirty;
render.willRender = (text) => hasChanges(text, getActiveCursor());
render.getCursorPosition = () => previousCursorPosition;
return render;
};
const createIncremental = (stream, { showCursor = false } = {}) => {
let previousLines = [];
let previousOutput = '';
let previousColumns = stream.columns;
let hasHiddenCursor = false;
let cursorPosition;
let isCursorDirty = false;
let previousCursorPosition;
let wasCursorShown = false;
const getActiveCursor = () => (isCursorDirty ? cursorPosition : undefined);
const hasChanges = (text, activeCursor) => {
const hasCursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
return text !== previousOutput || hasCursorChanged;
};
const render = (text) => {
if (!showCursor && !hasHiddenCursor) {
cliCursor.hide(stream);
hasHiddenCursor = true;
}
// Only use cursor if setCursorPosition was called since last render.
// This ensures stale positions don't persist after component unmount.
const activeCursor = getActiveCursor();
isCursorDirty = false;
const hasCursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
if (!hasChanges(text, activeCursor)) {
return false;
}
const areColumnsUnchanged = previousColumns === stream.columns;
previousColumns = stream.columns;
const nextLines = text.split('\n');
const visibleCount = visibleLineCount(nextLines, text);
const previousVisible = visibleLineCount(previousLines, previousOutput);
if (text === previousOutput && hasCursorChanged) {
stream.write(buildCursorOnlySequence({
cursorWasShown: wasCursorShown,
previousLineCount: previousLines.length,
previousCursorPosition,
cursorPosition: activeCursor,
}));
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
wasCursorShown = activeCursor !== undefined;
return true;
}
const returnPrefix = buildReturnToBottomPrefix(wasCursorShown, previousLines.length, previousCursorPosition);
if (text === '\n' || previousOutput.length === 0) {
const cursorSuffix = buildCursorSuffix(nextLines.length - 1, activeCursor);
stream.write(returnPrefix +
ansiEscapes.eraseLines(previousLines.length) +
text +
cursorSuffix);
wasCursorShown = activeCursor !== undefined;
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
previousOutput = text;
previousLines = nextLines;
return true;
}
const hasTrailingNewline = text.endsWith('\n');
// We aggregate all chunks for incremental rendering into a buffer, and then write them to stdout at the end.
const buffer = [];
buffer.push(returnPrefix);
// Clear extra lines if the current content's line count is lower than the previous.
if (visibleCount < previousVisible) {
const didPreviousHaveTrailingNewline = previousOutput.endsWith('\n');
const extraSlot = didPreviousHaveTrailingNewline ? 1 : 0;
buffer.push(ansiEscapes.eraseLines(previousVisible - visibleCount + extraSlot), ansiEscapes.cursorUp(visibleCount));
}
else if (previousLines.length > 1) {
buffer.push(ansiEscapes.cursorUp(previousLines.length - 1));
}
for (let i = 0; i < visibleCount; i++) {
const isLastLine = i === visibleCount - 1;
// We do not write lines if the contents are the same. This prevents flickering during renders.
// New blank rows need a line feed to scroll at the terminal bottom; the trailing split entry is not a visible row.
if (i < previousVisible && nextLines[i] === previousLines[i]) {
// Don't move past the last line when there's no trailing newline,
// otherwise the cursor overshoots the rendered block.
if (!isLastLine || hasTrailingNewline) {
buffer.push(ansiEscapes.cursorNextLine);
}
continue;
}
const nextLine = nextLines[i];
const changedLine = areColumnsUnchanged && nextLines.length === previousLines.length
? lineUpdate(previousLines[i], nextLine, stream.columns)
: ansiEscapes.cursorTo(0) + ansiEscapes.eraseEndLine + nextLine;
buffer.push(changedLine +
// Don't append newline after the last line when the input
// has no trailing newline (fullscreen mode).
(isLastLine && !hasTrailingNewline ? '' : '\n'));
}
const cursorSuffix = buildCursorSuffix(nextLines.length - 1, activeCursor);
buffer.push(cursorSuffix);
stream.write(buffer.join(''));
wasCursorShown = activeCursor !== undefined;
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
previousOutput = text;
previousLines = nextLines;
return true;
};
render.clear = () => {
stream.write(buildEraseFrame(previousLines.length, previousCursorPosition));
previousOutput = '';
previousLines = [];
previousCursorPosition = undefined;
wasCursorShown = false;
};
render.done = () => {
if (previousCursorPosition) {
// Leave the terminal at the output bottom before discarding the cursor position.
stream.write(buildReturnToBottom(previousLines.length, previousCursorPosition));
}
previousOutput = '';
previousLines = [];
previousCursorPosition = undefined;
wasCursorShown = false;
if (showCursor) {
return;
}
cliCursor.show(stream);
hasHiddenCursor = false;
};
render.reset = () => {
previousOutput = '';
previousLines = [];
previousCursorPosition = undefined;
wasCursorShown = false;
};
render.sync = (text) => {
const activeCursor = isCursorDirty ? cursorPosition : undefined;
isCursorDirty = false;
previousColumns = stream.columns;
const lines = text.split('\n');
previousOutput = text;
previousLines = lines;
if (!activeCursor && wasCursorShown) {
stream.write(hideCursorEscape);
}
if (activeCursor) {
stream.write(buildCursorSuffix(lines.length - 1, activeCursor));
}
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
wasCursorShown = activeCursor !== undefined;
};
render.setCursorPosition = (position) => {
cursorPosition = position;
isCursorDirty = true;
};
render.isCursorDirty = () => isCursorDirty;
render.willRender = (text) => hasChanges(text, getActiveCursor());
render.getCursorPosition = () => previousCursorPosition;
return render;
};
const create = (stream, { showCursor = false, incremental = false } = {}) => incremental
? createIncremental(stream, { showCursor })
: createStandard(stream, { showCursor });
const logUpdate = { create };
export default logUpdate;
//# sourceMappingURL=log-update.js.map