1487 lines
48 KiB
TypeScript
1487 lines
48 KiB
TypeScript
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) ---
|
|
|
|
// Character string pool shared across all screens.
|
|
// With a shared pool, interned char IDs are valid across screens,
|
|
// so blitRegion can copy IDs directly (no re-interning) and
|
|
// diffEach can compare IDs as integers (no string lookup).
|
|
export class CharPool {
|
|
private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty (spacer)
|
|
private stringMap = new Map<string, number>([
|
|
[' ', 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<string, number>()
|
|
|
|
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 an AnsiCode. endCode '\x1b[27m' flags VISIBLE_ON_SPACE
|
|
// so bit 0 of the resulting styleId is set → renderer won't skip inverted
|
|
// spaces as invisible.
|
|
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). Kept alongside yellow+bold — the underline is the
|
|
// unambiguous visible-on-any-theme marker. Yellow-bg-via-inverse can
|
|
// clash with existing bg colors (user-prompt style, tool chrome, syntax
|
|
// bg). If you see underline but no yellow, the yellow is being lost in
|
|
// the existing cell styling — the overlay IS finding the match.
|
|
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<string, number>()
|
|
private styles: AnsiCode[][] = []
|
|
private transitionCache = new Map<number, string>()
|
|
readonly none: number
|
|
|
|
constructor() {
|
|
this.none = this.intern([])
|
|
}
|
|
|
|
/**
|
|
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
|
|
* style has a visible effect on space characters (background, inverse,
|
|
* underline, etc.). Foreground-only styles get even IDs; styles visible
|
|
* on spaces get odd IDs. This lets the renderer skip invisible spaces
|
|
* with a single bitmask check on the packed word.
|
|
*/
|
|
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] ?? []
|
|
}
|
|
|
|
/**
|
|
* Returns the pre-serialized ANSI string to transition from one style to
|
|
* another. Cached by (fromId, toId) — zero allocations after first call
|
|
* for a given pair.
|
|
*/
|
|
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`. Cached by base ID so
|
|
* repeated calls for the same underlying style don't re-scan the
|
|
* AnsiCode[] array. Used by the selection overlay.
|
|
*/
|
|
private inverseCache = new Map<number, number>()
|
|
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-via-fg-swap for the CURRENT search match.
|
|
* OTHER matches are plain inverse — bg inherits from the theme. Current
|
|
* gets a distinct yellow bg (via fg-then-inverse swap) plus bold weight
|
|
* so it stands out in a sea of inverse. Underline was too subtle. Zero
|
|
* reflow risk: all pure SGR overlays, per-cell, post-layout. The yellow
|
|
* overrides any existing fg (syntax highlighting) on those cells — fine,
|
|
* the "you are here" signal IS the point, syntax color can yield. */
|
|
private currentMatchCache = new Map<number, number>()
|
|
withCurrentMatch(baseId: number): number {
|
|
let id = this.currentMatchCache.get(baseId)
|
|
if (id === undefined) {
|
|
const baseCodes = this.get(baseId)
|
|
// Filter BOTH fg + bg so yellow-via-inverse is unambiguous.
|
|
// User-prompt cells have an explicit bg (grey box); with that bg
|
|
// still set, inverse swaps yellow-fg↔grey-bg → grey-on-yellow on
|
|
// SOME terminals, yellow-on-grey on others (inverse semantics vary
|
|
// when both colors are explicit). Filtering both gives clean
|
|
// yellow-bg + terminal-default-fg everywhere. Bold/dim/italic
|
|
// coexist — keep those.
|
|
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 overlay: REPLACE the cell's background with a solid color
|
|
* while preserving its foreground (color, bold, italic, dim, underline).
|
|
* Matches native terminal selection — a dedicated bg color, not SGR-7
|
|
* inverse. Inverse swaps fg/bg per-cell, which fragments visually over
|
|
* syntax-highlighted text (every fg color becomes a different bg stripe).
|
|
*
|
|
* Strips any existing bg (endCode 49m — REPLACES, so diff-added green
|
|
* etc. don't bleed through) and any existing inverse (endCode 27m —
|
|
* inverse on top of a solid bg would re-swap and look wrong).
|
|
*
|
|
* bg is set via setSelectionBg(); null → fallback to withInverse() so the
|
|
* overlay still works before theme wiring sets a color (tests, first frame).
|
|
* Cache is keyed by baseId only — setSelectionBg() clears it on change.
|
|
*/
|
|
private selectionBgCode: AnsiCode | null = null
|
|
private selectionBgCache = new Map<number, number>()
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Cell width classification for handling double-wide characters (CJK, emoji,
|
|
* etc.)
|
|
*
|
|
* We use explicit spacer cells rather than inferring width at render time. This
|
|
* makes the data structure self-describing and simplifies cursor positioning
|
|
* logic.
|
|
*
|
|
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
|
|
*/
|
|
// 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(). Cells are stored as packed typed
|
|
* arrays internally to avoid GC pressure from allocating objects per cell.
|
|
*/
|
|
export type Cell = {
|
|
char: string
|
|
styleId: number
|
|
width: CellWidth
|
|
hyperlink: Hyperlink
|
|
}
|
|
|
|
// Constants for empty/spacer cells to enable fast comparisons
|
|
// These are indices into the charStrings table, not codepoints
|
|
const EMPTY_CHAR_INDEX = 0 // ' ' (space)
|
|
const SPACER_CHAR_INDEX = 1 // '' (empty string for spacer cells)
|
|
// Unwritten cells are [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0].
|
|
// Since StylePool.none is always 0 (first intern), unwritten cells are
|
|
// indistinguishable from explicitly-cleared cells in the packed array.
|
|
// This is intentional: diffEach can compare raw ints with zero normalization.
|
|
// isEmptyCellByIndex checks if both words are 0 to identify "never visually written" cells.
|
|
|
|
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
|
|
|
|
/**
|
|
* Screen uses a packed Int32Array instead of Cell objects to eliminate GC
|
|
* pressure. For a 200x120 screen, this avoids allocating 24,000 objects.
|
|
*
|
|
* Cell data is stored as 2 Int32s per cell in a single contiguous array:
|
|
* word0: charId (full 32 bits — index into CharPool)
|
|
* word1: styleId[31:17] | hyperlinkId[16:2] | width[1:0]
|
|
*
|
|
* This layout halves memory accesses in diffEach (2 int loads vs 4) and
|
|
* enables future SIMD comparison via Bun.indexOfFirstDifference.
|
|
*/
|
|
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.
|
|
* Used by diff() to limit iteration to only the region that could have changed.
|
|
*/
|
|
damage: Rectangle | undefined
|
|
|
|
/**
|
|
* Per-cell noSelect bitmap — 1 byte per cell, 1 = exclude from text
|
|
* selection (copy + highlight). Used by <NoSelect> to mark gutters
|
|
* (line numbers, diff sigils) so click-drag over a diff yields clean
|
|
* copyable code. Fully reset each frame in resetScreen; blitRegion
|
|
* copies it alongside cells so the blit optimization preserves marks.
|
|
*/
|
|
noSelect: Uint8Array
|
|
|
|
/**
|
|
* Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
|
|
* is a word-wrap continuation of row r-1 (the `\n` before it was
|
|
* inserted by wrapAnsi, not in the source), and row r-1's written
|
|
* content ends at absolute column N (exclusive — cells [0..N) are the
|
|
* fragment, past N is unwritten padding). 0 means row r is NOT a
|
|
* continuation (hard newline or first row). Selection copy checks
|
|
* softWrap[r]>0 to join row r onto row r-1 without a newline, and
|
|
* reads softWrap[r+1] to know row r's content end when row r+1
|
|
* continues from it. The content-end column is needed because an
|
|
* unwritten cell and a written-unstyled-space are indistinguishable in
|
|
* the packed typed array (both all-zero) — without it we'd either drop
|
|
* the word-separator space (trim) or include trailing padding (no
|
|
* trim). This encoding (continuation-on-self, prev-content-end-here)
|
|
* is chosen so shiftRows preserves the is-continuation semantics: when
|
|
* row r scrolls off the top and row r+1 shifts to row r, sw[r] gets
|
|
* old sw[r+1] — which correctly says the new row r is a continuation
|
|
* of what's now in scrolledOffAbove. Reset each frame; copied by
|
|
* blitRegion/shiftRows.
|
|
*/
|
|
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.
|
|
* Resizes if needed and clears all cells to empty/unwritten state.
|
|
*
|
|
* For double-buffering, this allows swapping between front and back buffers
|
|
* without allocating new Screen objects each frame.
|
|
*/
|
|
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.
|
|
* Used for generational pool reset — after migrating, the screen's
|
|
* typed arrays contain valid IDs for the new pools, and the old pools
|
|
* can be GC'd.
|
|
*
|
|
* O(width * height) but only called occasionally (e.g., between conversation turns).
|
|
*/
|
|
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. Returns a new object each call -
|
|
* this is intentional as cells are stored packed, not as objects.
|
|
*/
|
|
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. Skips bounds checks and
|
|
* index computation — caller must ensure index is valid.
|
|
*/
|
|
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.
|
|
* Returns undefined for spacer cells (charId 1), empty unstyled spaces, and
|
|
* fg-only styled spaces that match lastRenderedStyleId (cursor-forward
|
|
* produces an identical visual result, avoiding a Cell allocation).
|
|
*
|
|
* @param lastRenderedStyleId - styleId of the last rendered cell on this
|
|
* line, or -1 if none yet.
|
|
*/
|
|
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.
|
|
* Caller must ensure index is valid.
|
|
*/
|
|
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.
|
|
*
|
|
* Wide characters (CJK, emoji) occupy 2 cells in the buffer:
|
|
* 1. First cell: Contains the actual character with width = Wide
|
|
* 2. Second cell: Spacer cell with width = SpacerTail (empty, not rendered)
|
|
*
|
|
* If the cell has width = Wide, this function automatically creates the
|
|
* corresponding SpacerTail in the next column. This two-cell model keeps
|
|
* the buffer aligned to visual columns, making cursor positioning
|
|
* straightforward.
|
|
*
|
|
* TODO: When soft-wrapping is implemented, SpacerHead cells will be explicitly
|
|
* placed by the wrapping logic at line-end positions where wide characters
|
|
* wrap to the next line. This function doesn't need to handle SpacerHead
|
|
* automatically - it will be set directly by the wrapping code.
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace the styleId of a cell in-place without disturbing char, width,
|
|
* or hyperlink. Preserves empty cells as-is (char stays ' '). Tracks damage
|
|
* for the cell so diffEach picks up the change.
|
|
*/
|
|
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.
|
|
* Supports grapheme clusters like family emoji.
|
|
*/
|
|
function internCharString(screen: Screen, char: string): number {
|
|
return screen.charPool.intern(char)
|
|
}
|
|
|
|
/**
|
|
* Bulk-copy a rectangular region from src to dst using TypedArray.set().
|
|
* Single cells.set() call per row (or one call for contiguous blocks).
|
|
* Damage is computed once for the whole region.
|
|
*
|
|
* Clamps negative regionX/regionY to 0 (matching clearRegion) — absolute-
|
|
* positioned overlays in tiny terminals can compute negative screen coords.
|
|
* maxX/maxY should already be clamped to both screen bounds by the caller.
|
|
*/
|
|
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.
|
|
* Uses BigInt64Array.fill() for fast row clears.
|
|
* Handles wide character boundary cleanup at region edges.
|
|
*/
|
|
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.
|
|
* n > 0 shifts UP (simulating CSI n S); n < 0 shifts DOWN (CSI n T).
|
|
* Vacated rows are cleared. Does NOT update damage. Both cells and the
|
|
* noSelect bitmap are shifted so text-selection markers stay aligned when
|
|
* this is applied to next.screen during scroll fast path.
|
|
*/
|
|
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. Used by tests.
|
|
* Production code should use diffEach() to avoid allocations.
|
|
*/
|
|
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.
|
|
* Reuses two Cell objects to avoid per-change allocations. The callback must not
|
|
* retain references to the Cell objects — their contents are overwritten each call.
|
|
*
|
|
* Returns true if the callback ever returned true (early exit signal).
|
|
*/
|
|
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.
|
|
* Returns the number of matching cells before the first difference,
|
|
* or `count` if all cells match. Tiny and pure for JIT inlining.
|
|
*/
|
|
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.
|
|
* Scans for differences with findNextDiff, unpacks and calls cb for each.
|
|
*/
|
|
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).
|
|
* Cannot skip empty cells — the terminal still has content from the
|
|
* previous frame that needs to be cleared.
|
|
*/
|
|
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).
|
|
* Skips empty/unwritten cells.
|
|
*/
|
|
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.
|
|
* Dispatches each row to a small, JIT-friendly function.
|
|
*/
|
|
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).
|
|
* Separate indices for prev and next cells arrays.
|
|
*/
|
|
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).
|
|
* Clamps to screen bounds. Called from output.ts when a <NoSelect> box
|
|
* renders. No damage tracking — noSelect doesn't affect terminal output,
|
|
* only getSelectedText/applySelectionOverlay which read it directly.
|
|
*/
|
|
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)
|
|
}
|
|
}
|