igir
Version:
🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.
340 lines (339 loc) • 11.2 kB
JavaScript
import tty from "node:tty";
import stripAnsi from "strip-ansi";
import Timer from "../async/timer.js";
import NullWritable from "../streams/nullWritable.js";
const LIVE_REGION_PADDING = " ";
class Terminal {
// Throttles paints triggered by flushing queued log lines (the `schedulePaint` path), to reduce
// flicker and formatting expense when logs arrive in bursts. Live-region *content* changes are
// not rate-limited here: `setLiveRegion` coalesces a burst into one prompt paint so meaningful
// status changes stay timely.
static PAINT_MAX_FPS = 5;
stream;
columns = 65536;
rows = 65536;
// The logical live-region frame most recently provided (full width, un-truncated)
liveRegionRaw = "";
// The physical block currently on screen (padded/truncated, ending in a newline), or '' if none
lastDisplayed = "";
// Log lines waiting to be printed above the live region. Queued (rather than written immediately)
// while a live region is on screen so they can be flushed in the same synchronized update that
// repaints the region — otherwise the logs and the bars would be drawn as two separate frames.
pendingLogLines = [];
// Whether the content most recently written above the live region ended with a blank line. Used
// to drop the region's own leading blank-line separator so the gap is always exactly one line.
lastWriteEndedBlank = false;
cursorHidden = false;
sigwinchHandler;
lastPaintMs = 0;
pendingPaint;
liveRegionPaintQueued = false;
constructor(stream) {
this.stream = stream;
this.attachStream(stream);
}
/**
* Whether the underlying stream is an interactive terminal (and therefore draws the live region).
*/
isInteractive() {
return this.stream instanceof tty.WriteStream;
}
/**
* Replace the underlying output stream. Used at startup to wire up the real stream, and by tests
* to redirect output away from the real stdout.
*/
setStream(stream) {
this.pendingPaint?.cancel();
this.pendingPaint = void 0;
this.flushPendingLogs();
this.showCursor();
this.detachStream();
this.lastDisplayed = "";
this.lastWriteEndedBlank = false;
this.stream = stream;
this.attachStream(stream);
}
attachStream(stream) {
if (stream instanceof tty.WriteStream) {
this.columns = stream.columns;
this.rows = stream.rows;
this.sigwinchHandler = () => {
if (!(this.stream instanceof tty.WriteStream)) {
return;
}
this.columns = this.stream.columns;
this.rows = this.stream.rows;
if (this.liveRegionRaw === "") {
return;
}
this.clearDisplayed();
this.paint();
};
process.on("SIGWINCH", this.sigwinchHandler);
} else {
this.columns = 65536;
this.rows = 65536;
}
}
detachStream() {
if (this.sigwinchHandler === void 0) {
return;
}
process.off("SIGWINCH", this.sigwinchHandler);
this.sigwinchHandler = void 0;
}
/**
* Write a finished line (e.g. a log message) above the live region. When no live region is on
* screen the line is written immediately; otherwise it is queued and flushed together with the
* region repaint in a single synchronized update (see {@link paint}), so logs and bars are never
* drawn as separate, torn frames. Queued lines are coalesced behind the paint rate.
*/
writeLine(text) {
if (!(this.stream instanceof tty.WriteStream)) {
this.stream.write(`${text}
`);
return;
}
if (this.liveRegionRaw === "" && this.lastDisplayed === "") {
this.stream.write(`${text}
`);
this.lastWriteEndedBlank = Terminal.endsWithBlankLine(text);
return;
}
this.pendingLogLines.push(text);
this.schedulePaint();
}
/**
* Set (or replace) the live-region frame and repaint it at the end of the current tick.
*/
setLiveRegion(raw) {
if (raw === this.liveRegionRaw) {
return;
}
const hasRegionAppeared = this.liveRegionRaw === "" && raw !== "";
this.liveRegionRaw = raw;
if (!(this.stream instanceof tty.WriteStream)) {
return;
}
if (hasRegionAppeared) {
this.hideCursor();
}
this.pendingPaint?.cancel();
this.pendingPaint = void 0;
if (this.liveRegionPaintQueued) {
return;
}
this.liveRegionPaintQueued = true;
queueMicrotask(() => {
this.liveRegionPaintQueued = false;
this.paint();
});
}
/**
* Repaint the live region in place, but no more often than {@link Terminal.PAINT_MAX_FPS}. Bursts
* of requests within one frame interval are coalesced into a single deferred paint.
*/
schedulePaint() {
if (this.pendingPaint !== void 0) {
return;
}
const intervalMs = 1e3 / Terminal.PAINT_MAX_FPS;
const elapsedMs = Date.now() - this.lastPaintMs;
if (elapsedMs >= intervalMs) {
this.paint();
return;
}
this.pendingPaint = Timer.setTimeout(() => {
this.pendingPaint = void 0;
this.paint();
}, intervalMs - elapsedMs);
}
/**
* Flush any queued log lines and (re)draw the live region beneath them, all within one
* synchronized update so the two are presented as a single frame. No-op when there are no queued
* logs and the rendered frame is unchanged.
*/
paint() {
if (!(this.stream instanceof tty.WriteStream)) {
return;
}
if (this.pendingLogLines.length === 0) {
const displayed = this.renderFrame();
if (displayed === this.lastDisplayed) {
return;
}
this.withSynchronizedUpdate(() => {
this.repaint(this.lastDisplayed, displayed);
});
this.lastDisplayed = displayed;
} else {
this.withSynchronizedUpdate(() => {
this.clearDisplayed();
this.flushPendingLogs();
const displayed = this.renderFrame();
this.repaint("", displayed);
this.lastDisplayed = displayed;
});
}
this.lastPaintMs = Date.now();
}
/**
* Tear down the live region and restore the cursor.
*/
clearLiveRegion() {
this.pendingPaint?.cancel();
this.pendingPaint = void 0;
this.liveRegionPaintQueued = false;
this.liveRegionRaw = "";
if (this.stream instanceof tty.WriteStream && (this.lastDisplayed !== "" || this.pendingLogLines.length > 0)) {
this.withSynchronizedUpdate(() => {
if (this.lastDisplayed !== "") {
this.clearDisplayed();
}
this.flushPendingLogs();
});
}
this.showCursor();
}
/**
* Write any queued log lines to the stream and clear the queue. Assumes the live region has
* already been cleared from the cursor position, so the lines are written where it was.
*/
flushPendingLogs() {
if (this.pendingLogLines.length === 0) {
return;
}
this.stream.write(`${this.pendingLogLines.join("\n")}
`);
this.lastWriteEndedBlank = Terminal.endsWithBlankLine(this.pendingLogLines.at(-1) ?? "");
this.pendingLogLines = [];
}
/**
* Turn the logical live-region frame into the physical block to display, applying the terminal
* row limit, per-line column truncation, and left padding.
*/
renderFrame() {
if (this.liveRegionRaw === "") {
return "";
}
let raw = this.liveRegionRaw;
if (this.lastWriteEndedBlank && raw.startsWith("\n")) {
raw = raw.slice(1);
}
const outputLines = raw.split("\n").slice(0, this.rows - 1).map((line) => {
if (line.length <= this.columns - 10) {
return `${LIVE_REGION_PADDING}${line}`;
}
const stripChars = stripAnsi(line).length - this.columns + 10;
if (stripChars <= 0) {
return `${LIVE_REGION_PADDING}${line}`;
}
return `${LIVE_REGION_PADDING}${line.slice(0, line.length - stripChars)}\u2026`;
});
return outputLines.length > 0 ? `${outputLines.join("\n")}
` : "";
}
/**
* Clear the currently-displayed live region from the screen, moving the cursor back to where it
* started.
*/
clearDisplayed() {
if (!(this.stream instanceof tty.WriteStream)) {
this.lastDisplayed = "";
return;
}
const rowsToMoveUp = this.lastDisplayed.split("\n").length - 1;
if (rowsToMoveUp > 0) {
this.stream.moveCursor(0, -rowsToMoveUp);
this.stream.cursorTo(0);
this.stream.clearScreenDown();
}
this.lastDisplayed = "";
}
/**
* Repaint the live region in place: find the first changed line, move up to it, and overwrite
* only the lines that changed.
*/
repaint(oldDisplayed, newDisplayed) {
if (!(this.stream instanceof tty.WriteStream)) {
return;
}
const lastLines = oldDisplayed.split("\n");
const newLines = newDisplayed.split("\n");
let firstChangedRow = 0;
while (firstChangedRow < lastLines.length - 1 && firstChangedRow < newLines.length - 1 && lastLines[firstChangedRow] === newLines[firstChangedRow]) {
firstChangedRow++;
}
const rowsToMoveUp = lastLines.length - 1 - firstChangedRow;
if (rowsToMoveUp > 0) {
this.stream.moveCursor(0, -rowsToMoveUp);
this.stream.cursorTo(0);
}
const newlineCount = newLines.length - 1;
const lastLineCount = lastLines.length - 1;
for (let i = firstChangedRow; i < newlineCount; i++) {
this.stream.cursorTo(0);
this.stream.write(newLines[i]);
this.stream.clearLine(1);
this.stream.write("\n");
}
this.stream.cursorTo(0);
const extraOldLines = Math.max(0, lastLineCount - newlineCount);
for (let i = 0; i < extraOldLines; i++) {
this.stream.clearLine(0);
if (i < extraOldLines - 1) {
this.stream.moveCursor(0, 1);
}
}
if (extraOldLines > 1) {
this.stream.moveCursor(0, -(extraOldLines - 1));
}
}
/**
* Run a paint that emits multiple cursor moves/writes inside a DEC 2026 "synchronized update", so
* terminals that support it present the whole frame atomically instead of revealing intermediate
* steps (which flickers/tears). Terminals that don't support the private mode ignore the
* sequences.
*/
withSynchronizedUpdate(paint) {
if (!(this.stream instanceof tty.WriteStream)) {
paint();
return;
}
this.stream.write("\x1B[?2026h");
try {
paint();
} finally {
this.stream.write("\x1B[?2026l");
}
}
/**
* Whether writing the given text (which is always followed by a newline) leaves a blank line at
* the bottom — i.e. the text is empty or already ends with a newline.
*/
static endsWithBlankLine(text) {
return text === "" || text.endsWith("\n");
}
hideCursor() {
if (!(this.stream instanceof tty.WriteStream) || this.cursorHidden) {
return;
}
this.stream.write("\x1B[?25l");
this.cursorHidden = true;
}
showCursor() {
if (this.cursorHidden && this.stream instanceof tty.WriteStream) {
this.stream.write("\x1B[?25h");
}
this.cursorHidden = false;
}
}
const terminal = new Terminal(
process.env.NODE_ENV === "test" ? new NullWritable() : process.stdout
);
export {
LIVE_REGION_PADDING,
Terminal as default,
terminal
};
//# sourceMappingURL=terminal.js.map