source dump of claude code
at main 1486 lines 49 kB view raw
1import { 2 type AnsiCode, 3 ansiCodesToString, 4 diffAnsiCodes, 5} from '@alcalzone/ansi-tokenize' 6import { 7 type Point, 8 type Rectangle, 9 type Size, 10 unionRect, 11} from './layout/geometry.js' 12import { BEL, ESC, SEP } from './termio/ansi.js' 13import * as warn from './warn.js' 14 15// --- Shared Pools (interning for memory efficiency) --- 16 17// Character string pool shared across all screens. 18// With a shared pool, interned char IDs are valid across screens, 19// so blitRegion can copy IDs directly (no re-interning) and 20// diffEach can compare IDs as integers (no string lookup). 21export class CharPool { 22 private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty (spacer) 23 private stringMap = new Map<string, number>([ 24 [' ', 0], 25 ['', 1], 26 ]) 27 private ascii: Int32Array = initCharAscii() // charCode → index, -1 = not interned 28 29 intern(char: string): number { 30 // ASCII fast-path: direct array lookup instead of Map.get 31 if (char.length === 1) { 32 const code = char.charCodeAt(0) 33 if (code < 128) { 34 const cached = this.ascii[code]! 35 if (cached !== -1) return cached 36 const index = this.strings.length 37 this.strings.push(char) 38 this.ascii[code] = index 39 return index 40 } 41 } 42 const existing = this.stringMap.get(char) 43 if (existing !== undefined) return existing 44 const index = this.strings.length 45 this.strings.push(char) 46 this.stringMap.set(char, index) 47 return index 48 } 49 50 get(index: number): string { 51 return this.strings[index] ?? ' ' 52 } 53} 54 55// Hyperlink string pool shared across all screens. 56// Index 0 = no hyperlink. 57export class HyperlinkPool { 58 private strings: string[] = [''] // Index 0 = no hyperlink 59 private stringMap = new Map<string, number>() 60 61 intern(hyperlink: string | undefined): number { 62 if (!hyperlink) return 0 63 let id = this.stringMap.get(hyperlink) 64 if (id === undefined) { 65 id = this.strings.length 66 this.strings.push(hyperlink) 67 this.stringMap.set(hyperlink, id) 68 } 69 return id 70 } 71 72 get(id: number): string | undefined { 73 return id === 0 ? undefined : this.strings[id] 74 } 75} 76 77// SGR 7 (inverse) as an AnsiCode. endCode '\x1b[27m' flags VISIBLE_ON_SPACE 78// so bit 0 of the resulting styleId is set → renderer won't skip inverted 79// spaces as invisible. 80const INVERSE_CODE: AnsiCode = { 81 type: 'ansi', 82 code: '\x1b[7m', 83 endCode: '\x1b[27m', 84} 85// Bold (SGR 1) — stacks cleanly, no reflow in monospace. endCode 22 86// also cancels dim (SGR 2); harmless here since we never add dim. 87const BOLD_CODE: AnsiCode = { 88 type: 'ansi', 89 code: '\x1b[1m', 90 endCode: '\x1b[22m', 91} 92// Underline (SGR 4). Kept alongside yellow+bold — the underline is the 93// unambiguous visible-on-any-theme marker. Yellow-bg-via-inverse can 94// clash with existing bg colors (user-prompt style, tool chrome, syntax 95// bg). If you see underline but no yellow, the yellow is being lost in 96// the existing cell styling — the overlay IS finding the match. 97const UNDERLINE_CODE: AnsiCode = { 98 type: 'ansi', 99 code: '\x1b[4m', 100 endCode: '\x1b[24m', 101} 102// fg→yellow (SGR 33). With inverse already in the stack, the terminal 103// swaps fg↔bg at render — so yellow-fg becomes yellow-BG. Original bg 104// becomes fg (readable on most themes: dark-bg → dark-text on yellow). 105// endCode 39 is 'default fg' — cancels any prior fg color cleanly. 106const YELLOW_FG_CODE: AnsiCode = { 107 type: 'ansi', 108 code: '\x1b[33m', 109 endCode: '\x1b[39m', 110} 111 112export class StylePool { 113 private ids = new Map<string, number>() 114 private styles: AnsiCode[][] = [] 115 private transitionCache = new Map<number, string>() 116 readonly none: number 117 118 constructor() { 119 this.none = this.intern([]) 120 } 121 122 /** 123 * Intern a style and return its ID. Bit 0 of the ID encodes whether the 124 * style has a visible effect on space characters (background, inverse, 125 * underline, etc.). Foreground-only styles get even IDs; styles visible 126 * on spaces get odd IDs. This lets the renderer skip invisible spaces 127 * with a single bitmask check on the packed word. 128 */ 129 intern(styles: AnsiCode[]): number { 130 const key = styles.length === 0 ? '' : styles.map(s => s.code).join('\0') 131 let id = this.ids.get(key) 132 if (id === undefined) { 133 const rawId = this.styles.length 134 this.styles.push(styles.length === 0 ? [] : styles) 135 id = 136 (rawId << 1) | 137 (styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0) 138 this.ids.set(key, id) 139 } 140 return id 141 } 142 143 /** Recover styles from an encoded ID. Strips the bit-0 flag via >>> 1. */ 144 get(id: number): AnsiCode[] { 145 return this.styles[id >>> 1] ?? [] 146 } 147 148 /** 149 * Returns the pre-serialized ANSI string to transition from one style to 150 * another. Cached by (fromId, toId) — zero allocations after first call 151 * for a given pair. 152 */ 153 transition(fromId: number, toId: number): string { 154 if (fromId === toId) return '' 155 const key = fromId * 0x100000 + toId 156 let str = this.transitionCache.get(key) 157 if (str === undefined) { 158 str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId))) 159 this.transitionCache.set(key, str) 160 } 161 return str 162 } 163 164 /** 165 * Intern a style that is `base + inverse`. Cached by base ID so 166 * repeated calls for the same underlying style don't re-scan the 167 * AnsiCode[] array. Used by the selection overlay. 168 */ 169 private inverseCache = new Map<number, number>() 170 withInverse(baseId: number): number { 171 let id = this.inverseCache.get(baseId) 172 if (id === undefined) { 173 const baseCodes = this.get(baseId) 174 // If already inverted, use as-is (avoids SGR 7 stacking) 175 const hasInverse = baseCodes.some(c => c.endCode === '\x1b[27m') 176 id = hasInverse ? baseId : this.intern([...baseCodes, INVERSE_CODE]) 177 this.inverseCache.set(baseId, id) 178 } 179 return id 180 } 181 182 /** Inverse + bold + yellow-bg-via-fg-swap for the CURRENT search match. 183 * OTHER matches are plain inverse — bg inherits from the theme. Current 184 * gets a distinct yellow bg (via fg-then-inverse swap) plus bold weight 185 * so it stands out in a sea of inverse. Underline was too subtle. Zero 186 * reflow risk: all pure SGR overlays, per-cell, post-layout. The yellow 187 * overrides any existing fg (syntax highlighting) on those cells — fine, 188 * the "you are here" signal IS the point, syntax color can yield. */ 189 private currentMatchCache = new Map<number, number>() 190 withCurrentMatch(baseId: number): number { 191 let id = this.currentMatchCache.get(baseId) 192 if (id === undefined) { 193 const baseCodes = this.get(baseId) 194 // Filter BOTH fg + bg so yellow-via-inverse is unambiguous. 195 // User-prompt cells have an explicit bg (grey box); with that bg 196 // still set, inverse swaps yellow-fg↔grey-bg → grey-on-yellow on 197 // SOME terminals, yellow-on-grey on others (inverse semantics vary 198 // when both colors are explicit). Filtering both gives clean 199 // yellow-bg + terminal-default-fg everywhere. Bold/dim/italic 200 // coexist — keep those. 201 const codes = baseCodes.filter( 202 c => c.endCode !== '\x1b[39m' && c.endCode !== '\x1b[49m', 203 ) 204 // fg-yellow FIRST so inverse swaps it to bg. Bold after inverse is 205 // fine — SGR 1 is fg-attribute-only, order-independent vs 7. 206 codes.push(YELLOW_FG_CODE) 207 if (!baseCodes.some(c => c.endCode === '\x1b[27m')) 208 codes.push(INVERSE_CODE) 209 if (!baseCodes.some(c => c.endCode === '\x1b[22m')) codes.push(BOLD_CODE) 210 // Underline as the unambiguous marker — yellow-bg can clash with 211 // existing bg styling (user-prompt bg, syntax bg). If you see 212 // underline but no yellow on a match, the overlay IS finding it; 213 // the yellow is just losing a styling fight. 214 if (!baseCodes.some(c => c.endCode === '\x1b[24m')) 215 codes.push(UNDERLINE_CODE) 216 id = this.intern(codes) 217 this.currentMatchCache.set(baseId, id) 218 } 219 return id 220 } 221 222 /** 223 * Selection overlay: REPLACE the cell's background with a solid color 224 * while preserving its foreground (color, bold, italic, dim, underline). 225 * Matches native terminal selection — a dedicated bg color, not SGR-7 226 * inverse. Inverse swaps fg/bg per-cell, which fragments visually over 227 * syntax-highlighted text (every fg color becomes a different bg stripe). 228 * 229 * Strips any existing bg (endCode 49m — REPLACES, so diff-added green 230 * etc. don't bleed through) and any existing inverse (endCode 27m — 231 * inverse on top of a solid bg would re-swap and look wrong). 232 * 233 * bg is set via setSelectionBg(); null → fallback to withInverse() so the 234 * overlay still works before theme wiring sets a color (tests, first frame). 235 * Cache is keyed by baseId only — setSelectionBg() clears it on change. 236 */ 237 private selectionBgCode: AnsiCode | null = null 238 private selectionBgCache = new Map<number, number>() 239 setSelectionBg(bg: AnsiCode | null): void { 240 if (this.selectionBgCode?.code === bg?.code) return 241 this.selectionBgCode = bg 242 this.selectionBgCache.clear() 243 } 244 withSelectionBg(baseId: number): number { 245 const bg = this.selectionBgCode 246 if (bg === null) return this.withInverse(baseId) 247 let id = this.selectionBgCache.get(baseId) 248 if (id === undefined) { 249 // Keep everything except bg (49m) and inverse (27m). Fg, bold, dim, 250 // italic, underline, strikethrough all preserved. 251 const kept = this.get(baseId).filter( 252 c => c.endCode !== '\x1b[49m' && c.endCode !== '\x1b[27m', 253 ) 254 kept.push(bg) 255 id = this.intern(kept) 256 this.selectionBgCache.set(baseId, id) 257 } 258 return id 259 } 260} 261 262// endCodes that produce visible effects on space characters 263const VISIBLE_ON_SPACE = new Set([ 264 '\x1b[49m', // background color 265 '\x1b[27m', // inverse 266 '\x1b[24m', // underline 267 '\x1b[29m', // strikethrough 268 '\x1b[55m', // overline 269]) 270 271function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean { 272 for (const style of styles) { 273 if (VISIBLE_ON_SPACE.has(style.endCode)) return true 274 } 275 return false 276} 277 278/** 279 * Cell width classification for handling double-wide characters (CJK, emoji, 280 * etc.) 281 * 282 * We use explicit spacer cells rather than inferring width at render time. This 283 * makes the data structure self-describing and simplifies cursor positioning 284 * logic. 285 * 286 * @see https://mitchellh.com/writing/grapheme-clusters-in-terminals 287 */ 288// const enum is inlined at compile time - no runtime object, no property access 289export const enum CellWidth { 290 // Not a wide character, cell width 1 291 Narrow = 0, 292 // Wide character, cell width 2. This cell contains the actual character. 293 Wide = 1, 294 // Spacer occupying the second visual column of a wide character. Do not render. 295 SpacerTail = 2, 296 // Spacer at the end of a soft-wrapped line indicating that a wide character 297 // continues on the next line. Used for preserving wide character semantics 298 // across line breaks during soft wrapping. 299 SpacerHead = 3, 300} 301 302export type Hyperlink = string | undefined 303 304/** 305 * Cell is a view type returned by cellAt(). Cells are stored as packed typed 306 * arrays internally to avoid GC pressure from allocating objects per cell. 307 */ 308export type Cell = { 309 char: string 310 styleId: number 311 width: CellWidth 312 hyperlink: Hyperlink 313} 314 315// Constants for empty/spacer cells to enable fast comparisons 316// These are indices into the charStrings table, not codepoints 317const EMPTY_CHAR_INDEX = 0 // ' ' (space) 318const SPACER_CHAR_INDEX = 1 // '' (empty string for spacer cells) 319// Unwritten cells are [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0]. 320// Since StylePool.none is always 0 (first intern), unwritten cells are 321// indistinguishable from explicitly-cleared cells in the packed array. 322// This is intentional: diffEach can compare raw ints with zero normalization. 323// isEmptyCellByIndex checks if both words are 0 to identify "never visually written" cells. 324 325function initCharAscii(): Int32Array { 326 const table = new Int32Array(128) 327 table.fill(-1) 328 table[32] = EMPTY_CHAR_INDEX // ' ' (space) 329 return table 330} 331 332// --- Packed cell layout --- 333// Each cell is 2 consecutive Int32 elements in the cells array: 334// word0 (cells[ci]): charId (full 32 bits) 335// word1 (cells[ci + 1]): styleId[31:17] | hyperlinkId[16:2] | width[1:0] 336const STYLE_SHIFT = 17 337const HYPERLINK_SHIFT = 2 338const HYPERLINK_MASK = 0x7fff // 15 bits 339const WIDTH_MASK = 3 // 2 bits 340 341// Pack styleId, hyperlinkId, and width into a single Int32 342function packWord1( 343 styleId: number, 344 hyperlinkId: number, 345 width: number, 346): number { 347 return (styleId << STYLE_SHIFT) | (hyperlinkId << HYPERLINK_SHIFT) | width 348} 349 350// Unwritten cell as BigInt64 — both words are 0, so the 64-bit value is 0n. 351// Used by BigInt64Array.fill() for bulk clears (resetScreen, clearRegion). 352// Not used for comparison — BigInt element reads cause heap allocation. 353const EMPTY_CELL_VALUE = 0n 354 355/** 356 * Screen uses a packed Int32Array instead of Cell objects to eliminate GC 357 * pressure. For a 200x120 screen, this avoids allocating 24,000 objects. 358 * 359 * Cell data is stored as 2 Int32s per cell in a single contiguous array: 360 * word0: charId (full 32 bits — index into CharPool) 361 * word1: styleId[31:17] | hyperlinkId[16:2] | width[1:0] 362 * 363 * This layout halves memory accesses in diffEach (2 int loads vs 4) and 364 * enables future SIMD comparison via Bun.indexOfFirstDifference. 365 */ 366export type Screen = Size & { 367 // Packed cell data — 2 Int32s per cell: [charId, packed(styleId|hyperlinkId|width)] 368 // cells and cells64 are views over the same ArrayBuffer. 369 cells: Int32Array 370 cells64: BigInt64Array // 1 BigInt64 per cell — used for bulk fill in resetScreen/clearRegion 371 372 // Shared pools — IDs are valid across all screens using the same pools 373 charPool: CharPool 374 hyperlinkPool: HyperlinkPool 375 376 // Empty style ID for comparisons 377 emptyStyleId: number 378 379 /** 380 * Bounding box of cells that were written to (not blitted) during rendering. 381 * Used by diff() to limit iteration to only the region that could have changed. 382 */ 383 damage: Rectangle | undefined 384 385 /** 386 * Per-cell noSelect bitmap — 1 byte per cell, 1 = exclude from text 387 * selection (copy + highlight). Used by <NoSelect> to mark gutters 388 * (line numbers, diff sigils) so click-drag over a diff yields clean 389 * copyable code. Fully reset each frame in resetScreen; blitRegion 390 * copies it alongside cells so the blit optimization preserves marks. 391 */ 392 noSelect: Uint8Array 393 394 /** 395 * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r 396 * is a word-wrap continuation of row r-1 (the `\n` before it was 397 * inserted by wrapAnsi, not in the source), and row r-1's written 398 * content ends at absolute column N (exclusive — cells [0..N) are the 399 * fragment, past N is unwritten padding). 0 means row r is NOT a 400 * continuation (hard newline or first row). Selection copy checks 401 * softWrap[r]>0 to join row r onto row r-1 without a newline, and 402 * reads softWrap[r+1] to know row r's content end when row r+1 403 * continues from it. The content-end column is needed because an 404 * unwritten cell and a written-unstyled-space are indistinguishable in 405 * the packed typed array (both all-zero) — without it we'd either drop 406 * the word-separator space (trim) or include trailing padding (no 407 * trim). This encoding (continuation-on-self, prev-content-end-here) 408 * is chosen so shiftRows preserves the is-continuation semantics: when 409 * row r scrolls off the top and row r+1 shifts to row r, sw[r] gets 410 * old sw[r+1] — which correctly says the new row r is a continuation 411 * of what's now in scrolledOffAbove. Reset each frame; copied by 412 * blitRegion/shiftRows. 413 */ 414 softWrap: Int32Array 415} 416 417function isEmptyCellByIndex(screen: Screen, index: number): boolean { 418 // An empty/unwritten cell has both words === 0: 419 // word0 = EMPTY_CHAR_INDEX (0), word1 = packWord1(emptyStyleId=0, 0, 0) = 0. 420 const ci = index << 1 421 return screen.cells[ci] === 0 && screen.cells[ci | 1] === 0 422} 423 424export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean { 425 if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return true 426 return isEmptyCellByIndex(screen, y * screen.width + x) 427} 428 429/** 430 * Check if a Cell (view object) represents an empty cell. 431 */ 432export function isCellEmpty(screen: Screen, cell: Cell): boolean { 433 // Check if cell looks like an empty cell (space, empty style, narrow, no link). 434 // Note: After cellAt mapping, unwritten cells have emptyStyleId, so this 435 // returns true for both unwritten AND cleared cells. Use isEmptyCellAt 436 // for the internal distinction. 437 return ( 438 cell.char === ' ' && 439 cell.styleId === screen.emptyStyleId && 440 cell.width === CellWidth.Narrow && 441 !cell.hyperlink 442 ) 443} 444// Intern a hyperlink string and return its ID (0 = no hyperlink) 445function internHyperlink(screen: Screen, hyperlink: Hyperlink): number { 446 return screen.hyperlinkPool.intern(hyperlink) 447} 448 449// --- 450 451export function createScreen( 452 width: number, 453 height: number, 454 styles: StylePool, 455 charPool: CharPool, 456 hyperlinkPool: HyperlinkPool, 457): Screen { 458 // Warn if dimensions are not valid integers (likely bad yoga layout output) 459 warn.ifNotInteger(width, 'createScreen width') 460 warn.ifNotInteger(height, 'createScreen height') 461 462 // Ensure width and height are valid integers to prevent crashes 463 if (!Number.isInteger(width) || width < 0) { 464 width = Math.max(0, Math.floor(width) || 0) 465 } 466 if (!Number.isInteger(height) || height < 0) { 467 height = Math.max(0, Math.floor(height) || 0) 468 } 469 470 const size = width * height 471 472 // Allocate one buffer, two views: Int32Array for per-word access, 473 // BigInt64Array for bulk fill in resetScreen/clearRegion. 474 // ArrayBuffer is zero-filled, which is exactly the empty cell value: 475 // [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0]. 476 const buf = new ArrayBuffer(size << 3) // 8 bytes per cell 477 const cells = new Int32Array(buf) 478 const cells64 = new BigInt64Array(buf) 479 480 return { 481 width, 482 height, 483 cells, 484 cells64, 485 charPool, 486 hyperlinkPool, 487 emptyStyleId: styles.none, 488 damage: undefined, 489 noSelect: new Uint8Array(size), 490 softWrap: new Int32Array(height), 491 } 492} 493 494/** 495 * Reset an existing screen for reuse, avoiding allocation of new typed arrays. 496 * Resizes if needed and clears all cells to empty/unwritten state. 497 * 498 * For double-buffering, this allows swapping between front and back buffers 499 * without allocating new Screen objects each frame. 500 */ 501export function resetScreen( 502 screen: Screen, 503 width: number, 504 height: number, 505): void { 506 // Warn if dimensions are not valid integers 507 warn.ifNotInteger(width, 'resetScreen width') 508 warn.ifNotInteger(height, 'resetScreen height') 509 510 // Ensure width and height are valid integers to prevent crashes 511 if (!Number.isInteger(width) || width < 0) { 512 width = Math.max(0, Math.floor(width) || 0) 513 } 514 if (!Number.isInteger(height) || height < 0) { 515 height = Math.max(0, Math.floor(height) || 0) 516 } 517 518 const size = width * height 519 520 // Resize if needed (only grow, to avoid reallocations) 521 if (screen.cells64.length < size) { 522 const buf = new ArrayBuffer(size << 3) 523 screen.cells = new Int32Array(buf) 524 screen.cells64 = new BigInt64Array(buf) 525 screen.noSelect = new Uint8Array(size) 526 } 527 if (screen.softWrap.length < height) { 528 screen.softWrap = new Int32Array(height) 529 } 530 531 // Reset all cells — single fill call, no loop 532 screen.cells64.fill(EMPTY_CELL_VALUE, 0, size) 533 screen.noSelect.fill(0, 0, size) 534 screen.softWrap.fill(0, 0, height) 535 536 // Update dimensions 537 screen.width = width 538 screen.height = height 539 540 // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded. 541 542 // Clear damage tracking 543 screen.damage = undefined 544} 545 546/** 547 * Re-intern a screen's char and hyperlink IDs into new pools. 548 * Used for generational pool reset — after migrating, the screen's 549 * typed arrays contain valid IDs for the new pools, and the old pools 550 * can be GC'd. 551 * 552 * O(width * height) but only called occasionally (e.g., between conversation turns). 553 */ 554export function migrateScreenPools( 555 screen: Screen, 556 charPool: CharPool, 557 hyperlinkPool: HyperlinkPool, 558): void { 559 const oldCharPool = screen.charPool 560 const oldHyperlinkPool = screen.hyperlinkPool 561 if (oldCharPool === charPool && oldHyperlinkPool === hyperlinkPool) return 562 563 const size = screen.width * screen.height 564 const cells = screen.cells 565 566 // Re-intern chars and hyperlinks in a single pass, stride by 2 567 for (let ci = 0; ci < size << 1; ci += 2) { 568 // Re-intern charId (word0) 569 const oldCharId = cells[ci]! 570 cells[ci] = charPool.intern(oldCharPool.get(oldCharId)) 571 572 // Re-intern hyperlinkId (packed in word1) 573 const word1 = cells[ci + 1]! 574 const oldHyperlinkId = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK 575 if (oldHyperlinkId !== 0) { 576 const oldStr = oldHyperlinkPool.get(oldHyperlinkId) 577 const newHyperlinkId = hyperlinkPool.intern(oldStr) 578 // Repack word1 with new hyperlinkId, preserving styleId and width 579 const styleId = word1 >>> STYLE_SHIFT 580 const width = word1 & WIDTH_MASK 581 cells[ci + 1] = packWord1(styleId, newHyperlinkId, width) 582 } 583 } 584 585 screen.charPool = charPool 586 screen.hyperlinkPool = hyperlinkPool 587} 588 589/** 590 * Get a Cell view at the given position. Returns a new object each call - 591 * this is intentional as cells are stored packed, not as objects. 592 */ 593export function cellAt(screen: Screen, x: number, y: number): Cell | undefined { 594 if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) 595 return undefined 596 return cellAtIndex(screen, y * screen.width + x) 597} 598/** 599 * Get a Cell view by pre-computed array index. Skips bounds checks and 600 * index computation — caller must ensure index is valid. 601 */ 602export function cellAtIndex(screen: Screen, index: number): Cell { 603 const ci = index << 1 604 const word1 = screen.cells[ci + 1]! 605 const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK 606 return { 607 // Unwritten cells have charIndex=0 (EMPTY_CHAR_INDEX); charPool.get(0) returns ' ' 608 char: screen.charPool.get(screen.cells[ci]!), 609 styleId: word1 >>> STYLE_SHIFT, 610 width: word1 & WIDTH_MASK, 611 hyperlink: hid === 0 ? undefined : screen.hyperlinkPool.get(hid), 612 } 613} 614 615/** 616 * Get a Cell at the given index, or undefined if it has no visible content. 617 * Returns undefined for spacer cells (charId 1), empty unstyled spaces, and 618 * fg-only styled spaces that match lastRenderedStyleId (cursor-forward 619 * produces an identical visual result, avoiding a Cell allocation). 620 * 621 * @param lastRenderedStyleId - styleId of the last rendered cell on this 622 * line, or -1 if none yet. 623 */ 624export function visibleCellAtIndex( 625 cells: Int32Array, 626 charPool: CharPool, 627 hyperlinkPool: HyperlinkPool, 628 index: number, 629 lastRenderedStyleId: number, 630): Cell | undefined { 631 const ci = index << 1 632 const charId = cells[ci]! 633 if (charId === 1) return undefined // spacer 634 const word1 = cells[ci + 1]! 635 // For spaces: 0x3fffc masks bits 2-17 (hyperlinkId + styleId visibility 636 // bit). If zero, the space has no hyperlink and at most a fg-only style. 637 // Then word1 >>> STYLE_SHIFT is the foreground style — skip if it's zero 638 // (truly invisible) or matches the last rendered style on this line. 639 if (charId === 0 && (word1 & 0x3fffc) === 0) { 640 const fgStyle = word1 >>> STYLE_SHIFT 641 if (fgStyle === 0 || fgStyle === lastRenderedStyleId) return undefined 642 } 643 const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK 644 return { 645 char: charPool.get(charId), 646 styleId: word1 >>> STYLE_SHIFT, 647 width: word1 & WIDTH_MASK, 648 hyperlink: hid === 0 ? undefined : hyperlinkPool.get(hid), 649 } 650} 651 652/** 653 * Write cell data into an existing Cell object to avoid allocation. 654 * Caller must ensure index is valid. 655 */ 656function cellAtCI(screen: Screen, ci: number, out: Cell): void { 657 const w1 = ci | 1 658 const word1 = screen.cells[w1]! 659 out.char = screen.charPool.get(screen.cells[ci]!) 660 out.styleId = word1 >>> STYLE_SHIFT 661 out.width = word1 & WIDTH_MASK 662 const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK 663 out.hyperlink = hid === 0 ? undefined : screen.hyperlinkPool.get(hid) 664} 665 666export function charInCellAt( 667 screen: Screen, 668 x: number, 669 y: number, 670): string | undefined { 671 if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) 672 return undefined 673 const ci = (y * screen.width + x) << 1 674 return screen.charPool.get(screen.cells[ci]!) 675} 676/** 677 * Set a cell, optionally creating a spacer for wide characters. 678 * 679 * Wide characters (CJK, emoji) occupy 2 cells in the buffer: 680 * 1. First cell: Contains the actual character with width = Wide 681 * 2. Second cell: Spacer cell with width = SpacerTail (empty, not rendered) 682 * 683 * If the cell has width = Wide, this function automatically creates the 684 * corresponding SpacerTail in the next column. This two-cell model keeps 685 * the buffer aligned to visual columns, making cursor positioning 686 * straightforward. 687 * 688 * TODO: When soft-wrapping is implemented, SpacerHead cells will be explicitly 689 * placed by the wrapping logic at line-end positions where wide characters 690 * wrap to the next line. This function doesn't need to handle SpacerHead 691 * automatically - it will be set directly by the wrapping code. 692 */ 693export function setCellAt( 694 screen: Screen, 695 x: number, 696 y: number, 697 cell: Cell, 698): void { 699 if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return 700 const ci = (y * screen.width + x) << 1 701 const cells = screen.cells 702 703 // When a Wide char is overwritten by a Narrow char, its SpacerTail remains 704 // as a ghost cell that the diff/render pipeline skips, causing stale content 705 // to leak through from previous frames. 706 const prevWidth = cells[ci + 1]! & WIDTH_MASK 707 if (prevWidth === CellWidth.Wide && cell.width !== CellWidth.Wide) { 708 const spacerX = x + 1 709 if (spacerX < screen.width) { 710 const spacerCI = ci + 2 711 if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { 712 cells[spacerCI] = EMPTY_CHAR_INDEX 713 cells[spacerCI + 1] = packWord1( 714 screen.emptyStyleId, 715 0, 716 CellWidth.Narrow, 717 ) 718 } 719 } 720 } 721 // Track cleared Wide position for damage expansion below 722 let clearedWideX = -1 723 if ( 724 prevWidth === CellWidth.SpacerTail && 725 cell.width !== CellWidth.SpacerTail 726 ) { 727 // Overwriting a SpacerTail: clear the orphaned Wide char at (x-1). 728 // Keeping the wide character with Narrow width would cause the terminal 729 // to still render it with width 2, desyncing the cursor model. 730 if (x > 0) { 731 const wideCI = ci - 2 732 if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { 733 cells[wideCI] = EMPTY_CHAR_INDEX 734 cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) 735 clearedWideX = x - 1 736 } 737 } 738 } 739 740 // Pack cell data into cells array 741 cells[ci] = internCharString(screen, cell.char) 742 cells[ci + 1] = packWord1( 743 cell.styleId, 744 internHyperlink(screen, cell.hyperlink), 745 cell.width, 746 ) 747 748 // Track damage - expand bounds in place instead of allocating new objects 749 // Include the main cell position and any cleared orphan cells 750 const minX = clearedWideX >= 0 ? Math.min(x, clearedWideX) : x 751 const damage = screen.damage 752 if (damage) { 753 const right = damage.x + damage.width 754 const bottom = damage.y + damage.height 755 if (minX < damage.x) { 756 damage.width += damage.x - minX 757 damage.x = minX 758 } else if (x >= right) { 759 damage.width = x - damage.x + 1 760 } 761 if (y < damage.y) { 762 damage.height += damage.y - y 763 damage.y = y 764 } else if (y >= bottom) { 765 damage.height = y - damage.y + 1 766 } 767 } else { 768 screen.damage = { x: minX, y, width: x - minX + 1, height: 1 } 769 } 770 771 // If this is a wide character, create a spacer in the next column 772 if (cell.width === CellWidth.Wide) { 773 const spacerX = x + 1 774 if (spacerX < screen.width) { 775 const spacerCI = ci + 2 776 // If the cell we're overwriting with our SpacerTail is itself Wide, 777 // clear ITS SpacerTail at x+2 too. Otherwise the orphan SpacerTail 778 // makes diffEach report it as `added` and log-update's skip-spacer 779 // rule prevents clearing whatever prev content was at that column. 780 // Scenario: [a, 💻, spacer] → [本, spacer, ORPHAN spacer] when 781 // yoga squishes a💻 to height 0 and 本 renders at the same y. 782 if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { 783 const orphanCI = spacerCI + 2 784 if ( 785 spacerX + 1 < screen.width && 786 (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail 787 ) { 788 cells[orphanCI] = EMPTY_CHAR_INDEX 789 cells[orphanCI + 1] = packWord1( 790 screen.emptyStyleId, 791 0, 792 CellWidth.Narrow, 793 ) 794 } 795 } 796 cells[spacerCI] = SPACER_CHAR_INDEX 797 cells[spacerCI + 1] = packWord1( 798 screen.emptyStyleId, 799 0, 800 CellWidth.SpacerTail, 801 ) 802 803 // Expand damage to include SpacerTail so diff() scans it 804 const d = screen.damage 805 if (d && spacerX >= d.x + d.width) { 806 d.width = spacerX - d.x + 1 807 } 808 } 809 } 810} 811 812/** 813 * Replace the styleId of a cell in-place without disturbing char, width, 814 * or hyperlink. Preserves empty cells as-is (char stays ' '). Tracks damage 815 * for the cell so diffEach picks up the change. 816 */ 817export function setCellStyleId( 818 screen: Screen, 819 x: number, 820 y: number, 821 styleId: number, 822): void { 823 if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return 824 const ci = (y * screen.width + x) << 1 825 const cells = screen.cells 826 const word1 = cells[ci + 1]! 827 const width = word1 & WIDTH_MASK 828 // Skip spacer cells — inverse on the head cell visually covers both columns 829 if (width === CellWidth.SpacerTail || width === CellWidth.SpacerHead) return 830 const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK 831 cells[ci + 1] = packWord1(styleId, hid, width) 832 // Expand damage so diffEach scans this cell 833 const d = screen.damage 834 if (d) { 835 screen.damage = unionRect(d, { x, y, width: 1, height: 1 }) 836 } else { 837 screen.damage = { x, y, width: 1, height: 1 } 838 } 839} 840 841/** 842 * Intern a character string via the screen's shared CharPool. 843 * Supports grapheme clusters like family emoji. 844 */ 845function internCharString(screen: Screen, char: string): number { 846 return screen.charPool.intern(char) 847} 848 849/** 850 * Bulk-copy a rectangular region from src to dst using TypedArray.set(). 851 * Single cells.set() call per row (or one call for contiguous blocks). 852 * Damage is computed once for the whole region. 853 * 854 * Clamps negative regionX/regionY to 0 (matching clearRegion) — absolute- 855 * positioned overlays in tiny terminals can compute negative screen coords. 856 * maxX/maxY should already be clamped to both screen bounds by the caller. 857 */ 858export function blitRegion( 859 dst: Screen, 860 src: Screen, 861 regionX: number, 862 regionY: number, 863 maxX: number, 864 maxY: number, 865): void { 866 regionX = Math.max(0, regionX) 867 regionY = Math.max(0, regionY) 868 if (regionX >= maxX || regionY >= maxY) return 869 870 const rowLen = maxX - regionX 871 const srcStride = src.width << 1 872 const dstStride = dst.width << 1 873 const rowBytes = rowLen << 1 // 2 Int32s per cell 874 const srcCells = src.cells 875 const dstCells = dst.cells 876 const srcNoSel = src.noSelect 877 const dstNoSel = dst.noSelect 878 879 // softWrap is per-row — copy the row range regardless of stride/width. 880 // Partial-width blits still carry the row's wrap provenance since the 881 // blitted content (a cached ink-text node) is what set the bit. 882 dst.softWrap.set(src.softWrap.subarray(regionY, maxY), regionY) 883 884 // Fast path: contiguous memory when copying full-width rows at same stride 885 if (regionX === 0 && maxX === src.width && src.width === dst.width) { 886 const srcStart = regionY * srcStride 887 const totalBytes = (maxY - regionY) * srcStride 888 dstCells.set( 889 srcCells.subarray(srcStart, srcStart + totalBytes), 890 srcStart, // srcStart === dstStart when strides match and regionX === 0 891 ) 892 // noSelect is 1 byte/cell vs cells' 8 — same region, different scale 893 const nsStart = regionY * src.width 894 const nsLen = (maxY - regionY) * src.width 895 dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart) 896 } else { 897 // Per-row copy for partial-width or mismatched-stride regions 898 let srcRowCI = regionY * srcStride + (regionX << 1) 899 let dstRowCI = regionY * dstStride + (regionX << 1) 900 let srcRowNS = regionY * src.width + regionX 901 let dstRowNS = regionY * dst.width + regionX 902 for (let y = regionY; y < maxY; y++) { 903 dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI) 904 dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS) 905 srcRowCI += srcStride 906 dstRowCI += dstStride 907 srcRowNS += src.width 908 dstRowNS += dst.width 909 } 910 } 911 912 // Compute damage once for the whole region 913 const regionRect = { 914 x: regionX, 915 y: regionY, 916 width: rowLen, 917 height: maxY - regionY, 918 } 919 if (dst.damage) { 920 dst.damage = unionRect(dst.damage, regionRect) 921 } else { 922 dst.damage = regionRect 923 } 924 925 // Handle wide char at right edge: spacer might be outside blit region 926 // but still within dst bounds. Per-row check only at the boundary column. 927 if (maxX < dst.width) { 928 let srcLastCI = (regionY * src.width + (maxX - 1)) << 1 929 let dstSpacerCI = (regionY * dst.width + maxX) << 1 930 let wroteSpacerOutsideRegion = false 931 for (let y = regionY; y < maxY; y++) { 932 if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { 933 dstCells[dstSpacerCI] = SPACER_CHAR_INDEX 934 dstCells[dstSpacerCI + 1] = packWord1( 935 dst.emptyStyleId, 936 0, 937 CellWidth.SpacerTail, 938 ) 939 wroteSpacerOutsideRegion = true 940 } 941 srcLastCI += srcStride 942 dstSpacerCI += dstStride 943 } 944 // Expand damage to include SpacerTail column if we wrote any 945 if (wroteSpacerOutsideRegion && dst.damage) { 946 const rightEdge = dst.damage.x + dst.damage.width 947 if (rightEdge === maxX) { 948 dst.damage = { ...dst.damage, width: dst.damage.width + 1 } 949 } 950 } 951 } 952} 953 954/** 955 * Bulk-clear a rectangular region of the screen. 956 * Uses BigInt64Array.fill() for fast row clears. 957 * Handles wide character boundary cleanup at region edges. 958 */ 959export function clearRegion( 960 screen: Screen, 961 regionX: number, 962 regionY: number, 963 regionWidth: number, 964 regionHeight: number, 965): void { 966 const startX = Math.max(0, regionX) 967 const startY = Math.max(0, regionY) 968 const maxX = Math.min(regionX + regionWidth, screen.width) 969 const maxY = Math.min(regionY + regionHeight, screen.height) 970 if (startX >= maxX || startY >= maxY) return 971 972 const cells = screen.cells 973 const cells64 = screen.cells64 974 const screenWidth = screen.width 975 const rowBase = startY * screenWidth 976 let damageMinX = startX 977 let damageMaxX = maxX 978 979 // EMPTY_CELL_VALUE (0n) matches the zero-initialized state: 980 // word0=EMPTY_CHAR_INDEX(0), word1=packWord1(0,0,0)=0 981 if (startX === 0 && maxX === screenWidth) { 982 // Full-width: single fill, no boundary checks needed 983 cells64.fill( 984 EMPTY_CELL_VALUE, 985 rowBase, 986 rowBase + (maxY - startY) * screenWidth, 987 ) 988 } else { 989 // Partial-width: single loop handles boundary cleanup and fill per row. 990 const stride = screenWidth << 1 // 2 Int32s per cell 991 const rowLen = maxX - startX 992 const checkLeft = startX > 0 993 const checkRight = maxX < screenWidth 994 let leftEdge = (rowBase + startX) << 1 995 let rightEdge = (rowBase + maxX - 1) << 1 996 let fillStart = rowBase + startX 997 998 for (let y = startY; y < maxY; y++) { 999 // Left boundary: if cell at startX is a SpacerTail, the Wide char 1000 // at startX-1 (outside the region) will be orphaned. Clear it. 1001 if (checkLeft) { 1002 // leftEdge points to word0 of cell at startX; +1 is its word1 1003 if ((cells[leftEdge + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { 1004 // word1 of cell at startX-1 is leftEdge-1; word0 is leftEdge-2 1005 const prevW1 = leftEdge - 1 1006 if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) { 1007 cells[prevW1 - 1] = EMPTY_CHAR_INDEX 1008 cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) 1009 damageMinX = startX - 1 1010 } 1011 } 1012 } 1013 1014 // Right boundary: if cell at maxX-1 is Wide, its SpacerTail at maxX 1015 // (outside the region) will be orphaned. Clear it. 1016 if (checkRight) { 1017 // rightEdge points to word0 of cell at maxX-1; +1 is its word1 1018 if ((cells[rightEdge + 1]! & WIDTH_MASK) === CellWidth.Wide) { 1019 // word1 of cell at maxX is rightEdge+3 (+2 to next word0, +1 to word1) 1020 const nextW1 = rightEdge + 3 1021 if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) { 1022 cells[nextW1 - 1] = EMPTY_CHAR_INDEX 1023 cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) 1024 damageMaxX = maxX + 1 1025 } 1026 } 1027 } 1028 1029 cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen) 1030 leftEdge += stride 1031 rightEdge += stride 1032 fillStart += screenWidth 1033 } 1034 } 1035 1036 // Update damage once for the whole region 1037 const regionRect = { 1038 x: damageMinX, 1039 y: startY, 1040 width: damageMaxX - damageMinX, 1041 height: maxY - startY, 1042 } 1043 if (screen.damage) { 1044 screen.damage = unionRect(screen.damage, regionRect) 1045 } else { 1046 screen.damage = regionRect 1047 } 1048} 1049 1050/** 1051 * Shift full-width rows within [top, bottom] (inclusive, 0-indexed) by n. 1052 * n > 0 shifts UP (simulating CSI n S); n < 0 shifts DOWN (CSI n T). 1053 * Vacated rows are cleared. Does NOT update damage. Both cells and the 1054 * noSelect bitmap are shifted so text-selection markers stay aligned when 1055 * this is applied to next.screen during scroll fast path. 1056 */ 1057export function shiftRows( 1058 screen: Screen, 1059 top: number, 1060 bottom: number, 1061 n: number, 1062): void { 1063 if (n === 0 || top < 0 || bottom >= screen.height || top > bottom) return 1064 const w = screen.width 1065 const cells64 = screen.cells64 1066 const noSel = screen.noSelect 1067 const sw = screen.softWrap 1068 const absN = Math.abs(n) 1069 if (absN > bottom - top) { 1070 cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w) 1071 noSel.fill(0, top * w, (bottom + 1) * w) 1072 sw.fill(0, top, bottom + 1) 1073 return 1074 } 1075 if (n > 0) { 1076 // SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom 1077 cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) 1078 noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) 1079 sw.copyWithin(top, top + n, bottom + 1) 1080 cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w) 1081 noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w) 1082 sw.fill(0, bottom - n + 1, bottom + 1) 1083 } else { 1084 // SD: row top..bottom+n → top-n..bottom; clear top..top-n-1 1085 cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) 1086 noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) 1087 sw.copyWithin(top - n, top, bottom + n + 1) 1088 cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w) 1089 noSel.fill(0, top * w, (top - n) * w) 1090 sw.fill(0, top, top - n) 1091 } 1092} 1093 1094// Matches OSC 8 ; ; URI BEL 1095const OSC8_REGEX = new RegExp(`^${ESC}\\]8${SEP}${SEP}([^${BEL}]*)${BEL}$`) 1096// OSC8 prefix: ESC ] 8 ; — cheap check to skip regex for the vast majority of styles (SGR = ESC [) 1097export const OSC8_PREFIX = `${ESC}]8${SEP}` 1098 1099export function extractHyperlinkFromStyles( 1100 styles: AnsiCode[], 1101): Hyperlink | null { 1102 for (const style of styles) { 1103 const code = style.code 1104 if (code.length < 5 || !code.startsWith(OSC8_PREFIX)) continue 1105 const match = code.match(OSC8_REGEX) 1106 if (match) { 1107 return match[1] || null 1108 } 1109 } 1110 return null 1111} 1112 1113export function filterOutHyperlinkStyles(styles: AnsiCode[]): AnsiCode[] { 1114 return styles.filter( 1115 style => 1116 !style.code.startsWith(OSC8_PREFIX) || !OSC8_REGEX.test(style.code), 1117 ) 1118} 1119 1120// --- 1121 1122/** 1123 * Returns an array of all changes between two screens. Used by tests. 1124 * Production code should use diffEach() to avoid allocations. 1125 */ 1126export function diff( 1127 prev: Screen, 1128 next: Screen, 1129): [point: Point, removed: Cell | undefined, added: Cell | undefined][] { 1130 const output: [Point, Cell | undefined, Cell | undefined][] = [] 1131 diffEach(prev, next, (x, y, removed, added) => { 1132 // Copy cells since diffEach reuses the objects 1133 output.push([ 1134 { x, y }, 1135 removed ? { ...removed } : undefined, 1136 added ? { ...added } : undefined, 1137 ]) 1138 }) 1139 return output 1140} 1141 1142type DiffCallback = ( 1143 x: number, 1144 y: number, 1145 removed: Cell | undefined, 1146 added: Cell | undefined, 1147) => boolean | void 1148 1149/** 1150 * Like diff(), but calls a callback for each change instead of building an array. 1151 * Reuses two Cell objects to avoid per-change allocations. The callback must not 1152 * retain references to the Cell objects — their contents are overwritten each call. 1153 * 1154 * Returns true if the callback ever returned true (early exit signal). 1155 */ 1156export function diffEach( 1157 prev: Screen, 1158 next: Screen, 1159 cb: DiffCallback, 1160): boolean { 1161 const prevWidth = prev.width 1162 const nextWidth = next.width 1163 const prevHeight = prev.height 1164 const nextHeight = next.height 1165 1166 let region: Rectangle 1167 if (prevWidth === 0 && prevHeight === 0) { 1168 region = { x: 0, y: 0, width: nextWidth, height: nextHeight } 1169 } else if (next.damage) { 1170 region = next.damage 1171 if (prev.damage) { 1172 region = unionRect(region, prev.damage) 1173 } 1174 } else if (prev.damage) { 1175 region = prev.damage 1176 } else { 1177 region = { x: 0, y: 0, width: 0, height: 0 } 1178 } 1179 1180 if (prevHeight > nextHeight) { 1181 region = unionRect(region, { 1182 x: 0, 1183 y: nextHeight, 1184 width: prevWidth, 1185 height: prevHeight - nextHeight, 1186 }) 1187 } 1188 if (prevWidth > nextWidth) { 1189 region = unionRect(region, { 1190 x: nextWidth, 1191 y: 0, 1192 width: prevWidth - nextWidth, 1193 height: prevHeight, 1194 }) 1195 } 1196 1197 const maxHeight = Math.max(prevHeight, nextHeight) 1198 const maxWidth = Math.max(prevWidth, nextWidth) 1199 const endY = Math.min(region.y + region.height, maxHeight) 1200 const endX = Math.min(region.x + region.width, maxWidth) 1201 1202 if (prevWidth === nextWidth) { 1203 return diffSameWidth(prev, next, region.x, endX, region.y, endY, cb) 1204 } 1205 return diffDifferentWidth(prev, next, region.x, endX, region.y, endY, cb) 1206} 1207 1208/** 1209 * Scan for the next cell that differs between two Int32Arrays. 1210 * Returns the number of matching cells before the first difference, 1211 * or `count` if all cells match. Tiny and pure for JIT inlining. 1212 */ 1213function findNextDiff( 1214 a: Int32Array, 1215 b: Int32Array, 1216 w0: number, 1217 count: number, 1218): number { 1219 for (let i = 0; i < count; i++, w0 += 2) { 1220 const w1 = w0 | 1 1221 if (a[w0] !== b[w0] || a[w1] !== b[w1]) return i 1222 } 1223 return count 1224} 1225 1226/** 1227 * Diff one row where both screens are in bounds. 1228 * Scans for differences with findNextDiff, unpacks and calls cb for each. 1229 */ 1230function diffRowBoth( 1231 prevCells: Int32Array, 1232 nextCells: Int32Array, 1233 prev: Screen, 1234 next: Screen, 1235 ci: number, 1236 y: number, 1237 startX: number, 1238 endX: number, 1239 prevCell: Cell, 1240 nextCell: Cell, 1241 cb: DiffCallback, 1242): boolean { 1243 let x = startX 1244 while (x < endX) { 1245 const skip = findNextDiff(prevCells, nextCells, ci, endX - x) 1246 x += skip 1247 ci += skip << 1 1248 if (x >= endX) break 1249 cellAtCI(prev, ci, prevCell) 1250 cellAtCI(next, ci, nextCell) 1251 if (cb(x, y, prevCell, nextCell)) return true 1252 x++ 1253 ci += 2 1254 } 1255 return false 1256} 1257 1258/** 1259 * Emit removals for a row that only exists in prev (height shrank). 1260 * Cannot skip empty cells — the terminal still has content from the 1261 * previous frame that needs to be cleared. 1262 */ 1263function diffRowRemoved( 1264 prev: Screen, 1265 ci: number, 1266 y: number, 1267 startX: number, 1268 endX: number, 1269 prevCell: Cell, 1270 cb: DiffCallback, 1271): boolean { 1272 for (let x = startX; x < endX; x++, ci += 2) { 1273 cellAtCI(prev, ci, prevCell) 1274 if (cb(x, y, prevCell, undefined)) return true 1275 } 1276 return false 1277} 1278 1279/** 1280 * Emit additions for a row that only exists in next (height grew). 1281 * Skips empty/unwritten cells. 1282 */ 1283function diffRowAdded( 1284 nextCells: Int32Array, 1285 next: Screen, 1286 ci: number, 1287 y: number, 1288 startX: number, 1289 endX: number, 1290 nextCell: Cell, 1291 cb: DiffCallback, 1292): boolean { 1293 for (let x = startX; x < endX; x++, ci += 2) { 1294 if (nextCells[ci] === 0 && nextCells[ci | 1] === 0) continue 1295 cellAtCI(next, ci, nextCell) 1296 if (cb(x, y, undefined, nextCell)) return true 1297 } 1298 return false 1299} 1300 1301/** 1302 * Diff two screens with identical width. 1303 * Dispatches each row to a small, JIT-friendly function. 1304 */ 1305function diffSameWidth( 1306 prev: Screen, 1307 next: Screen, 1308 startX: number, 1309 endX: number, 1310 startY: number, 1311 endY: number, 1312 cb: DiffCallback, 1313): boolean { 1314 const prevCells = prev.cells 1315 const nextCells = next.cells 1316 const width = prev.width 1317 const prevHeight = prev.height 1318 const nextHeight = next.height 1319 const stride = width << 1 1320 1321 const prevCell: Cell = { 1322 char: ' ', 1323 styleId: 0, 1324 width: CellWidth.Narrow, 1325 hyperlink: undefined, 1326 } 1327 const nextCell: Cell = { 1328 char: ' ', 1329 styleId: 0, 1330 width: CellWidth.Narrow, 1331 hyperlink: undefined, 1332 } 1333 1334 const rowEndX = Math.min(endX, width) 1335 let rowCI = (startY * width + startX) << 1 1336 1337 for (let y = startY; y < endY; y++) { 1338 const prevIn = y < prevHeight 1339 const nextIn = y < nextHeight 1340 1341 if (prevIn && nextIn) { 1342 if ( 1343 diffRowBoth( 1344 prevCells, 1345 nextCells, 1346 prev, 1347 next, 1348 rowCI, 1349 y, 1350 startX, 1351 rowEndX, 1352 prevCell, 1353 nextCell, 1354 cb, 1355 ) 1356 ) 1357 return true 1358 } else if (prevIn) { 1359 if (diffRowRemoved(prev, rowCI, y, startX, rowEndX, prevCell, cb)) 1360 return true 1361 } else if (nextIn) { 1362 if ( 1363 diffRowAdded(nextCells, next, rowCI, y, startX, rowEndX, nextCell, cb) 1364 ) 1365 return true 1366 } 1367 1368 rowCI += stride 1369 } 1370 1371 return false 1372} 1373 1374/** 1375 * Fallback: diff two screens with different widths (resize). 1376 * Separate indices for prev and next cells arrays. 1377 */ 1378function diffDifferentWidth( 1379 prev: Screen, 1380 next: Screen, 1381 startX: number, 1382 endX: number, 1383 startY: number, 1384 endY: number, 1385 cb: DiffCallback, 1386): boolean { 1387 const prevWidth = prev.width 1388 const nextWidth = next.width 1389 const prevCells = prev.cells 1390 const nextCells = next.cells 1391 1392 const prevCell: Cell = { 1393 char: ' ', 1394 styleId: 0, 1395 width: CellWidth.Narrow, 1396 hyperlink: undefined, 1397 } 1398 const nextCell: Cell = { 1399 char: ' ', 1400 styleId: 0, 1401 width: CellWidth.Narrow, 1402 hyperlink: undefined, 1403 } 1404 1405 const prevStride = prevWidth << 1 1406 const nextStride = nextWidth << 1 1407 let prevRowCI = (startY * prevWidth + startX) << 1 1408 let nextRowCI = (startY * nextWidth + startX) << 1 1409 1410 for (let y = startY; y < endY; y++) { 1411 const prevIn = y < prev.height 1412 const nextIn = y < next.height 1413 const prevEndX = prevIn ? Math.min(endX, prevWidth) : startX 1414 const nextEndX = nextIn ? Math.min(endX, nextWidth) : startX 1415 const bothEndX = Math.min(prevEndX, nextEndX) 1416 1417 let prevCI = prevRowCI 1418 let nextCI = nextRowCI 1419 1420 for (let x = startX; x < bothEndX; x++) { 1421 if ( 1422 prevCells[prevCI] === nextCells[nextCI] && 1423 prevCells[prevCI + 1] === nextCells[nextCI + 1] 1424 ) { 1425 prevCI += 2 1426 nextCI += 2 1427 continue 1428 } 1429 cellAtCI(prev, prevCI, prevCell) 1430 cellAtCI(next, nextCI, nextCell) 1431 prevCI += 2 1432 nextCI += 2 1433 if (cb(x, y, prevCell, nextCell)) return true 1434 } 1435 1436 if (prevEndX > bothEndX) { 1437 prevCI = prevRowCI + ((bothEndX - startX) << 1) 1438 for (let x = bothEndX; x < prevEndX; x++) { 1439 cellAtCI(prev, prevCI, prevCell) 1440 prevCI += 2 1441 if (cb(x, y, prevCell, undefined)) return true 1442 } 1443 } 1444 1445 if (nextEndX > bothEndX) { 1446 nextCI = nextRowCI + ((bothEndX - startX) << 1) 1447 for (let x = bothEndX; x < nextEndX; x++) { 1448 if (nextCells[nextCI] === 0 && nextCells[nextCI | 1] === 0) { 1449 nextCI += 2 1450 continue 1451 } 1452 cellAtCI(next, nextCI, nextCell) 1453 nextCI += 2 1454 if (cb(x, y, undefined, nextCell)) return true 1455 } 1456 } 1457 1458 prevRowCI += prevStride 1459 nextRowCI += nextStride 1460 } 1461 1462 return false 1463} 1464 1465/** 1466 * Mark a rectangular region as noSelect (exclude from text selection). 1467 * Clamps to screen bounds. Called from output.ts when a <NoSelect> box 1468 * renders. No damage tracking — noSelect doesn't affect terminal output, 1469 * only getSelectedText/applySelectionOverlay which read it directly. 1470 */ 1471export function markNoSelectRegion( 1472 screen: Screen, 1473 x: number, 1474 y: number, 1475 width: number, 1476 height: number, 1477): void { 1478 const maxX = Math.min(x + width, screen.width) 1479 const maxY = Math.min(y + height, screen.height) 1480 const noSel = screen.noSelect 1481 const stride = screen.width 1482 for (let row = Math.max(0, y); row < maxY; row++) { 1483 const rowStart = row * stride 1484 noSel.fill(1, rowStart + Math.max(0, x), rowStart + maxX) 1485 } 1486}