798 lines
26 KiB
TypeScript
798 lines
26 KiB
TypeScript
import {
|
|
type AnsiCode,
|
|
type StyledChar,
|
|
styledCharsFromTokens,
|
|
tokenize,
|
|
} from '@alcalzone/ansi-tokenize'
|
|
import { logForDebugging } from '../utils/debug.js'
|
|
import { getGraphemeSegmenter } from '../utils/intl.js'
|
|
import sliceAnsi from '../utils/sliceAnsi.js'
|
|
import { reorderBidi } from './bidi.js'
|
|
import { type Rectangle, unionRect } from './layout/geometry.js'
|
|
import {
|
|
blitRegion,
|
|
CellWidth,
|
|
extractHyperlinkFromStyles,
|
|
filterOutHyperlinkStyles,
|
|
markNoSelectRegion,
|
|
OSC8_PREFIX,
|
|
resetScreen,
|
|
type Screen,
|
|
type StylePool,
|
|
setCellAt,
|
|
shiftRows,
|
|
} from './screen.js'
|
|
import { stringWidth } from './stringWidth.js'
|
|
import { widestLine } from './widest-line.js'
|
|
|
|
/**
|
|
* A grapheme cluster with precomputed terminal width, styleId, and hyperlink.
|
|
* Built once per unique line (cached via charCache), so the per-char hot loop
|
|
* is just property reads + setCellAt — no stringWidth, no style interning,
|
|
* no hyperlink extraction per frame.
|
|
*
|
|
* styleId is safe to cache: StylePool is session-lived (never reset).
|
|
* hyperlink is stored as a string (not interned ID) since hyperlinkPool
|
|
* resets every 5 min; setCellAt interns it per-frame (cheap Map.get).
|
|
*/
|
|
type ClusteredChar = {
|
|
value: string
|
|
width: number
|
|
styleId: number
|
|
hyperlink: string | undefined
|
|
}
|
|
|
|
/**
|
|
* Collects write/blit/clear/clip operations from the render tree, then
|
|
* applies them to a Screen buffer in `get()`. The Screen is what gets
|
|
* diffed against the previous frame to produce terminal updates.
|
|
*/
|
|
|
|
type Options = {
|
|
width: number
|
|
height: number
|
|
stylePool: StylePool
|
|
/**
|
|
* Screen to render into. Will be reset before use.
|
|
* For double-buffering, pass a reusable screen. Otherwise create a new one.
|
|
*/
|
|
screen: Screen
|
|
}
|
|
|
|
export type Operation =
|
|
| WriteOperation
|
|
| ClipOperation
|
|
| UnclipOperation
|
|
| BlitOperation
|
|
| ClearOperation
|
|
| NoSelectOperation
|
|
| ShiftOperation
|
|
|
|
type WriteOperation = {
|
|
type: 'write'
|
|
x: number
|
|
y: number
|
|
text: string
|
|
/**
|
|
* Per-line soft-wrap flags, parallel to text.split('\n'). softWrap[i]=true
|
|
* means line i is a continuation of line i-1 (the `\n` before it was
|
|
* inserted by word-wrap, not in the source). Index 0 is always false.
|
|
* Undefined means the producer didn't track wrapping (e.g. fills,
|
|
* raw-ansi) — the screen's per-row bitmap is left untouched.
|
|
*/
|
|
softWrap?: boolean[]
|
|
}
|
|
|
|
type ClipOperation = {
|
|
type: 'clip'
|
|
clip: Clip
|
|
}
|
|
|
|
export type Clip = {
|
|
x1: number | undefined
|
|
x2: number | undefined
|
|
y1: number | undefined
|
|
y2: number | undefined
|
|
}
|
|
|
|
/**
|
|
* Intersect two clips. `undefined` on an axis means unbounded; the other
|
|
* clip's bound wins. If both are bounded, take the tighter constraint
|
|
* (max of mins, min of maxes). If the resulting region is empty
|
|
* (x1 >= x2 or y1 >= y2), writes clipped by it will be dropped.
|
|
*/
|
|
function intersectClip(parent: Clip | undefined, child: Clip): Clip {
|
|
if (!parent) return child
|
|
return {
|
|
x1: maxDefined(parent.x1, child.x1),
|
|
x2: minDefined(parent.x2, child.x2),
|
|
y1: maxDefined(parent.y1, child.y1),
|
|
y2: minDefined(parent.y2, child.y2),
|
|
}
|
|
}
|
|
|
|
function maxDefined(
|
|
a: number | undefined,
|
|
b: number | undefined,
|
|
): number | undefined {
|
|
if (a === undefined) return b
|
|
if (b === undefined) return a
|
|
return Math.max(a, b)
|
|
}
|
|
|
|
function minDefined(
|
|
a: number | undefined,
|
|
b: number | undefined,
|
|
): number | undefined {
|
|
if (a === undefined) return b
|
|
if (b === undefined) return a
|
|
return Math.min(a, b)
|
|
}
|
|
|
|
type UnclipOperation = {
|
|
type: 'unclip'
|
|
}
|
|
|
|
type BlitOperation = {
|
|
type: 'blit'
|
|
src: Screen
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
type ShiftOperation = {
|
|
type: 'shift'
|
|
top: number
|
|
bottom: number
|
|
n: number
|
|
}
|
|
|
|
type ClearOperation = {
|
|
type: 'clear'
|
|
region: Rectangle
|
|
/**
|
|
* Set when the clear is for an absolute-positioned node's old bounds.
|
|
* Absolute nodes overlay normal-flow siblings, so their stale paint is
|
|
* what an earlier sibling's clean-subtree blit wrongly restores from
|
|
* prevScreen. Normal-flow siblings' clears don't have this problem —
|
|
* their old position can't have been painted on top of a sibling.
|
|
*/
|
|
fromAbsolute?: boolean
|
|
}
|
|
|
|
type NoSelectOperation = {
|
|
type: 'noSelect'
|
|
region: Rectangle
|
|
}
|
|
|
|
export default class Output {
|
|
width: number
|
|
height: number
|
|
private readonly stylePool: StylePool
|
|
private screen: Screen
|
|
|
|
private readonly operations: Operation[] = []
|
|
|
|
private charCache: Map<string, ClusteredChar[]> = new Map()
|
|
|
|
constructor(options: Options) {
|
|
const { width, height, stylePool, screen } = options
|
|
|
|
this.width = width
|
|
this.height = height
|
|
this.stylePool = stylePool
|
|
this.screen = screen
|
|
|
|
resetScreen(screen, width, height)
|
|
}
|
|
|
|
/**
|
|
* Reuse this Output for a new frame. Zeroes the screen buffer, clears
|
|
* the operation list (backing storage is retained), and caps charCache
|
|
* growth. Preserving charCache across frames is the main win — most
|
|
* lines don't change between renders, so tokenize + grapheme clustering
|
|
* becomes a cache hit.
|
|
*/
|
|
reset(width: number, height: number, screen: Screen): void {
|
|
this.width = width
|
|
this.height = height
|
|
this.screen = screen
|
|
this.operations.length = 0
|
|
resetScreen(screen, width, height)
|
|
if (this.charCache.size > 16384) this.charCache.clear()
|
|
}
|
|
|
|
/**
|
|
* Copy cells from a source screen region (blit = block image transfer).
|
|
*/
|
|
blit(src: Screen, x: number, y: number, width: number, height: number): void {
|
|
this.operations.push({ type: 'blit', src, x, y, width, height })
|
|
}
|
|
|
|
/**
|
|
* Shift full-width rows within [top, bottom] by n. n > 0 = up. Mirrors
|
|
* what DECSTBM + SU/SD does to the terminal. Paired with blit() to reuse
|
|
* prevScreen content during pure scroll, avoiding full child re-render.
|
|
*/
|
|
shift(top: number, bottom: number, n: number): void {
|
|
this.operations.push({ type: 'shift', top, bottom, n })
|
|
}
|
|
|
|
/**
|
|
* Clear a region by writing empty cells. Used when a node shrinks to
|
|
* ensure stale content from the previous frame is removed.
|
|
*/
|
|
clear(region: Rectangle, fromAbsolute?: boolean): void {
|
|
this.operations.push({ type: 'clear', region, fromAbsolute })
|
|
}
|
|
|
|
/**
|
|
* Mark a region as non-selectable (excluded from fullscreen text
|
|
* selection copy + highlight). Used by <NoSelect> to fence off
|
|
* gutters (line numbers, diff sigils). Applied AFTER blit/write so
|
|
* the mark wins regardless of what's blitted into the region.
|
|
*/
|
|
noSelect(region: Rectangle): void {
|
|
this.operations.push({ type: 'noSelect', region })
|
|
}
|
|
|
|
write(x: number, y: number, text: string, softWrap?: boolean[]): void {
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
this.operations.push({
|
|
type: 'write',
|
|
x,
|
|
y,
|
|
text,
|
|
softWrap,
|
|
})
|
|
}
|
|
|
|
clip(clip: Clip) {
|
|
this.operations.push({
|
|
type: 'clip',
|
|
clip,
|
|
})
|
|
}
|
|
|
|
unclip() {
|
|
this.operations.push({
|
|
type: 'unclip',
|
|
})
|
|
}
|
|
|
|
get(): Screen {
|
|
const screen = this.screen
|
|
const screenWidth = this.width
|
|
const screenHeight = this.height
|
|
|
|
// Track blit vs write cell counts for debugging
|
|
let blitCells = 0
|
|
let writeCells = 0
|
|
|
|
// Pass 1: expand damage to cover clear regions. The buffer is freshly
|
|
// zeroed by resetScreen, so this pass only marks damage so diff()
|
|
// checks these regions against the previous frame.
|
|
//
|
|
// Also collect clears from absolute-positioned nodes. An absolute
|
|
// node overlays normal-flow siblings; when it shrinks, its clear is
|
|
// pushed AFTER those siblings' clean-subtree blits (DOM order). The
|
|
// blit copies the absolute node's own stale paint from prevScreen,
|
|
// and since clear is damage-only, the ghost survives diff. Normal-
|
|
// flow clears don't need this — a normal-flow node's old position
|
|
// can't have been painted on top of a sibling's current position.
|
|
const absoluteClears: Rectangle[] = []
|
|
for (const operation of this.operations) {
|
|
if (operation.type !== 'clear') continue
|
|
const { x, y, width, height } = operation.region
|
|
const startX = Math.max(0, x)
|
|
const startY = Math.max(0, y)
|
|
const maxX = Math.min(x + width, screenWidth)
|
|
const maxY = Math.min(y + height, screenHeight)
|
|
if (startX >= maxX || startY >= maxY) continue
|
|
const rect = {
|
|
x: startX,
|
|
y: startY,
|
|
width: maxX - startX,
|
|
height: maxY - startY,
|
|
}
|
|
screen.damage = screen.damage ? unionRect(screen.damage, rect) : rect
|
|
if (operation.fromAbsolute) absoluteClears.push(rect)
|
|
}
|
|
|
|
const clips: Clip[] = []
|
|
|
|
for (const operation of this.operations) {
|
|
switch (operation.type) {
|
|
case 'clear':
|
|
// handled in pass 1
|
|
continue
|
|
|
|
case 'clip':
|
|
// Intersect with the parent clip (if any) so nested
|
|
// overflow:hidden boxes can't write outside their ancestor's
|
|
// clip region. Without this, a message with overflow:hidden at
|
|
// the bottom of a scrollbox pushes its OWN clip (based on its
|
|
// layout bounds, already translated by -scrollTop) which can
|
|
// extend below the scrollbox viewport — writes escape into
|
|
// the sibling bottom section's rows.
|
|
clips.push(intersectClip(clips.at(-1), operation.clip))
|
|
continue
|
|
|
|
case 'unclip':
|
|
clips.pop()
|
|
continue
|
|
|
|
case 'blit': {
|
|
// Bulk-copy cells from source screen region using TypedArray.set().
|
|
// Tracking damage ensures diff() checks blitted cells for stale content
|
|
// when a parent blits an area that previously contained child content.
|
|
const {
|
|
src,
|
|
x: regionX,
|
|
y: regionY,
|
|
width: regionWidth,
|
|
height: regionHeight,
|
|
} = operation
|
|
// Intersect with active clip — a child's clean-blit passes its full
|
|
// cached rect, but the parent ScrollBox may have shrunk (pill mount).
|
|
// Without this, the blit writes past the ScrollBox's new bottom edge
|
|
// into the pill's row.
|
|
const clip = clips.at(-1)
|
|
const startX = Math.max(regionX, clip?.x1 ?? 0)
|
|
const startY = Math.max(regionY, clip?.y1 ?? 0)
|
|
const maxY = Math.min(
|
|
regionY + regionHeight,
|
|
screenHeight,
|
|
src.height,
|
|
clip?.y2 ?? Infinity,
|
|
)
|
|
const maxX = Math.min(
|
|
regionX + regionWidth,
|
|
screenWidth,
|
|
src.width,
|
|
clip?.x2 ?? Infinity,
|
|
)
|
|
if (startX >= maxX || startY >= maxY) continue
|
|
// Skip rows covered by an absolute-positioned node's clear.
|
|
// Absolute nodes overlay normal-flow siblings, so prevScreen in
|
|
// that region holds the absolute node's stale paint — blitting
|
|
// it back would ghost. See absoluteClears collection above.
|
|
if (absoluteClears.length === 0) {
|
|
blitRegion(screen, src, startX, startY, maxX, maxY)
|
|
blitCells += (maxY - startY) * (maxX - startX)
|
|
continue
|
|
}
|
|
let rowStart = startY
|
|
for (let row = startY; row <= maxY; row++) {
|
|
const excluded =
|
|
row < maxY &&
|
|
absoluteClears.some(
|
|
r =>
|
|
row >= r.y &&
|
|
row < r.y + r.height &&
|
|
startX >= r.x &&
|
|
maxX <= r.x + r.width,
|
|
)
|
|
if (excluded || row === maxY) {
|
|
if (row > rowStart) {
|
|
blitRegion(screen, src, startX, rowStart, maxX, row)
|
|
blitCells += (row - rowStart) * (maxX - startX)
|
|
}
|
|
rowStart = row + 1
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
case 'shift': {
|
|
shiftRows(screen, operation.top, operation.bottom, operation.n)
|
|
continue
|
|
}
|
|
|
|
case 'write': {
|
|
const { text, softWrap } = operation
|
|
let { x, y } = operation
|
|
let lines = text.split('\n')
|
|
let swFrom = 0
|
|
let prevContentEnd = 0
|
|
|
|
const clip = clips.at(-1)
|
|
|
|
if (clip) {
|
|
const clipHorizontally =
|
|
typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'
|
|
|
|
const clipVertically =
|
|
typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'
|
|
|
|
// If text is positioned outside of clipping area altogether,
|
|
// skip to the next operation to avoid unnecessary calculations
|
|
if (clipHorizontally) {
|
|
const width = widestLine(text)
|
|
|
|
if (x + width <= clip.x1! || x >= clip.x2!) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (clipVertically) {
|
|
const height = lines.length
|
|
|
|
if (y + height <= clip.y1! || y >= clip.y2!) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (clipHorizontally) {
|
|
lines = lines.map(line => {
|
|
const from = x < clip.x1! ? clip.x1! - x : 0
|
|
const width = stringWidth(line)
|
|
const to = x + width > clip.x2! ? clip.x2! - x : width
|
|
let sliced = sliceAnsi(line, from, to)
|
|
// Wide chars (CJK, emoji) occupy 2 cells. When `to` lands
|
|
// on the first cell of a wide char, sliceAnsi includes the
|
|
// entire glyph and the result overflows clip.x2 by one cell,
|
|
// writing a SpacerTail into the adjacent sibling. Re-slice
|
|
// one cell earlier; wide chars are exactly 2 cells, so a
|
|
// single retry always fits.
|
|
if (stringWidth(sliced) > to - from) {
|
|
sliced = sliceAnsi(line, from, to - 1)
|
|
}
|
|
return sliced
|
|
})
|
|
|
|
if (x < clip.x1!) {
|
|
x = clip.x1!
|
|
}
|
|
}
|
|
|
|
if (clipVertically) {
|
|
const from = y < clip.y1! ? clip.y1! - y : 0
|
|
const height = lines.length
|
|
const to = y + height > clip.y2! ? clip.y2! - y : height
|
|
|
|
// If the first visible line is a soft-wrap continuation, we
|
|
// need the clipped previous line's content end so
|
|
// screen.softWrap[lineY] correctly records the join point
|
|
// even though that line's cells were never written.
|
|
if (softWrap && from > 0 && softWrap[from] === true) {
|
|
prevContentEnd = x + stringWidth(lines[from - 1]!)
|
|
}
|
|
|
|
lines = lines.slice(from, to)
|
|
swFrom = from
|
|
|
|
if (y < clip.y1!) {
|
|
y = clip.y1!
|
|
}
|
|
}
|
|
}
|
|
|
|
const swBits = screen.softWrap
|
|
let offsetY = 0
|
|
|
|
for (const line of lines) {
|
|
const lineY = y + offsetY
|
|
// Line can be outside screen if `text` is taller than screen height
|
|
if (lineY >= screenHeight) {
|
|
break
|
|
}
|
|
const contentEnd = writeLineToScreen(
|
|
screen,
|
|
line,
|
|
x,
|
|
lineY,
|
|
screenWidth,
|
|
this.stylePool,
|
|
this.charCache,
|
|
)
|
|
writeCells += contentEnd - x
|
|
// See Screen.softWrap docstring for the encoding. contentEnd
|
|
// from writeLineToScreen is tab-expansion-aware, unlike
|
|
// x+stringWidth(line) which treats tabs as width 0.
|
|
if (softWrap) {
|
|
const isSW = softWrap[swFrom + offsetY] === true
|
|
swBits[lineY] = isSW ? prevContentEnd : 0
|
|
prevContentEnd = contentEnd
|
|
}
|
|
offsetY++
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// noSelect ops go LAST so they win over blits (which copy noSelect
|
|
// from prevScreen) and writes (which don't touch noSelect). This way
|
|
// a <NoSelect> box correctly fences its region even when the parent
|
|
// blits, and moving a <NoSelect> between frames correctly clears the
|
|
// old region (resetScreen already zeroed the bitmap).
|
|
for (const operation of this.operations) {
|
|
if (operation.type === 'noSelect') {
|
|
const { x, y, width, height } = operation.region
|
|
markNoSelectRegion(screen, x, y, width, height)
|
|
}
|
|
}
|
|
|
|
// Log blit/write ratio for debugging - high write count suggests blitting isn't working
|
|
const totalCells = blitCells + writeCells
|
|
if (totalCells > 1000 && writeCells > blitCells) {
|
|
logForDebugging(
|
|
`High write ratio: blit=${blitCells}, write=${writeCells} (${((writeCells / totalCells) * 100).toFixed(1)}% writes), screen=${screenHeight}x${screenWidth}`,
|
|
)
|
|
}
|
|
|
|
return screen
|
|
}
|
|
}
|
|
|
|
function stylesEqual(a: AnsiCode[], b: AnsiCode[]): boolean {
|
|
if (a === b) return true // Reference equality fast path
|
|
const len = a.length
|
|
if (len !== b.length) return false
|
|
if (len === 0) return true // Both empty
|
|
for (let i = 0; i < len; i++) {
|
|
if (a[i]!.code !== b[i]!.code) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Convert a string with ANSI codes into styled characters with proper grapheme
|
|
* clustering. Fixes ansi-tokenize splitting grapheme clusters (like family
|
|
* emojis) into individual code points.
|
|
*
|
|
* Also precomputes styleId + hyperlink per style run (not per char) — an
|
|
* 80-char line with 3 style runs does 3 intern calls instead of 80.
|
|
*/
|
|
function styledCharsWithGraphemeClustering(
|
|
chars: StyledChar[],
|
|
stylePool: StylePool,
|
|
): ClusteredChar[] {
|
|
const charCount = chars.length
|
|
if (charCount === 0) return []
|
|
|
|
const result: ClusteredChar[] = []
|
|
const bufferChars: string[] = []
|
|
let bufferStyles: AnsiCode[] = chars[0]!.styles
|
|
|
|
for (let i = 0; i < charCount; i++) {
|
|
const char = chars[i]!
|
|
const styles = char.styles
|
|
|
|
// Different styles means we need to flush and start new buffer
|
|
if (bufferChars.length > 0 && !stylesEqual(styles, bufferStyles)) {
|
|
flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
|
|
bufferChars.length = 0
|
|
}
|
|
|
|
bufferChars.push(char.value)
|
|
bufferStyles = styles
|
|
}
|
|
|
|
// Final flush
|
|
if (bufferChars.length > 0) {
|
|
flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function flushBuffer(
|
|
buffer: string,
|
|
styles: AnsiCode[],
|
|
stylePool: StylePool,
|
|
out: ClusteredChar[],
|
|
): void {
|
|
// Compute styleId + hyperlink ONCE for the whole style run.
|
|
// Every grapheme in this buffer shares the same styles.
|
|
//
|
|
// Extract and track hyperlinks separately, filter from styles.
|
|
// Always check for OSC 8 codes to filter, not just when a URL is
|
|
// extracted. The tokenizer treats OSC 8 close codes (empty URL) as
|
|
// active styles, so they must be filtered even when no hyperlink
|
|
// URL is present.
|
|
const hyperlink = extractHyperlinkFromStyles(styles) ?? undefined
|
|
const hasOsc8Styles =
|
|
hyperlink !== undefined ||
|
|
styles.some(
|
|
s =>
|
|
s.code.length >= OSC8_PREFIX.length && s.code.startsWith(OSC8_PREFIX),
|
|
)
|
|
const filteredStyles = hasOsc8Styles
|
|
? filterOutHyperlinkStyles(styles)
|
|
: styles
|
|
const styleId = stylePool.intern(filteredStyles)
|
|
|
|
for (const { segment: grapheme } of getGraphemeSegmenter().segment(buffer)) {
|
|
out.push({
|
|
value: grapheme,
|
|
width: stringWidth(grapheme),
|
|
styleId,
|
|
hyperlink,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a single line's characters into the screen buffer.
|
|
* Extracted from Output.get() so JSC can optimize this tight,
|
|
* monomorphic loop independently — better register allocation,
|
|
* setCellAt inlining, and type feedback than when buried inside
|
|
* a 300-line dispatch function.
|
|
*
|
|
* Returns the end column (x + visual width, including tab expansion) so
|
|
* the caller can record it in screen.softWrap without re-walking the
|
|
* line via stringWidth(). Caller computes the debug cell-count as end-x.
|
|
*/
|
|
function writeLineToScreen(
|
|
screen: Screen,
|
|
line: string,
|
|
x: number,
|
|
y: number,
|
|
screenWidth: number,
|
|
stylePool: StylePool,
|
|
charCache: Map<string, ClusteredChar[]>,
|
|
): number {
|
|
let characters = charCache.get(line)
|
|
if (!characters) {
|
|
characters = reorderBidi(
|
|
styledCharsWithGraphemeClustering(
|
|
styledCharsFromTokens(tokenize(line)),
|
|
stylePool,
|
|
),
|
|
)
|
|
charCache.set(line, characters)
|
|
}
|
|
|
|
let offsetX = x
|
|
|
|
for (let charIdx = 0; charIdx < characters.length; charIdx++) {
|
|
const character = characters[charIdx]!
|
|
const codePoint = character.value.codePointAt(0)
|
|
|
|
// Handle C0 control characters (0x00-0x1F) that cause cursor movement
|
|
// mismatches. stringWidth treats these as width 0, but terminals may
|
|
// move the cursor differently.
|
|
if (codePoint !== undefined && codePoint <= 0x1f) {
|
|
// Tab (0x09): expand to spaces to reach next tab stop
|
|
if (codePoint === 0x09) {
|
|
const tabWidth = 8
|
|
const spacesToNextStop = tabWidth - (offsetX % tabWidth)
|
|
for (let i = 0; i < spacesToNextStop && offsetX < screenWidth; i++) {
|
|
setCellAt(screen, offsetX, y, {
|
|
char: ' ',
|
|
styleId: stylePool.none,
|
|
width: CellWidth.Narrow,
|
|
hyperlink: undefined,
|
|
})
|
|
offsetX++
|
|
}
|
|
}
|
|
// ESC (0x1B): skip incomplete escape sequences that ansi-tokenize
|
|
// didn't recognize. ansi-tokenize only parses SGR sequences (ESC[...m)
|
|
// and OSC 8 hyperlinks (ESC]8;;url BEL). Other sequences like cursor
|
|
// movement, screen clearing, or terminal title become individual char
|
|
// tokens that we need to skip here.
|
|
else if (codePoint === 0x1b) {
|
|
const nextChar = characters[charIdx + 1]?.value
|
|
const nextCode = nextChar?.codePointAt(0)
|
|
if (
|
|
nextChar === '(' ||
|
|
nextChar === ')' ||
|
|
nextChar === '*' ||
|
|
nextChar === '+'
|
|
) {
|
|
// Charset selection: ESC ( X, ESC ) X, etc.
|
|
// Skip the intermediate char and the charset designator
|
|
charIdx += 2
|
|
} else if (nextChar === '[') {
|
|
// CSI sequence: ESC [ ... final-byte
|
|
// Final byte is in range 0x40-0x7E (@, A-Z, [\]^_`, a-z, {|}~)
|
|
// Examples: ESC[2J (clear), ESC[?25l (cursor hide), ESC[H (home)
|
|
charIdx++ // skip the [
|
|
while (charIdx < characters.length - 1) {
|
|
charIdx++
|
|
const c = characters[charIdx]?.value.codePointAt(0)
|
|
// Final byte terminates the sequence
|
|
if (c !== undefined && c >= 0x40 && c <= 0x7e) {
|
|
break
|
|
}
|
|
}
|
|
} else if (
|
|
nextChar === ']' ||
|
|
nextChar === 'P' ||
|
|
nextChar === '_' ||
|
|
nextChar === '^' ||
|
|
nextChar === 'X'
|
|
) {
|
|
// String-based sequences terminated by BEL (0x07) or ST (ESC \):
|
|
// - OSC: ESC ] ... (Operating System Command)
|
|
// - DCS: ESC P ... (Device Control String)
|
|
// - APC: ESC _ ... (Application Program Command)
|
|
// - PM: ESC ^ ... (Privacy Message)
|
|
// - SOS: ESC X ... (Start of String)
|
|
charIdx++ // skip the introducer char
|
|
while (charIdx < characters.length - 1) {
|
|
charIdx++
|
|
const c = characters[charIdx]?.value
|
|
// BEL (0x07) terminates the sequence
|
|
if (c === '\x07') {
|
|
break
|
|
}
|
|
// ST (String Terminator) is ESC \
|
|
// When we see ESC, check if next char is backslash
|
|
if (c === '\x1b') {
|
|
const nextC = characters[charIdx + 1]?.value
|
|
if (nextC === '\\') {
|
|
charIdx++ // skip the backslash too
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else if (
|
|
nextCode !== undefined &&
|
|
nextCode >= 0x30 &&
|
|
nextCode <= 0x7e
|
|
) {
|
|
// Single-character escape sequences: ESC followed by 0x30-0x7E
|
|
// (excluding the multi-char introducers already handled above)
|
|
// - Fp range (0x30-0x3F): ESC 7 (save cursor), ESC 8 (restore)
|
|
// - Fe range (0x40-0x5F): ESC D (index), ESC M (reverse index)
|
|
// - Fs range (0x60-0x7E): ESC c (reset)
|
|
charIdx++ // skip the command char
|
|
}
|
|
}
|
|
// Carriage return (0x0D): would move cursor to column 0, skip it
|
|
// Backspace (0x08): would move cursor left, skip it
|
|
// Bell (0x07), vertical tab (0x0B), form feed (0x0C): skip
|
|
// All other control chars (0x00-0x06, 0x0E-0x1F): skip
|
|
// Note: newline (0x0A) is already handled by line splitting
|
|
continue
|
|
}
|
|
|
|
// Zero-width characters (combining marks, ZWNJ, ZWS, etc.)
|
|
// don't occupy terminal cells — storing them as Narrow cells
|
|
// desyncs the virtual cursor from the real terminal cursor.
|
|
// Width was computed once during clustering (cached via charCache).
|
|
const charWidth = character.width
|
|
if (charWidth === 0) {
|
|
continue
|
|
}
|
|
|
|
const isWideCharacter = charWidth >= 2
|
|
|
|
// Wide char at last column can't fit — terminal would wrap it to
|
|
// the next line, desyncing our cursor model. Place a SpacerHead
|
|
// to mark the blank column, matching terminal behavior.
|
|
if (isWideCharacter && offsetX + 2 > screenWidth) {
|
|
setCellAt(screen, offsetX, y, {
|
|
char: ' ',
|
|
styleId: stylePool.none,
|
|
width: CellWidth.SpacerHead,
|
|
hyperlink: undefined,
|
|
})
|
|
offsetX++
|
|
continue
|
|
}
|
|
|
|
// styleId + hyperlink were precomputed during clustering (once per
|
|
// style run, cached via charCache). Hot loop is now just property
|
|
// reads — no intern, no extract, no filter per frame.
|
|
setCellAt(screen, offsetX, y, {
|
|
char: character.value,
|
|
styleId: character.styleId,
|
|
width: isWideCharacter ? CellWidth.Wide : CellWidth.Narrow,
|
|
hyperlink: character.hyperlink,
|
|
})
|
|
offsetX += isWideCharacter ? 2 : 1
|
|
}
|
|
|
|
return offsetX
|
|
}
|