π File detail
ink/screen.ts
π§© .tsπ 1,487 linesπΎ 49,323 bytesπ text
β Back to All Filesπ― Use case
This file lives under βink/β, which covers Ink terminal UI (layouts, TTY IO, keyboard, renderer components). On the API surface it exposes CharPool, HyperlinkPool, StylePool, enum, and Hyperlink (and more) β mainly functions, hooks, or classes. Dependencies touch @alcalzone. It composes internal code from layout, termio, and warn (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { type AnsiCode, ansiCodesToString, diffAnsiCodes, } from '@alcalzone/ansi-tokenize'
π€ Exports (heuristic)
CharPoolHyperlinkPoolStylePoolenumHyperlinkCellScreenisEmptyCellAtisCellEmptycreateScreenresetScreenmigrateScreenPoolscellAtcellAtIndexvisibleCellAtIndexcharInCellAtsetCellAtsetCellStyleIdblitRegionclearRegionshiftRowsOSC8_PREFIXextractHyperlinkFromStylesfilterOutHyperlinkStylesdiffdiffEachmarkNoSelectRegion
π External import roots
Package roots from from "β¦" (relative paths omitted).
@alcalzone
π₯οΈ Source preview
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)
}
}