init
This commit is contained in:
+797
@@ -0,0 +1,797 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user