UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

297 lines 10.5 kB
import * as unicode from '@teaui/term'; import { BG_DRAW } from './ansi.js'; import { Style } from './Style.js'; import { Size } from './geometry.js'; const EMPTY_CELL = { char: ' ', style: Style.NONE, width: 1 }; export class Buffer { size = Size.zero; #meta = ''; #canvas = new Map(); #prev = new Map(); #paintRects = []; #prevPaintRects = []; #dirtyRows = new Set(); #prevDirtyRows = new Set(); #mergeCache = new Map(); setForeground(_fg) { } setBackground(_bg) { } resize(size) { if (size.width !== this.size.width || size.height !== this.size.height) { this.#prev = new Map(); } this.size = size; } /** * Invalidates the diff cache so the next flush writes all cells. * Used by test harnesses that reset the terminal between renders. */ invalidate() { this.#prev = new Map(); } /** * Writes the string at the cursor from left to write. Exits on newline (no default * wrapping behavior). */ writeChar(char, x, y, style) { x = ~~x; y = ~~y; if (char === '\n') { return; } const width = unicode.charWidth(char); if (width === 0) { return; } if (x < 0 || x >= this.size.width || y < 0 || y >= this.size.height) { return; } this.#dirtyRows.add(y); let line = this.#canvas.get(y); if (line) { const prev = line.get(x); if (prev?.char === BG_DRAW) { style = this.#mergeBackgroundStyle(style, prev.style); } else if (!prev) { const paintStyle = this.#paintStyleAt(x, y); if (paintStyle) { style = this.#mergeBackgroundStyle(style, paintStyle); } } const leftChar = line.get(x - 1); if (leftChar && leftChar.width === 2) { // hides a 2-width character that this character is overlapping line.set(x - 1, { char: ' ', width: 1, style: leftChar.style }); // actually writes the character, and records the hidden character line.set(x, { char, width, style, hiding: leftChar }); if (width === 2) { line.delete(x + 1); } const hiding = leftChar.hiding; if (hiding) { line.set(x - 2, hiding); } } else { // actually writes the character line.set(x, { char, width, style }); if (width === 2) { line.delete(x + 1); } const next = line.get(x + 1); if (next && next.hiding) { // the next character can no longer be "hiding" the previous character (this // character) line.set(x + 1, { ...next, hiding: undefined }); } } } else { const paintStyle = this.#paintStyleAt(x, y); if (paintStyle) { style = this.#mergeBackgroundStyle(style, paintStyle); } line = new Map([[x, { char, width, style }]]); this.#canvas.set(y, line); } } /** * Merges foreground/background from a background style into a text style, * only when the text style is missing those properties. Single merge call. */ #mergeBackgroundStyle(style, bgStyle) { const needsFg = style.foreground === undefined && bgStyle.foreground !== undefined; const needsBg = style.background === undefined && bgStyle.background !== undefined; if (!needsFg && !needsBg) { return style; } // Check cache let bgCache = this.#mergeCache.get(bgStyle); if (bgCache) { const cached = bgCache.get(style); if (cached) return cached; } else { bgCache = new Map(); this.#mergeCache.set(bgStyle, bgCache); } const merged = style.merge({ foreground: needsFg ? bgStyle.foreground : undefined, background: needsBg ? bgStyle.background : undefined, }); bgCache.set(style, merged); return merged; } /** * Fills a rectangular region with BG_DRAW cells lazily. * The cells are materialized during flush or when overwritten by writeChar. */ paintRect(style, minX, minY, maxX, maxY) { const rMinX = Math.max(0, ~~minX); const rMinY = Math.max(0, ~~minY); const rMaxX = Math.min(this.size.width, ~~maxX); const rMaxY = Math.min(this.size.height, ~~maxY); // Clear canvas cells in this region so the lazy paint rect takes priority // over content that was rendered before this paint call. for (let y = rMinY; y < rMaxY; y++) { const line = this.#canvas.get(y); if (!line) continue; for (let x = rMinX; x < rMaxX; x++) { line.delete(x); } } this.#paintRects.push({ style, cell: { char: BG_DRAW, width: 1, style }, minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY, }); } /** * Returns the paint style for coordinates that fall within a paint rect, * checking most recent rects first. */ #paintStyleAt(x, y) { for (let i = this.#paintRects.length - 1; i >= 0; i--) { const r = this.#paintRects[i]; if (x >= r.minX && x < r.maxX && y >= r.minY && y < r.maxY) { return r.style; } } return undefined; } /** * Replaces the style of an existing cell without changing its character. * If no cell exists at (x, y), writes a space with the given style. */ restyleChar(x, y, style) { x = ~~x; y = ~~y; if (x < 0 || x >= this.size.width || y < 0 || y >= this.size.height) { return; } this.#dirtyRows.add(y); let line = this.#canvas.get(y); if (!line) { line = new Map(); this.#canvas.set(y, line); } const existing = line.get(x); if (existing) { line.set(x, { ...existing, style }); } else { line.set(x, { char: ' ', width: 1, style }); } } /** * For ANSI sequences that aren't related to any specific character. */ writeMeta(str) { this.#meta += str; } flush(terminal) { if (this.#meta) { terminal.write(this.#meta); } // Check if paint rects changed since last flush const paintRectsChanged = !this.#paintRectsEqual(this.#paintRects, this.#prevPaintRects); let prevStyle = Style.NONE; for (let y = 0; y < this.size.height; y++) { // Skip rows with no canvas writes (now or previously) and unchanged paint rects if (!this.#dirtyRows.has(y) && !this.#prevDirtyRows.has(y) && !paintRectsChanged && this.#prev.has(y)) { continue; } const line = this.#canvas.get(y) ?? new Map(); const prevLine = this.#prev.get(y) ?? new Map(); this.#prev.set(y, prevLine); // Pre-compute paint rects applicable to this row, newest first. There // can be multiple disjoint paint rects on the same row (e.g. multiple // themed calendars in a horizontal stack), so choose per-cell below. const rowPaintRects = []; for (let i = this.#paintRects.length - 1; i >= 0; i--) { const r = this.#paintRects[i]; if (y >= r.minY && y < r.maxY) { rowPaintRects.push(r); } } let didWrite = false; let dx = 1; for (let x = 0; x < this.size.width; x += dx) { const chrInfo = line.get(x) ?? paintCellAt(rowPaintRects, x) ?? EMPTY_CELL; const prevInfo = prevLine.get(x); dx = chrInfo.width; if (prevInfo && isCharEqual(chrInfo, prevInfo)) { didWrite = false; continue; } if (!didWrite) { didWrite = true; terminal.move(x, y); } let { char, style } = chrInfo; if (char === BG_DRAW) { char = ' '; } if (!prevStyle.isEqual(style)) { terminal.write(style.toSGR(prevStyle)); prevStyle = style; } terminal.write(char); if (unicode.isAnnoyingWidth(char) && x + chrInfo.width < this.size.width) { terminal.move(x + chrInfo.width, y); } prevLine.set(x, chrInfo); if (chrInfo.width === 2) { prevLine.delete(x + 1); } } } if (prevStyle !== Style.NONE) { terminal.write('\x1b[0m'); } terminal.flush(); this.#canvas = new Map(); this.#prevDirtyRows = this.#dirtyRows; this.#dirtyRows = new Set(); this.#prevPaintRects = this.#paintRects; this.#paintRects = []; } #paintRectsEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { const ra = a[i]; const rb = b[i]; if (ra.minX !== rb.minX || ra.minY !== rb.minY || ra.maxX !== rb.maxX || ra.maxY !== rb.maxY || !ra.style.isEqual(rb.style)) { return false; } } return true; } } function paintCellAt(rects, x) { for (const r of rects) { if (x >= r.minX && x < r.maxX) { return r.cell; } } } function isCharEqual(lhs, rhs) { return (lhs.char === rhs.char && lhs.width === rhs.width && lhs.style.isEqual(rhs.style)); } //# sourceMappingURL=Buffer.js.map