UNPKG

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
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