import { type AnsiCode, ansiCodesToString, diffAnsiCodes, } from '@alcalzone/ansi-tokenize' import { type Point, type Rectangle, type Size, unionRect, } from './layout/geometry.js' import { BEL, ESC, SEP } from './termio/ansi.js' import * as warn from './warn.js' // --- Shared Pools (interning for memory efficiency) --- // Process-wide pool of every distinct character string we have ever // stored in a cell. Because the pool is shared across screens, the // integer IDs we put into cells are universally valid: `blitRegion` // can copy IDs straight from one screen's `cells` array into // another's, and `diffEach` can compare cells by integer ID instead of // going back through the string table. export class CharPool { private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty (spacer) private stringMap = new Map([ [' ', 0], ['', 1], ]) private ascii: Int32Array = initCharAscii() // charCode → index, -1 = not interned intern(char: string): number { // ASCII fast-path: direct array lookup instead of Map.get if (char.length === 1) { const code = char.charCodeAt(0) if (code < 128) { const cached = this.ascii[code]! if (cached !== -1) return cached const index = this.strings.length this.strings.push(char) this.ascii[code] = index return index } } const existing = this.stringMap.get(char) if (existing !== undefined) return existing const index = this.strings.length this.strings.push(char) this.stringMap.set(char, index) return index } get(index: number): string { return this.strings[index] ?? ' ' } } // Hyperlink string pool shared across all screens. // Index 0 = no hyperlink. export class HyperlinkPool { private strings: string[] = [''] // Index 0 = no hyperlink private stringMap = new Map() intern(hyperlink: string | undefined): number { if (!hyperlink) return 0 let id = this.stringMap.get(hyperlink) if (id === undefined) { id = this.strings.length this.strings.push(hyperlink) this.stringMap.set(hyperlink, id) } return id } get(id: number): string | undefined { return id === 0 ? undefined : this.strings[id] } } // SGR 7 (inverse) as a pre-built `AnsiCode`. The `endCode` of `\x1b[27m` // is the trigger that sets the `VISIBLE_ON_SPACE` bit on the styleId, // so an inverted space cell is never skipped by the "invisible empty // cell" optimisation in the renderer — an inverse space is a visible // block of background colour. const INVERSE_CODE: AnsiCode = { type: 'ansi', code: '\x1b[7m', endCode: '\x1b[27m', } // Bold (SGR 1) — stacks cleanly, no reflow in monospace. endCode 22 // also cancels dim (SGR 2); harmless here since we never add dim. const BOLD_CODE: AnsiCode = { type: 'ansi', code: '\x1b[1m', endCode: '\x1b[22m', } // Underline (SGR 4). We carry this alongside yellow + bold because // underline is the only marker that survives every colour theme intact. // The yellow background we set via inverse can collide with existing // background colours (prompt styling, tool chrome, syntax-highlighted // backgrounds). When debugging: if you see the underline appear but no // yellow tint, the overlay *is* matching — the yellow is just being // drowned out by the underlying cell's own colours. const UNDERLINE_CODE: AnsiCode = { type: 'ansi', code: '\x1b[4m', endCode: '\x1b[24m', } // fg→yellow (SGR 33). With inverse already in the stack, the terminal // swaps fg↔bg at render — so yellow-fg becomes yellow-BG. Original bg // becomes fg (readable on most themes: dark-bg → dark-text on yellow). // endCode 39 is 'default fg' — cancels any prior fg color cleanly. const YELLOW_FG_CODE: AnsiCode = { type: 'ansi', code: '\x1b[33m', endCode: '\x1b[39m', } export class StylePool { private ids = new Map() private styles: AnsiCode[][] = [] private transitionCache = new Map() readonly none: number constructor() { this.none = this.intern([]) } /** Intern a style and return its ID. */ intern(styles: AnsiCode[]): number { const key = styles.length === 0 ? '' : styles.map(s => s.code).join('\0') let id = this.ids.get(key) if (id === undefined) { const rawId = this.styles.length this.styles.push(styles.length === 0 ? [] : styles) id = (rawId << 1) | (styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0) this.ids.set(key, id) } return id } /** Recover styles from an encoded ID. Strips the bit-0 flag via >>> 1. */ get(id: number): AnsiCode[] { return this.styles[id >>> 1] ?? [] } transition(fromId: number, toId: number): string { if (fromId === toId) return '' const key = fromId * 0x100000 + toId let str = this.transitionCache.get(key) if (str === undefined) { str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId))) this.transitionCache.set(key, str) } return str } /** Intern a style that is `base + inverse`. */ private inverseCache = new Map() withInverse(baseId: number): number { let id = this.inverseCache.get(baseId) if (id === undefined) { const baseCodes = this.get(baseId) // If already inverted, use as-is (avoids SGR 7 stacking) const hasInverse = baseCodes.some(c => c.endCode === '\x1b[27m') id = hasInverse ? baseId : this.intern([...baseCodes, INVERSE_CODE]) this.inverseCache.set(baseId, id) } return id } /** Inverse + bold + yellow-bg overlay for the *current* search match. */ private currentMatchCache = new Map() withCurrentMatch(baseId: number): number { let id = this.currentMatchCache.get(baseId) if (id === undefined) { const baseCodes = this.get(baseId) // Strip both fg and bg from the base style before layering the // overlay. Reason: when both fg and bg are explicit, the // semantics of SGR 7 (inverse) become terminal-dependent. A // user-prompt cell with an explicit grey bg becomes // "grey-on-yellow" on some terminals and "yellow-on-grey" on // others. Filtering them out gives us a consistent // yellow-bg + terminal-default-fg everywhere. Bold, dim, italic // and the rest are orthogonal — keep them. const codes = baseCodes.filter( c => c.endCode !== '\x1b[39m' && c.endCode !== '\x1b[49m', ) // fg-yellow FIRST so inverse swaps it to bg. Bold after inverse is // fine — SGR 1 is fg-attribute-only, order-independent vs 7. codes.push(YELLOW_FG_CODE) if (!baseCodes.some(c => c.endCode === '\x1b[27m')) codes.push(INVERSE_CODE) if (!baseCodes.some(c => c.endCode === '\x1b[22m')) codes.push(BOLD_CODE) // Underline as the unambiguous marker — yellow-bg can clash with // existing bg styling (user-prompt bg, syntax bg). If you see // underline but no yellow on a match, the overlay IS finding it; // the yellow is just losing a styling fight. if (!baseCodes.some(c => c.endCode === '\x1b[24m')) codes.push(UNDERLINE_CODE) id = this.intern(codes) this.currentMatchCache.set(baseId, id) } return id } /** Selection-highlight overlay. */ private selectionBgCode: AnsiCode | null = null private selectionBgCache = new Map() setSelectionBg(bg: AnsiCode | null): void { if (this.selectionBgCode?.code === bg?.code) return this.selectionBgCode = bg this.selectionBgCache.clear() } withSelectionBg(baseId: number): number { const bg = this.selectionBgCode if (bg === null) return this.withInverse(baseId) let id = this.selectionBgCache.get(baseId) if (id === undefined) { // Keep everything except bg (49m) and inverse (27m). Fg, bold, dim, // italic, underline, strikethrough all preserved. const kept = this.get(baseId).filter( c => c.endCode !== '\x1b[49m' && c.endCode !== '\x1b[27m', ) kept.push(bg) id = this.intern(kept) this.selectionBgCache.set(baseId, id) } return id } } // endCodes that produce visible effects on space characters const VISIBLE_ON_SPACE = new Set([ '\x1b[49m', // background color '\x1b[27m', // inverse '\x1b[24m', // underline '\x1b[29m', // strikethrough '\x1b[55m', // overline ]) function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean { for (const style of styles) { if (VISIBLE_ON_SPACE.has(style.endCode)) return true } return false } // const enum is inlined at compile time - no runtime object, no property access export const enum CellWidth { // Not a wide character, cell width 1 Narrow = 0, // Wide character, cell width 2. This cell contains the actual character. Wide = 1, // Spacer occupying the second visual column of a wide character. Do not render. SpacerTail = 2, // Spacer at the end of a soft-wrapped line indicating that a wide character // continues on the next line. Used for preserving wide character semantics // across line breaks during soft wrapping. SpacerHead = 3, } export type Hyperlink = string | undefined /** Cell is a view type returned by cellAt(). */ export type Cell = { char: string styleId: number width: CellWidth hyperlink: Hyperlink } // Pool indices for the two cell payloads we want to compare cheaply. // Note these are indices into `CharPool.strings`, not codepoints — // `intern(' ')` and `intern('')` always land at 0 and 1 because the // pool seeds them first. const EMPTY_CHAR_INDEX = 0 // ' ' (space) const SPACER_CHAR_INDEX = 1 // '' — used in the tail half of wide cells // Unwritten cells decay to `[EMPTY_CHAR_INDEX=0, packWord1(0, 0, 0)=0]`. // `StylePool.none` is also 0 (it is the first style interned), so an // unwritten cell is bit-identical to an explicitly-cleared one in the // packed array. We rely on that: `diffEach` can compare cells as raw // integers without normalising for "default style". `isEmptyCellByIndex` // checks both packed words against 0 to detect "never visually written". function initCharAscii(): Int32Array { const table = new Int32Array(128) table.fill(-1) table[32] = EMPTY_CHAR_INDEX // ' ' (space) return table } // --- Packed cell layout --- // Each cell is 2 consecutive Int32 elements in the cells array: // word0 (cells[ci]): charId (full 32 bits) // word1 (cells[ci + 1]): styleId[31:17] | hyperlinkId[16:2] | width[1:0] const STYLE_SHIFT = 17 const HYPERLINK_SHIFT = 2 const HYPERLINK_MASK = 0x7fff // 15 bits const WIDTH_MASK = 3 // 2 bits // Pack styleId, hyperlinkId, and width into a single Int32 function packWord1( styleId: number, hyperlinkId: number, width: number, ): number { return (styleId << STYLE_SHIFT) | (hyperlinkId << HYPERLINK_SHIFT) | width } // Unwritten cell as BigInt64 — both words are 0, so the 64-bit value is 0n. // Used by BigInt64Array.fill() for bulk clears (resetScreen, clearRegion). // Not used for comparison — BigInt element reads cause heap allocation. const EMPTY_CELL_VALUE = 0n export type Screen = Size & { // Packed cell data — 2 Int32s per cell: [charId, packed(styleId|hyperlinkId|width)] // cells and cells64 are views over the same ArrayBuffer. cells: Int32Array cells64: BigInt64Array // 1 BigInt64 per cell — used for bulk fill in resetScreen/clearRegion // Shared pools — IDs are valid across all screens using the same pools charPool: CharPool hyperlinkPool: HyperlinkPool // Empty style ID for comparisons emptyStyleId: number /** Bounding box of cells that were written to (not blitted) during rendering. */ damage: Rectangle | undefined noSelect: Uint8Array /** Per-ROW soft-wrap continuation marker. */ softWrap: Int32Array } function isEmptyCellByIndex(screen: Screen, index: number): boolean { // An empty/unwritten cell has both words === 0: // word0 = EMPTY_CHAR_INDEX (0), word1 = packWord1(emptyStyleId=0, 0, 0) = 0. const ci = index << 1 return screen.cells[ci] === 0 && screen.cells[ci | 1] === 0 } export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean { if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return true return isEmptyCellByIndex(screen, y * screen.width + x) } /** Check if a Cell (view object) represents an empty cell. */ export function isCellEmpty(screen: Screen, cell: Cell): boolean { // Check if cell looks like an empty cell (space, empty style, narrow, no link). // Note: After cellAt mapping, unwritten cells have emptyStyleId, so this // returns true for both unwritten AND cleared cells. Use isEmptyCellAt // for the internal distinction. return ( cell.char === ' ' && cell.styleId === screen.emptyStyleId && cell.width === CellWidth.Narrow && !cell.hyperlink ) } // Intern a hyperlink string and return its ID (0 = no hyperlink) function internHyperlink(screen: Screen, hyperlink: Hyperlink): number { return screen.hyperlinkPool.intern(hyperlink) } // --- export function createScreen( width: number, height: number, styles: StylePool, charPool: CharPool, hyperlinkPool: HyperlinkPool, ): Screen { // Warn if dimensions are not valid integers (likely bad yoga layout output) warn.ifNotInteger(width, 'createScreen width') warn.ifNotInteger(height, 'createScreen height') // Ensure width and height are valid integers to prevent crashes if (!Number.isInteger(width) || width < 0) { width = Math.max(0, Math.floor(width) || 0) } if (!Number.isInteger(height) || height < 0) { height = Math.max(0, Math.floor(height) || 0) } const size = width * height // Allocate one buffer, two views: Int32Array for per-word access, // BigInt64Array for bulk fill in resetScreen/clearRegion. // ArrayBuffer is zero-filled, which is exactly the empty cell value: // [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0]. const buf = new ArrayBuffer(size << 3) // 8 bytes per cell const cells = new Int32Array(buf) const cells64 = new BigInt64Array(buf) return { width, height, cells, cells64, charPool, hyperlinkPool, emptyStyleId: styles.none, damage: undefined, noSelect: new Uint8Array(size), softWrap: new Int32Array(height), } } /** Reset an existing screen for reuse, avoiding allocation of new typed arrays. */ export function resetScreen( screen: Screen, width: number, height: number, ): void { // Warn if dimensions are not valid integers warn.ifNotInteger(width, 'resetScreen width') warn.ifNotInteger(height, 'resetScreen height') // Ensure width and height are valid integers to prevent crashes if (!Number.isInteger(width) || width < 0) { width = Math.max(0, Math.floor(width) || 0) } if (!Number.isInteger(height) || height < 0) { height = Math.max(0, Math.floor(height) || 0) } const size = width * height // Resize if needed (only grow, to avoid reallocations) if (screen.cells64.length < size) { const buf = new ArrayBuffer(size << 3) screen.cells = new Int32Array(buf) screen.cells64 = new BigInt64Array(buf) screen.noSelect = new Uint8Array(size) } if (screen.softWrap.length < height) { screen.softWrap = new Int32Array(height) } // Reset all cells — single fill call, no loop screen.cells64.fill(EMPTY_CELL_VALUE, 0, size) screen.noSelect.fill(0, 0, size) screen.softWrap.fill(0, 0, height) // Update dimensions screen.width = width screen.height = height // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded. // Clear damage tracking screen.damage = undefined } /** Re-intern a screen's char and hyperlink IDs into new pools. */ export function migrateScreenPools( screen: Screen, charPool: CharPool, hyperlinkPool: HyperlinkPool, ): void { const oldCharPool = screen.charPool const oldHyperlinkPool = screen.hyperlinkPool if (oldCharPool === charPool && oldHyperlinkPool === hyperlinkPool) return const size = screen.width * screen.height const cells = screen.cells // Re-intern chars and hyperlinks in a single pass, stride by 2 for (let ci = 0; ci < size << 1; ci += 2) { // Re-intern charId (word0) const oldCharId = cells[ci]! cells[ci] = charPool.intern(oldCharPool.get(oldCharId)) // Re-intern hyperlinkId (packed in word1) const word1 = cells[ci + 1]! const oldHyperlinkId = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK if (oldHyperlinkId !== 0) { const oldStr = oldHyperlinkPool.get(oldHyperlinkId) const newHyperlinkId = hyperlinkPool.intern(oldStr) // Repack word1 with new hyperlinkId, preserving styleId and width const styleId = word1 >>> STYLE_SHIFT const width = word1 & WIDTH_MASK cells[ci + 1] = packWord1(styleId, newHyperlinkId, width) } } screen.charPool = charPool screen.hyperlinkPool = hyperlinkPool } /** Get a Cell view at the given position. */ export function cellAt(screen: Screen, x: number, y: number): Cell | undefined { if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return undefined return cellAtIndex(screen, y * screen.width + x) } /** Get a Cell view by pre-computed array index. */ export function cellAtIndex(screen: Screen, index: number): Cell { const ci = index << 1 const word1 = screen.cells[ci + 1]! const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK return { // Unwritten cells have charIndex=0 (EMPTY_CHAR_INDEX); charPool.get(0) returns ' ' char: screen.charPool.get(screen.cells[ci]!), styleId: word1 >>> STYLE_SHIFT, width: word1 & WIDTH_MASK, hyperlink: hid === 0 ? undefined : screen.hyperlinkPool.get(hid), } } /** Get a Cell at the given index, or undefined if it has no visible content. */ export function visibleCellAtIndex( cells: Int32Array, charPool: CharPool, hyperlinkPool: HyperlinkPool, index: number, lastRenderedStyleId: number, ): Cell | undefined { const ci = index << 1 const charId = cells[ci]! if (charId === 1) return undefined // spacer const word1 = cells[ci + 1]! // For spaces: 0x3fffc masks bits 2-17 (hyperlinkId + styleId visibility // bit). If zero, the space has no hyperlink and at most a fg-only style. // Then word1 >>> STYLE_SHIFT is the foreground style — skip if it's zero // (truly invisible) or matches the last rendered style on this line. if (charId === 0 && (word1 & 0x3fffc) === 0) { const fgStyle = word1 >>> STYLE_SHIFT if (fgStyle === 0 || fgStyle === lastRenderedStyleId) return undefined } const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK return { char: charPool.get(charId), styleId: word1 >>> STYLE_SHIFT, width: word1 & WIDTH_MASK, hyperlink: hid === 0 ? undefined : hyperlinkPool.get(hid), } } /** Write cell data into an existing Cell object to avoid allocation. */ function cellAtCI(screen: Screen, ci: number, out: Cell): void { const w1 = ci | 1 const word1 = screen.cells[w1]! out.char = screen.charPool.get(screen.cells[ci]!) out.styleId = word1 >>> STYLE_SHIFT out.width = word1 & WIDTH_MASK const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK out.hyperlink = hid === 0 ? undefined : screen.hyperlinkPool.get(hid) } export function charInCellAt( screen: Screen, x: number, y: number, ): string | undefined { if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return undefined const ci = (y * screen.width + x) << 1 return screen.charPool.get(screen.cells[ci]!) } /** Set a cell, optionally creating a spacer for wide characters. */ export function setCellAt( screen: Screen, x: number, y: number, cell: Cell, ): void { if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return const ci = (y * screen.width + x) << 1 const cells = screen.cells // When a Wide char is overwritten by a Narrow char, its SpacerTail remains // as a ghost cell that the diff/render pipeline skips, causing stale content // to leak through from previous frames. const prevWidth = cells[ci + 1]! & WIDTH_MASK if (prevWidth === CellWidth.Wide && cell.width !== CellWidth.Wide) { const spacerX = x + 1 if (spacerX < screen.width) { const spacerCI = ci + 2 if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { cells[spacerCI] = EMPTY_CHAR_INDEX cells[spacerCI + 1] = packWord1( screen.emptyStyleId, 0, CellWidth.Narrow, ) } } } // Track cleared Wide position for damage expansion below let clearedWideX = -1 if ( prevWidth === CellWidth.SpacerTail && cell.width !== CellWidth.SpacerTail ) { // Overwriting a SpacerTail: clear the orphaned Wide char at (x-1). // Keeping the wide character with Narrow width would cause the terminal // to still render it with width 2, desyncing the cursor model. if (x > 0) { const wideCI = ci - 2 if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { cells[wideCI] = EMPTY_CHAR_INDEX cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) clearedWideX = x - 1 } } } // Pack cell data into cells array cells[ci] = internCharString(screen, cell.char) cells[ci + 1] = packWord1( cell.styleId, internHyperlink(screen, cell.hyperlink), cell.width, ) // Track damage - expand bounds in place instead of allocating new objects // Include the main cell position and any cleared orphan cells const minX = clearedWideX >= 0 ? Math.min(x, clearedWideX) : x const damage = screen.damage if (damage) { const right = damage.x + damage.width const bottom = damage.y + damage.height if (minX < damage.x) { damage.width += damage.x - minX damage.x = minX } else if (x >= right) { damage.width = x - damage.x + 1 } if (y < damage.y) { damage.height += damage.y - y damage.y = y } else if (y >= bottom) { damage.height = y - damage.y + 1 } } else { screen.damage = { x: minX, y, width: x - minX + 1, height: 1 } } // If this is a wide character, create a spacer in the next column if (cell.width === CellWidth.Wide) { const spacerX = x + 1 if (spacerX < screen.width) { const spacerCI = ci + 2 // If the cell we're overwriting with our SpacerTail is itself Wide, // clear ITS SpacerTail at x+2 too. Otherwise the orphan SpacerTail // makes diffEach report it as `added` and log-update's skip-spacer // rule prevents clearing whatever prev content was at that column. // Scenario: [a, 💻, spacer] → [本, spacer, ORPHAN spacer] when // yoga squishes a💻 to height 0 and 本 renders at the same y. if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { const orphanCI = spacerCI + 2 if ( spacerX + 1 < screen.width && (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail ) { cells[orphanCI] = EMPTY_CHAR_INDEX cells[orphanCI + 1] = packWord1( screen.emptyStyleId, 0, CellWidth.Narrow, ) } } cells[spacerCI] = SPACER_CHAR_INDEX cells[spacerCI + 1] = packWord1( screen.emptyStyleId, 0, CellWidth.SpacerTail, ) // Expand damage to include SpacerTail so diff() scans it const d = screen.damage if (d && spacerX >= d.x + d.width) { d.width = spacerX - d.x + 1 } } } } export function setCellStyleId( screen: Screen, x: number, y: number, styleId: number, ): void { if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return const ci = (y * screen.width + x) << 1 const cells = screen.cells const word1 = cells[ci + 1]! const width = word1 & WIDTH_MASK // Skip spacer cells — inverse on the head cell visually covers both columns if (width === CellWidth.SpacerTail || width === CellWidth.SpacerHead) return const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK cells[ci + 1] = packWord1(styleId, hid, width) // Expand damage so diffEach scans this cell const d = screen.damage if (d) { screen.damage = unionRect(d, { x, y, width: 1, height: 1 }) } else { screen.damage = { x, y, width: 1, height: 1 } } } /** Intern a character string via the screen's shared CharPool. */ function internCharString(screen: Screen, char: string): number { return screen.charPool.intern(char) } /** Bulk-copy a rectangular region from src to dst using TypedArray.set(). */ export function blitRegion( dst: Screen, src: Screen, regionX: number, regionY: number, maxX: number, maxY: number, ): void { regionX = Math.max(0, regionX) regionY = Math.max(0, regionY) if (regionX >= maxX || regionY >= maxY) return const rowLen = maxX - regionX const srcStride = src.width << 1 const dstStride = dst.width << 1 const rowBytes = rowLen << 1 // 2 Int32s per cell const srcCells = src.cells const dstCells = dst.cells const srcNoSel = src.noSelect const dstNoSel = dst.noSelect // softWrap is per-row — copy the row range regardless of stride/width. // Partial-width blits still carry the row's wrap provenance since the // blitted content (a cached ink-text node) is what set the bit. dst.softWrap.set(src.softWrap.subarray(regionY, maxY), regionY) // Fast path: contiguous memory when copying full-width rows at same stride if (regionX === 0 && maxX === src.width && src.width === dst.width) { const srcStart = regionY * srcStride const totalBytes = (maxY - regionY) * srcStride dstCells.set( srcCells.subarray(srcStart, srcStart + totalBytes), srcStart, // srcStart === dstStart when strides match and regionX === 0 ) // noSelect is 1 byte/cell vs cells' 8 — same region, different scale const nsStart = regionY * src.width const nsLen = (maxY - regionY) * src.width dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart) } else { // Per-row copy for partial-width or mismatched-stride regions let srcRowCI = regionY * srcStride + (regionX << 1) let dstRowCI = regionY * dstStride + (regionX << 1) let srcRowNS = regionY * src.width + regionX let dstRowNS = regionY * dst.width + regionX for (let y = regionY; y < maxY; y++) { dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI) dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS) srcRowCI += srcStride dstRowCI += dstStride srcRowNS += src.width dstRowNS += dst.width } } // Compute damage once for the whole region const regionRect = { x: regionX, y: regionY, width: rowLen, height: maxY - regionY, } if (dst.damage) { dst.damage = unionRect(dst.damage, regionRect) } else { dst.damage = regionRect } // Handle wide char at right edge: spacer might be outside blit region // but still within dst bounds. Per-row check only at the boundary column. if (maxX < dst.width) { let srcLastCI = (regionY * src.width + (maxX - 1)) << 1 let dstSpacerCI = (regionY * dst.width + maxX) << 1 let wroteSpacerOutsideRegion = false for (let y = regionY; y < maxY; y++) { if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { dstCells[dstSpacerCI] = SPACER_CHAR_INDEX dstCells[dstSpacerCI + 1] = packWord1( dst.emptyStyleId, 0, CellWidth.SpacerTail, ) wroteSpacerOutsideRegion = true } srcLastCI += srcStride dstSpacerCI += dstStride } // Expand damage to include SpacerTail column if we wrote any if (wroteSpacerOutsideRegion && dst.damage) { const rightEdge = dst.damage.x + dst.damage.width if (rightEdge === maxX) { dst.damage = { ...dst.damage, width: dst.damage.width + 1 } } } } } /** Bulk-clear a rectangular region of the screen. */ export function clearRegion( screen: Screen, regionX: number, regionY: number, regionWidth: number, regionHeight: number, ): void { const startX = Math.max(0, regionX) const startY = Math.max(0, regionY) const maxX = Math.min(regionX + regionWidth, screen.width) const maxY = Math.min(regionY + regionHeight, screen.height) if (startX >= maxX || startY >= maxY) return const cells = screen.cells const cells64 = screen.cells64 const screenWidth = screen.width const rowBase = startY * screenWidth let damageMinX = startX let damageMaxX = maxX // EMPTY_CELL_VALUE (0n) matches the zero-initialized state: // word0=EMPTY_CHAR_INDEX(0), word1=packWord1(0,0,0)=0 if (startX === 0 && maxX === screenWidth) { // Full-width: single fill, no boundary checks needed cells64.fill( EMPTY_CELL_VALUE, rowBase, rowBase + (maxY - startY) * screenWidth, ) } else { // Partial-width: single loop handles boundary cleanup and fill per row. const stride = screenWidth << 1 // 2 Int32s per cell const rowLen = maxX - startX const checkLeft = startX > 0 const checkRight = maxX < screenWidth let leftEdge = (rowBase + startX) << 1 let rightEdge = (rowBase + maxX - 1) << 1 let fillStart = rowBase + startX for (let y = startY; y < maxY; y++) { // Left boundary: if cell at startX is a SpacerTail, the Wide char // at startX-1 (outside the region) will be orphaned. Clear it. if (checkLeft) { // leftEdge points to word0 of cell at startX; +1 is its word1 if ((cells[leftEdge + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { // word1 of cell at startX-1 is leftEdge-1; word0 is leftEdge-2 const prevW1 = leftEdge - 1 if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) { cells[prevW1 - 1] = EMPTY_CHAR_INDEX cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) damageMinX = startX - 1 } } } // Right boundary: if cell at maxX-1 is Wide, its SpacerTail at maxX // (outside the region) will be orphaned. Clear it. if (checkRight) { // rightEdge points to word0 of cell at maxX-1; +1 is its word1 if ((cells[rightEdge + 1]! & WIDTH_MASK) === CellWidth.Wide) { // word1 of cell at maxX is rightEdge+3 (+2 to next word0, +1 to word1) const nextW1 = rightEdge + 3 if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) { cells[nextW1 - 1] = EMPTY_CHAR_INDEX cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) damageMaxX = maxX + 1 } } } cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen) leftEdge += stride rightEdge += stride fillStart += screenWidth } } // Update damage once for the whole region const regionRect = { x: damageMinX, y: startY, width: damageMaxX - damageMinX, height: maxY - startY, } if (screen.damage) { screen.damage = unionRect(screen.damage, regionRect) } else { screen.damage = regionRect } } /** Shift full-width rows within [top, bottom] (inclusive, 0-indexed) by n. */ export function shiftRows( screen: Screen, top: number, bottom: number, n: number, ): void { if (n === 0 || top < 0 || bottom >= screen.height || top > bottom) return const w = screen.width const cells64 = screen.cells64 const noSel = screen.noSelect const sw = screen.softWrap const absN = Math.abs(n) if (absN > bottom - top) { cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w) noSel.fill(0, top * w, (bottom + 1) * w) sw.fill(0, top, bottom + 1) return } if (n > 0) { // SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) sw.copyWithin(top, top + n, bottom + 1) cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w) noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w) sw.fill(0, bottom - n + 1, bottom + 1) } else { // SD: row top..bottom+n → top-n..bottom; clear top..top-n-1 cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) sw.copyWithin(top - n, top, bottom + n + 1) cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w) noSel.fill(0, top * w, (top - n) * w) sw.fill(0, top, top - n) } } // Matches OSC 8 ; ; URI BEL const OSC8_REGEX = new RegExp(`^${ESC}\\]8${SEP}${SEP}([^${BEL}]*)${BEL}$`) // OSC8 prefix: ESC ] 8 ; — cheap check to skip regex for the vast majority of styles (SGR = ESC [) export const OSC8_PREFIX = `${ESC}]8${SEP}` export function extractHyperlinkFromStyles( styles: AnsiCode[], ): Hyperlink | null { for (const style of styles) { const code = style.code if (code.length < 5 || !code.startsWith(OSC8_PREFIX)) continue const match = code.match(OSC8_REGEX) if (match) { return match[1] || null } } return null } export function filterOutHyperlinkStyles(styles: AnsiCode[]): AnsiCode[] { return styles.filter( style => !style.code.startsWith(OSC8_PREFIX) || !OSC8_REGEX.test(style.code), ) } // --- /** Returns an array of all changes between two screens. */ export function diff( prev: Screen, next: Screen, ): [point: Point, removed: Cell | undefined, added: Cell | undefined][] { const output: [Point, Cell | undefined, Cell | undefined][] = [] diffEach(prev, next, (x, y, removed, added) => { // Copy cells since diffEach reuses the objects output.push([ { x, y }, removed ? { ...removed } : undefined, added ? { ...added } : undefined, ]) }) return output } type DiffCallback = ( x: number, y: number, removed: Cell | undefined, added: Cell | undefined, ) => boolean | void /** Like diff(), but calls a callback for each change instead of building an array. */ export function diffEach( prev: Screen, next: Screen, cb: DiffCallback, ): boolean { const prevWidth = prev.width const nextWidth = next.width const prevHeight = prev.height const nextHeight = next.height let region: Rectangle if (prevWidth === 0 && prevHeight === 0) { region = { x: 0, y: 0, width: nextWidth, height: nextHeight } } else if (next.damage) { region = next.damage if (prev.damage) { region = unionRect(region, prev.damage) } } else if (prev.damage) { region = prev.damage } else { region = { x: 0, y: 0, width: 0, height: 0 } } if (prevHeight > nextHeight) { region = unionRect(region, { x: 0, y: nextHeight, width: prevWidth, height: prevHeight - nextHeight, }) } if (prevWidth > nextWidth) { region = unionRect(region, { x: nextWidth, y: 0, width: prevWidth - nextWidth, height: prevHeight, }) } const maxHeight = Math.max(prevHeight, nextHeight) const maxWidth = Math.max(prevWidth, nextWidth) const endY = Math.min(region.y + region.height, maxHeight) const endX = Math.min(region.x + region.width, maxWidth) if (prevWidth === nextWidth) { return diffSameWidth(prev, next, region.x, endX, region.y, endY, cb) } return diffDifferentWidth(prev, next, region.x, endX, region.y, endY, cb) } /** Scan for the next cell that differs between two Int32Arrays. */ function findNextDiff( a: Int32Array, b: Int32Array, w0: number, count: number, ): number { for (let i = 0; i < count; i++, w0 += 2) { const w1 = w0 | 1 if (a[w0] !== b[w0] || a[w1] !== b[w1]) return i } return count } /** Diff one row where both screens are in bounds. */ function diffRowBoth( prevCells: Int32Array, nextCells: Int32Array, prev: Screen, next: Screen, ci: number, y: number, startX: number, endX: number, prevCell: Cell, nextCell: Cell, cb: DiffCallback, ): boolean { let x = startX while (x < endX) { const skip = findNextDiff(prevCells, nextCells, ci, endX - x) x += skip ci += skip << 1 if (x >= endX) break cellAtCI(prev, ci, prevCell) cellAtCI(next, ci, nextCell) if (cb(x, y, prevCell, nextCell)) return true x++ ci += 2 } return false } /** Emit removals for a row that only exists in prev (height shrank). */ function diffRowRemoved( prev: Screen, ci: number, y: number, startX: number, endX: number, prevCell: Cell, cb: DiffCallback, ): boolean { for (let x = startX; x < endX; x++, ci += 2) { cellAtCI(prev, ci, prevCell) if (cb(x, y, prevCell, undefined)) return true } return false } /** Emit additions for a row that only exists in next (height grew). */ function diffRowAdded( nextCells: Int32Array, next: Screen, ci: number, y: number, startX: number, endX: number, nextCell: Cell, cb: DiffCallback, ): boolean { for (let x = startX; x < endX; x++, ci += 2) { if (nextCells[ci] === 0 && nextCells[ci | 1] === 0) continue cellAtCI(next, ci, nextCell) if (cb(x, y, undefined, nextCell)) return true } return false } /** Diff two screens with identical width. */ function diffSameWidth( prev: Screen, next: Screen, startX: number, endX: number, startY: number, endY: number, cb: DiffCallback, ): boolean { const prevCells = prev.cells const nextCells = next.cells const width = prev.width const prevHeight = prev.height const nextHeight = next.height const stride = width << 1 const prevCell: Cell = { char: ' ', styleId: 0, width: CellWidth.Narrow, hyperlink: undefined, } const nextCell: Cell = { char: ' ', styleId: 0, width: CellWidth.Narrow, hyperlink: undefined, } const rowEndX = Math.min(endX, width) let rowCI = (startY * width + startX) << 1 for (let y = startY; y < endY; y++) { const prevIn = y < prevHeight const nextIn = y < nextHeight if (prevIn && nextIn) { if ( diffRowBoth( prevCells, nextCells, prev, next, rowCI, y, startX, rowEndX, prevCell, nextCell, cb, ) ) return true } else if (prevIn) { if (diffRowRemoved(prev, rowCI, y, startX, rowEndX, prevCell, cb)) return true } else if (nextIn) { if ( diffRowAdded(nextCells, next, rowCI, y, startX, rowEndX, nextCell, cb) ) return true } rowCI += stride } return false } /** Fallback: diff two screens with different widths (resize). */ function diffDifferentWidth( prev: Screen, next: Screen, startX: number, endX: number, startY: number, endY: number, cb: DiffCallback, ): boolean { const prevWidth = prev.width const nextWidth = next.width const prevCells = prev.cells const nextCells = next.cells const prevCell: Cell = { char: ' ', styleId: 0, width: CellWidth.Narrow, hyperlink: undefined, } const nextCell: Cell = { char: ' ', styleId: 0, width: CellWidth.Narrow, hyperlink: undefined, } const prevStride = prevWidth << 1 const nextStride = nextWidth << 1 let prevRowCI = (startY * prevWidth + startX) << 1 let nextRowCI = (startY * nextWidth + startX) << 1 for (let y = startY; y < endY; y++) { const prevIn = y < prev.height const nextIn = y < next.height const prevEndX = prevIn ? Math.min(endX, prevWidth) : startX const nextEndX = nextIn ? Math.min(endX, nextWidth) : startX const bothEndX = Math.min(prevEndX, nextEndX) let prevCI = prevRowCI let nextCI = nextRowCI for (let x = startX; x < bothEndX; x++) { if ( prevCells[prevCI] === nextCells[nextCI] && prevCells[prevCI + 1] === nextCells[nextCI + 1] ) { prevCI += 2 nextCI += 2 continue } cellAtCI(prev, prevCI, prevCell) cellAtCI(next, nextCI, nextCell) prevCI += 2 nextCI += 2 if (cb(x, y, prevCell, nextCell)) return true } if (prevEndX > bothEndX) { prevCI = prevRowCI + ((bothEndX - startX) << 1) for (let x = bothEndX; x < prevEndX; x++) { cellAtCI(prev, prevCI, prevCell) prevCI += 2 if (cb(x, y, prevCell, undefined)) return true } } if (nextEndX > bothEndX) { nextCI = nextRowCI + ((bothEndX - startX) << 1) for (let x = bothEndX; x < nextEndX; x++) { if (nextCells[nextCI] === 0 && nextCells[nextCI | 1] === 0) { nextCI += 2 continue } cellAtCI(next, nextCI, nextCell) nextCI += 2 if (cb(x, y, undefined, nextCell)) return true } } prevRowCI += prevStride nextRowCI += nextStride } return false } /** Mark a rectangular region as noSelect (exclude from text selection). */ export function markNoSelectRegion( screen: Screen, x: number, y: number, width: number, height: number, ): void { const maxX = Math.min(x + width, screen.width) const maxY = Math.min(y + height, screen.height) const noSel = screen.noSelect const stride = screen.width for (let row = Math.max(0, y); row < maxY; row++) { const rowStart = row * stride noSel.fill(1, rowStart + Math.max(0, x), rowStart + maxX) } }