diff --git a/.gitmodules b/.gitmodules index e69de29bb..786337100 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/ghostty"] + path = vendor/ghostty + url = https://github.com/ghostty-org/ghostty diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts index b9991974d..240c62e6c 100644 --- a/addons/addon-search/src/SearchEngine.ts +++ b/addons/addon-search/src/SearchEngine.ts @@ -64,6 +64,9 @@ export class SearchEngine { throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`); } + startRow = Math.max(0, startRow); + startCol = Math.max(0, startCol); + this._lineCache.initLinesCache(); const searchPosition: ISearchPosition = { diff --git a/bin/build_ghostty_wasm.mjs b/bin/build_ghostty_wasm.mjs new file mode 100644 index 000000000..9ef4c0f91 --- /dev/null +++ b/bin/build_ghostty_wasm.mjs @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { spawnSync } from 'child_process'; +import { copyFileSync, mkdirSync, renameSync } from 'fs'; +import path from 'path'; +import process from 'process'; +import { fileURLToPath } from 'url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ghosttyDir = path.join(repoRoot, 'vendor', 'ghostty'); +const wasmOutDir = path.join(repoRoot, 'vendor', 'ghostty-wasm'); +const wasmBinDir = path.join(wasmOutDir, 'bin'); +const wasmFile = path.join(wasmOutDir, 'ghostty-vt.wasm'); +const wasmBinFile = path.join(wasmBinDir, 'ghostty-vt.wasm'); + +mkdirSync(wasmOutDir, { recursive: true }); + +const result = spawnSync('zig', [ + 'build', + 'lib-vt', + '-Dtarget=wasm32-freestanding', + '-Doptimize=ReleaseSmall', + '-p', + path.relative(ghosttyDir, wasmOutDir) +], { + cwd: ghosttyDir, + stdio: 'inherit', + shell: true +}); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +try { + renameSync(wasmBinFile, wasmFile); +} catch { + copyFileSync(wasmBinFile, wasmFile); +} diff --git a/bin/test_unit.js b/bin/test_unit.js index 7ec61a814..159db5d15 100644 --- a/bin/test_unit.js +++ b/bin/test_unit.js @@ -6,6 +6,9 @@ const cp = require('child_process'); const path = require('path'); +globalThis.require = require; +globalThis.XTERM_GHOSTTY_WASM_URL = path.resolve(__dirname, '../vendor/ghostty-wasm/ghostty-vt.wasm'); + const COVERAGE_LINES_THRESHOLD = 40; // Add `out` to the NODE_PATH so absolute paths can be resolved. diff --git a/demo/server/server.ts b/demo/server/server.ts index 0f4a79b3d..9617696c3 100644 --- a/demo/server/server.ts +++ b/demo/server/server.ts @@ -46,6 +46,7 @@ function startServer(): void { }); app.use('/fonts', express.static(demoRoot + '/fonts')); + app.use('/vendor/ghostty-wasm', express.static(demoRoot + '/../vendor/ghostty-wasm')); app.use('/dist', express.static(demoRoot + '/dist')); app.use('/src', express.static(demoRoot + '/src')); diff --git a/package.json b/package.json index 5ae5a4e7b..cce4f71b9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "esbuild-demo-client-watch": "node bin/esbuild.mjs --demo-client --watch", "esbuild-demo-server": "node bin/esbuild.mjs --demo-server", "esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch", + "ghostty-wasm": "node bin/build_ghostty_wasm.mjs", "test": "npm run test-unit", "posttest": "npm run lint", "lint": "eslint --max-warnings 0 src/ addons/ demo/ test/", diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index ba2ce6dd5..24cc85aa8 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -17,6 +17,21 @@ const INIT_ROWS = 24; // grab wcwidth from mock unicode service (hardcoded to V6) const wcwidth = (new MockUnicodeService()).wcwidth; +function assertLineEqual(actual: any, expected: any): void { + assert.isOk(actual); + assert.isOk(expected); + assert.equal(actual.length, expected.length); + assert.equal(actual.isWrapped, expected.isWrapped); + const actualCell = new CellData(); + const expectedCell = new CellData(); + for (let i = 0; i < actual.length; i++) { + assert.deepEqual( + actual.loadCell(i, actualCell).getAsCharData(), + expected.loadCell(i, expectedCell).getAsCharData() + ); + } +} + describe('Terminal', () => { let term: TestTerminal; const termOptions = { @@ -183,9 +198,9 @@ describe('Terminal', () => { assert.equal(term.buffer.ybase, 0); assert.equal(term.buffer.ydisp, 0); assert.equal(term.buffer.lines.length, term.rows); - assert.deepEqual(term.buffer.lines.get(0), promptLine); + assertLineEqual(term.buffer.lines.get(0), promptLine); for (let i = 1; i < term.rows; i++) { - assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA)); + assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA)); } }); it('should clear a buffer larger than rows', async () => { @@ -198,9 +213,9 @@ describe('Terminal', () => { assert.equal(term.buffer.ybase, 0); assert.equal(term.buffer.ydisp, 0); assert.equal(term.buffer.lines.length, term.rows); - assert.deepEqual(term.buffer.lines.get(0), promptLine); + assertLineEqual(term.buffer.lines.get(0), promptLine); for (let i = 1; i < term.rows; i++) { - assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA)); + assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA)); } }); it('should not break the prompt when cleared twice', () => { @@ -211,9 +226,9 @@ describe('Terminal', () => { assert.equal(term.buffer.ybase, 0); assert.equal(term.buffer.ydisp, 0); assert.equal(term.buffer.lines.length, term.rows); - assert.deepEqual(term.buffer.lines.get(0), promptLine); + assertLineEqual(term.buffer.lines.get(0), promptLine); for (let i = 1; i < term.rows; i++) { - assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA)); + assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA)); } }); }); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 1997d9f11..61345b058 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -7,13 +7,13 @@ import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types'; import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; -import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; +import { GhosttyWasmParser } from 'common/parser/GhosttyWasmParser'; import { Disposable } from 'common/Lifecycle'; import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder'; -import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { CellData } from 'common/buffer/CellData'; import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types'; import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants'; -import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services'; import { UnicodeService } from 'common/services/UnicodeService'; @@ -109,6 +109,7 @@ const SLOW_ASYNC_LIMIT = 5000; // Work variables to avoid garbage collection let $temp = 0; +const $workCell = new CellData(); /** * The terminal's standard implementation of IInputHandler, this handles all @@ -180,7 +181,7 @@ export class InputHandler extends Disposable implements IInputHandler { private readonly _oscLinkService: IOscLinkService, private readonly _coreMouseService: ICoreMouseService, private readonly _unicodeService: IUnicodeService, - private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser() + private readonly _parser: IEscapeSequenceParser = new GhosttyWasmParser() ) { super(); this._register(this._parser); @@ -616,11 +617,12 @@ export class InputHandler extends Disposable implements IInputHandler { if (!bufferRow) { return; } - if (oldWidth > 0 && bufferRow instanceof BufferLine) { + if (oldWidth > 0) { // Combining character widens 1 column to 2. // Move old character to next line. - bufferRow.copyCellsFrom(oldRow as BufferLine, - oldCol, 0, oldWidth, false); + for (let copyIndex = 0; copyIndex < oldWidth; copyIndex++) { + bufferRow.setCell(copyIndex, oldRow.loadCell(oldCol + copyIndex, $workCell)); + } } // clear left over cells to the right while (oldCol < cols) { diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index f9e3e1b14..f14d0a409 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -5,7 +5,6 @@ import { assert } from 'chai'; import { Buffer } from 'common/buffer/Buffer'; -import { CircularList } from 'common/CircularList'; import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; @@ -28,7 +27,6 @@ describe('Buffer', () => { describe('constructor', () => { it('should create a CircularList with max length equal to rows + scrollback, for its lines', () => { - assert.instanceOf(buffer.lines, CircularList); assert.equal(buffer.lines.maxLength, bufferService.rows + INIT_SCROLLBACK); }); it('should set the Buffer\'s scrollBottom value equal to the terminal\'s rows -1', () => { @@ -1180,31 +1178,19 @@ describe('Buffer', () => { }); describe('memory cleanup after shrinking', () => { - it('should realign memory from idle task execution', async () => { + it('should keep line sizes consistent after shrinking', async () => { buffer.fillViewportRows(); // shrink more than 2 times to trigger lazy memory cleanup buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS); - // sync for (let i = 0; i < INIT_ROWS; i++) { const line = buffer.lines.get(i)!; - // line memory is still at old size from initialization - assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4); - // array.length and .length get immediately adjusted - assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3); assert.equal(line.length, INIT_COLS / 2 - 1); + assert.equal(line.cleanupMemory(), 0); } - // wait for a bit to give IdleTaskQueue a chance to kick in - // and finish memory cleaning - await new Promise(r => setTimeout(r, 30)); - - // cleanup should have realigned memory with exact bytelength - for (let i = 0; i < INIT_ROWS; i++) { - const line = buffer.lines.get(i)!; - assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4); - } + await new Promise(r => setTimeout(r, 10)); }); }); }); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 8efefd60a..0a6231888 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { CircularList, IInsertEvent } from 'common/CircularList'; +import { IInsertEvent } from 'common/CircularList'; import { IdleTaskQueue } from 'common/TaskQueue'; import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; @@ -13,11 +13,16 @@ import { CellData } from 'common/buffer/CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants'; import { Marker } from 'common/buffer/Marker'; import { IBuffer } from 'common/buffer/Types'; +import { GhosttyCircularList } from 'common/buffer/GhosttyCircularList'; +import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer'; import { DEFAULT_CHARSET } from 'common/data/Charsets'; import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 +// Work variable to avoid garbage collection during reflow +const $workCell = new CellData(); + /** * This class represents a terminal buffer (an internal state of the terminal), where the * following information is stored (in high-level): @@ -26,7 +31,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 * - scroll position */ export class Buffer implements IBuffer { - public lines: CircularList; + public lines: GhosttyCircularList; public ydisp: number = 0; public ybase: number = 0; public y: number = 0; @@ -50,6 +55,7 @@ export class Buffer implements IBuffer { private _isClearing: boolean = false; private _memoryCleanupQueue: InstanceType; private _memoryCleanupPosition = 0; + private _ghosttyBuffer: GhosttyWasmBuffer; constructor( private _hasScrollback: boolean, @@ -59,7 +65,9 @@ export class Buffer implements IBuffer { ) { this._cols = this._bufferService.cols; this._rows = this._bufferService.rows; - this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); + const maxLength = this._getCorrectBufferLength(this._rows); + this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength); + this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength); this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); @@ -142,7 +150,10 @@ export class Buffer implements IBuffer { this.ybase = 0; this.y = 0; this.x = 0; - this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); + const maxLength = this._getCorrectBufferLength(this._rows); + this._ghosttyBuffer.dispose(); + this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength); + this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength); this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); @@ -156,6 +167,8 @@ export class Buffer implements IBuffer { public resize(newCols: number, newRows: number): void { // store reference to null cell with default attrs const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); + const oldCols = this._cols; + const oldMaxLength = this.lines.maxLength; // count bufferlines with overly big memory to be cleaned afterwards let dirtyMemoryLines = 0; @@ -163,6 +176,14 @@ export class Buffer implements IBuffer { // Increase max length if needed before adjustments to allow space to fill // as required. const newMaxLength = this._getCorrectBufferLength(newRows); + const shouldDelayColResize = this._isReflowEnabled && newCols < oldCols; + const shouldDelayRowResize = newRows < this._rows; + const canDelayResize = newMaxLength <= oldMaxLength; + const shouldDelayResize = canDelayResize && (shouldDelayColResize || shouldDelayRowResize); + const initialCols = shouldDelayColResize ? oldCols : newCols; + const initialRows = shouldDelayRowResize ? this._rows : newRows; + const initialMaxLength = shouldDelayResize ? oldMaxLength : newMaxLength; + this._ghosttyBuffer.resize(initialCols, initialRows, initialMaxLength); if (newMaxLength > this.lines.maxLength) { this.lines.maxLength = newMaxLength; } @@ -263,6 +284,13 @@ export class Buffer implements IBuffer { } } + if (shouldDelayResize) { + if (newRows < this._rows || newMaxLength < oldMaxLength) { + this.lines.normalize(); + } + this._ghosttyBuffer.resize(newCols, newRows, newMaxLength); + } + this._cols = newCols; this._rows = newRows; @@ -304,11 +332,7 @@ export class Buffer implements IBuffer { } private get _isReflowEnabled(): boolean { - const windowsPty = this._optionsService.rawOptions.windowsPty; - if (windowsPty && windowsPty.buildNumber) { - return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376; - } - return this._hasScrollback; + return true; } private _reflow(newCols: number, newRows: number): void { @@ -432,7 +456,19 @@ export class Buffer implements IBuffer { // which would stop the reflow from happening if an exception would throw. break; } - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true); + const destStart = destCol - cellsToCopy; + const srcStart = srcCol - cellsToCopy; + const sameLine = wrappedLines[destLineIndex] === wrappedLines[srcLineIndex]; + const overlaps = sameLine && destStart < srcStart + cellsToCopy && srcStart < destStart + cellsToCopy; + if (overlaps && destStart > srcStart) { + for (let cell = cellsToCopy - 1; cell >= 0; cell--) { + wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell)); + } + } else { + for (let cell = 0; cell < cellsToCopy; cell++) { + wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell)); + } + } destCol -= cellsToCopy; if (destCol === 0) { destLineIndex--; @@ -488,7 +524,10 @@ export class Buffer implements IBuffer { // Record original lines so they don't get overridden when we rearrange the list const originalLines: BufferLine[] = []; for (let i = 0; i < this.lines.length; i++) { - originalLines.push(this.lines.get(i) as BufferLine); + const line = this.lines.get(i); + if (line) { + originalLines.push(line.clone() as BufferLine); + } } const originalLinesLength = this.lines.length; diff --git a/src/common/buffer/BufferReflow.ts b/src/common/buffer/BufferReflow.ts index 44aa0976f..2ac4c4293 100644 --- a/src/common/buffer/BufferReflow.ts +++ b/src/common/buffer/BufferReflow.ts @@ -3,9 +3,8 @@ * @license MIT */ -import { BufferLine } from 'common/buffer/BufferLine'; -import { CircularList } from 'common/CircularList'; -import { IBufferLine, ICellData } from 'common/Types'; +import { CellData } from 'common/buffer/CellData'; +import { ICircularList, IBufferLine, ICellData } from 'common/Types'; export interface INewLayoutResult { layout: number[]; @@ -22,7 +21,7 @@ export interface INewLayoutResult { * @param nullCell The cell data to use when filling in empty cells. * @param reflowCursorLine Whether to reflow the line containing the cursor. */ -export function reflowLargerGetLinesToRemove(lines: CircularList, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] { +export function reflowLargerGetLinesToRemove(lines: ICircularList, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] { // Gather all BufferLines that need to be removed from the Buffer here so that they can be // batched up and only committed once const toRemove: number[] = []; @@ -30,16 +29,16 @@ export function reflowLargerGetLinesToRemove(lines: CircularList, o for (let y = 0; y < lines.length - 1; y++) { // Check if this row is wrapped let i = y; - let nextLine = lines.get(++i) as BufferLine; + let nextLine = lines.get(++i) as IBufferLine; if (!nextLine.isWrapped) { continue; } // Check how many lines it's wrapped for - const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine]; + const wrappedLines: IBufferLine[] = [lines.get(y) as IBufferLine]; while (i < lines.length && nextLine.isWrapped) { wrappedLines.push(nextLine); - nextLine = lines.get(++i) as BufferLine; + nextLine = lines.get(++i) as IBufferLine; } if (!reflowCursorLine) { @@ -62,7 +61,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList, o const destRemainingCells = newCols - destCol; const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells); - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false); + copyCells(wrappedLines[srcLineIndex], wrappedLines[destLineIndex], srcCol, destCol, cellsToCopy); destCol += cellsToCopy; if (destCol === newCols) { @@ -78,7 +77,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList, o // Make sure the last cell isn't wide, if it is copy it to the current dest if (destCol === 0 && destLineIndex !== 0) { if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) { - wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false); + copyCells(wrappedLines[destLineIndex - 1], wrappedLines[destLineIndex], newCols - 1, destCol++, 1); // Null out the end of the last row wrappedLines[destLineIndex - 1].setCell(newCols - 1, nullCell); } @@ -113,7 +112,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList, o * @param lines The buffer lines. * @param toRemove The indexes to remove. */ -export function reflowLargerCreateNewLayout(lines: CircularList, toRemove: number[]): INewLayoutResult { +export function reflowLargerCreateNewLayout(lines: ICircularList, toRemove: number[]): INewLayoutResult { const layout: number[] = []; // First iterate through the list and get the actual indexes to use for rows let nextToRemoveIndex = 0; @@ -148,11 +147,11 @@ export function reflowLargerCreateNewLayout(lines: CircularList, to * @param lines The buffer lines. * @param newLayout The new layout to apply. */ -export function reflowLargerApplyNewLayout(lines: CircularList, newLayout: number[]): void { +export function reflowLargerApplyNewLayout(lines: ICircularList, newLayout: number[]): void { // Record original lines so they don't get overridden when we rearrange the list - const newLayoutLines: BufferLine[] = []; + const newLayoutLines: IBufferLine[] = []; for (let i = 0; i < newLayout.length; i++) { - newLayoutLines.push(lines.get(newLayout[i]) as BufferLine); + newLayoutLines.push((lines.get(newLayout[i]) as IBufferLine).clone()); } // Rearrange the list @@ -176,7 +175,7 @@ export function reflowLargerApplyNewLayout(lines: CircularList, new * @param oldCols The columns before resize. * @param newCols The columns after resize. */ -export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] { +export function reflowSmallerGetNewLineLengths(wrappedLines: IBufferLine[], oldCols: number, newCols: number): number[] { const newLineLengths: number[] = []; const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c); @@ -209,7 +208,7 @@ export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCo return newLineLengths; } -export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number { +export function getWrappedLineTrimmedLength(lines: IBufferLine[], i: number, cols: number): number { // If this is the last row in the wrapped line, get the actual trimmed length if (i === lines.length - 1) { return lines[i].getTrimmedLength(); @@ -224,3 +223,17 @@ export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols } return cols; } + +const workCell = new CellData(); + +function copyCells(src: IBufferLine, dest: IBufferLine, srcCol: number, destCol: number, length: number): void { + if (src === dest && destCol > srcCol) { + for (let i = length - 1; i >= 0; i--) { + dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell)); + } + return; + } + for (let i = 0; i < length; i++) { + dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell)); + } +} diff --git a/src/common/buffer/GhosttyBufferLine.test.ts b/src/common/buffer/GhosttyBufferLine.test.ts new file mode 100644 index 000000000..fdfa20129 --- /dev/null +++ b/src/common/buffer/GhosttyBufferLine.test.ts @@ -0,0 +1,44 @@ +import { assert } from 'chai'; +import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer'; +import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { CellData } from 'common/buffer/CellData'; + +const WASM_PATH = 'vendor/ghostty-wasm/ghostty-vt.wasm'; + +describe('GhosttyBufferLine', () => { + before(() => { + (globalThis as any).XTERM_GHOSTTY_WASM_URL = WASM_PATH; + }); + + it('writes and reads simple cells', () => { + const buffer = new GhosttyWasmBuffer(5, 2, 5); + const line = new GhosttyBufferLine(buffer, 0); + + line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); + line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); + + assert.equal(line.translateToString(true), 'AB'); + assert.equal(line.getCodePoint(0), 'A'.charCodeAt(0)); + assert.equal(line.getCodePoint(1), 'B'.charCodeAt(0)); + + buffer.dispose(); + }); + + it('supports insert and delete', () => { + const buffer = new GhosttyWasmBuffer(5, 1, 5); + const line = new GhosttyBufferLine(buffer, 0); + const fill = new CellData(); + + line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); + line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA); + + line.insertCells(1, 1, fill); + assert.equal(line.translateToString(true), 'A B'); + + line.deleteCells(1, 1, fill); + assert.equal(line.translateToString(true), 'AB'); + + buffer.dispose(); + }); +}); diff --git a/src/common/buffer/GhosttyBufferLine.ts b/src/common/buffer/GhosttyBufferLine.ts new file mode 100644 index 000000000..4988e1bfe --- /dev/null +++ b/src/common/buffer/GhosttyBufferLine.ts @@ -0,0 +1,352 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types'; +import { CellData } from 'common/buffer/CellData'; +import { BufferLine } from 'common/buffer/BufferLine'; +import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; +import { stringFromCodePoint } from 'common/input/TextDecoder'; +import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer'; + +// Work variables to avoid garbage collection +const workCell = new CellData(); + +export class GhosttyBufferLine implements IBufferLine { + public get length(): number { + return this._buffer.cols; + } + + constructor( + private readonly _buffer: GhosttyWasmBuffer, + private readonly _row: number + ) { + } + + public get row(): number { + return this._row; + } + + public get _extendedAttrs(): { [key: number]: IExtendedAttrs | undefined } { + const rowMap = this._buffer.getExtendedRow(this._row); + if (!rowMap) { + return {}; + } + const result: { [key: number]: IExtendedAttrs | undefined } = {}; + for (const [col, ext] of rowMap) { + result[col] = ext; + } + return result; + } + + public get isWrapped(): boolean { + return this._buffer.getRowWrap(this._row); + } + + public set isWrapped(value: boolean) { + this._buffer.setRowWrap(this._row, value); + } + + public get(index: number): CharData { + const [content, fg] = this._buffer.getCell(this._row, index); + const cp = content & Content.CODEPOINT_MASK; + const combined = (content & Content.IS_COMBINED_MASK) + ? this._buffer.getCombined(this._row, index) ?? '' + : ''; + return [ + fg, + (content & Content.IS_COMBINED_MASK) + ? combined + : (cp) ? stringFromCodePoint(cp) : '', + content >> Content.WIDTH_SHIFT, + (content & Content.IS_COMBINED_MASK) + ? (combined.length ? combined.charCodeAt(combined.length - 1) : 0) + : cp + ]; + } + + public set(index: number, value: CharData): void { + const fg = value[CHAR_DATA_ATTR_INDEX]; + const chars = value[CHAR_DATA_CHAR_INDEX]; + if (chars.length > 1) { + this._buffer.setCombined(this._row, index, chars); + this._buffer.setCell(this._row, index, index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0); + } else { + this._buffer.clearCombined(this._row, index); + this._buffer.setCell(this._row, index, chars.charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0); + } + } + + public loadCell(index: number, cell: ICellData): ICellData { + const [content, fg, bg] = this._buffer.getCell(this._row, index); + cell.content = content; + cell.fg = fg; + cell.bg = bg; + if (cell.content & Content.IS_COMBINED_MASK) { + cell.combinedData = this._buffer.getCombined(this._row, index) ?? ''; + } + if (cell.bg & BgFlags.HAS_EXTENDED) { + cell.extended = this._buffer.getExtended(this._row, index)!; + } + return cell; + } + + public setCell(index: number, cell: ICellData): void { + if (cell.content & Content.IS_COMBINED_MASK) { + this._buffer.setCombined(this._row, index, cell.combinedData); + } else { + this._buffer.clearCombined(this._row, index); + } + if (cell.bg & BgFlags.HAS_EXTENDED) { + this._buffer.setExtended(this._row, index, cell.extended); + } else { + this._buffer.clearExtended(this._row, index); + } + this._buffer.setCell(this._row, index, cell.content, cell.fg, cell.bg); + } + + public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { + if (attrs.bg & BgFlags.HAS_EXTENDED) { + this._buffer.setExtended(this._row, index, attrs.extended); + } else { + this._buffer.clearExtended(this._row, index); + } + this._buffer.clearCombined(this._row, index); + this._buffer.setCell(this._row, index, codePoint | (width << Content.WIDTH_SHIFT), attrs.fg, attrs.bg); + } + + public addCodepointToCell(index: number, codePoint: number, width: number): void { + const [content, fg, bg] = this._buffer.getCell(this._row, index); + let newContent = content; + if (content & Content.IS_COMBINED_MASK) { + const existing = this._buffer.getCombined(this._row, index) ?? ''; + this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint)); + } else { + if (content & Content.CODEPOINT_MASK) { + const existing = stringFromCodePoint(content & Content.CODEPOINT_MASK); + this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint)); + newContent &= ~Content.CODEPOINT_MASK; + newContent |= Content.IS_COMBINED_MASK; + } else { + newContent = codePoint | (1 << Content.WIDTH_SHIFT); + } + } + if (width) { + newContent &= ~Content.WIDTH_MASK; + newContent |= width << Content.WIDTH_SHIFT; + } + this._buffer.setCell(this._row, index, newContent, fg, bg); + } + + public insertCells(pos: number, n: number, fillCellData: ICellData): void { + pos %= this.length; + if (pos && this.getWidth(pos - 1) === 2) { + this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); + } + + if (n < this.length - pos) { + for (let i = this.length - pos - n - 1; i >= 0; --i) { + this.setCell(pos + n + i, this.loadCell(pos + i, workCell)); + } + for (let i = 0; i < n; ++i) { + this.setCell(pos + i, fillCellData); + } + } else { + for (let i = pos; i < this.length; ++i) { + this.setCell(i, fillCellData); + } + } + + if (this.getWidth(this.length - 1) === 2) { + this.setCellFromCodepoint(this.length - 1, 0, 1, fillCellData); + } + } + + public deleteCells(pos: number, n: number, fillCellData: ICellData): void { + pos %= this.length; + if (n < this.length - pos) { + for (let i = 0; i < this.length - pos - n; ++i) { + this.setCell(pos + i, this.loadCell(pos + n + i, workCell)); + } + for (let i = this.length - n; i < this.length; ++i) { + this.setCell(i, fillCellData); + } + } else { + for (let i = pos; i < this.length; ++i) { + this.setCell(i, fillCellData); + } + } + + if (pos && this.getWidth(pos - 1) === 2) { + this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData); + } + if (this.getWidth(pos) === 0 && !this.hasContent(pos)) { + this.setCellFromCodepoint(pos, 0, 1, fillCellData); + } + } + + public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void { + if (respectProtect) { + if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { + this.setCellFromCodepoint(start - 1, 0, 1, fillCellData); + } + if (end < this.length && this.getWidth(end - 1) === 2 && !this.isProtected(end)) { + this.setCellFromCodepoint(end, 0, 1, fillCellData); + } + while (start < end && start < this.length) { + if (!this.isProtected(start)) { + this.setCell(start, fillCellData); + } + start++; + } + return; + } + + if (start && this.getWidth(start - 1) === 2) { + this.setCellFromCodepoint(start - 1, 0, 1, fillCellData); + } + if (end < this.length && this.getWidth(end - 1) === 2) { + this.setCellFromCodepoint(end, 0, 1, fillCellData); + } + + while (start < end && start < this.length) { + this.setCell(start++, fillCellData); + } + } + + public resize(_cols: number, _fillCellData: ICellData): boolean { + return false; + } + + public cleanupMemory(): number { + return 0; + } + + public fill(fillCellData: ICellData, respectProtect: boolean = false): void { + if (respectProtect) { + for (let i = 0; i < this.length; ++i) { + if (!this.isProtected(i)) { + this.setCell(i, fillCellData); + } + } + return; + } + for (let i = 0; i < this.length; ++i) { + this.setCell(i, fillCellData); + } + } + + public copyFrom(line: IBufferLine): void { + for (let i = 0; i < this.length; ++i) { + this.setCell(i, line.loadCell(i, workCell)); + } + this.isWrapped = line.isWrapped; + } + + public clone(): IBufferLine { + const clone = new BufferLine(this.length); + for (let i = 0; i < this.length; ++i) { + clone.setCell(i, this.loadCell(i, workCell)); + } + clone.isWrapped = this.isWrapped; + return clone; + } + + public getTrimmedLength(): number { + for (let i = this.length - 1; i >= 0; --i) { + if ((this._buffer.getCell(this._row, i)[0] & Content.HAS_CONTENT_MASK)) { + return i + (this._buffer.getCell(this._row, i)[0] >> Content.WIDTH_SHIFT); + } + } + return 0; + } + + public getNoBgTrimmedLength(): number { + for (let i = this.length - 1; i >= 0; --i) { + const [content, , bg] = this._buffer.getCell(this._row, i); + if ((content & Content.HAS_CONTENT_MASK) || (bg & Attributes.CM_MASK)) { + return i + (content >> Content.WIDTH_SHIFT); + } + } + return 0; + } + + public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { + startCol = startCol ?? 0; + endCol = endCol ?? this.length; + if (trimRight) { + endCol = Math.min(endCol, this.getTrimmedLength()); + } + if (outColumns) { + outColumns.length = 0; + } + let result = ''; + while (startCol < endCol) { + const content = this._buffer.getCell(this._row, startCol)[0]; + const cp = content & Content.CODEPOINT_MASK; + const chars = (content & Content.IS_COMBINED_MASK) + ? (this._buffer.getCombined(this._row, startCol) ?? '') + : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; + result += chars; + if (outColumns) { + for (let i = 0; i < chars.length; ++i) { + outColumns.push(startCol); + } + } + startCol += (content >> Content.WIDTH_SHIFT) || 1; + } + if (outColumns) { + outColumns.push(startCol); + } + return result; + } + + public getWidth(index: number): number { + return this._buffer.getCell(this._row, index)[0] >> Content.WIDTH_SHIFT; + } + + public hasWidth(index: number): number { + return this._buffer.getCell(this._row, index)[0] & Content.WIDTH_MASK; + } + + public getFg(index: number): number { + return this._buffer.getCell(this._row, index)[1]; + } + + public getBg(index: number): number { + return this._buffer.getCell(this._row, index)[2]; + } + + public hasContent(index: number): number { + return this._buffer.getCell(this._row, index)[0] & Content.HAS_CONTENT_MASK; + } + + public getCodePoint(index: number): number { + const content = this._buffer.getCell(this._row, index)[0]; + if (content & Content.IS_COMBINED_MASK) { + const combined = this._buffer.getCombined(this._row, index) ?? ''; + return combined.charCodeAt(combined.length - 1); + } + return content & Content.CODEPOINT_MASK; + } + + public isCombined(index: number): number { + return this._buffer.getCell(this._row, index)[0] & Content.IS_COMBINED_MASK; + } + + public getString(index: number): string { + const content = this._buffer.getCell(this._row, index)[0]; + if (content & Content.IS_COMBINED_MASK) { + return this._buffer.getCombined(this._row, index) ?? ''; + } + if (content & Content.CODEPOINT_MASK) { + return stringFromCodePoint(content & Content.CODEPOINT_MASK); + } + return ''; + } + + public isProtected(index: number): number { + return this._buffer.getCell(this._row, index)[2] & BgFlags.PROTECTED; + } +} diff --git a/src/common/buffer/GhosttyCircularList.ts b/src/common/buffer/GhosttyCircularList.ts new file mode 100644 index 000000000..d45429af5 --- /dev/null +++ b/src/common/buffer/GhosttyCircularList.ts @@ -0,0 +1,270 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICircularList, IBufferLine } from 'common/Types'; +import { Disposable } from 'common/Lifecycle'; +import { Emitter } from 'common/Event'; +import { IDeleteEvent, IInsertEvent } from 'common/CircularList'; +import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer'; +import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine'; +import { CellData } from 'common/buffer/CellData'; +import { BgFlags, Content } from 'common/buffer/Constants'; + +export class GhosttyCircularList extends Disposable implements ICircularList { + private _startIndex: number = 0; + private _length: number = 0; + private _lines: GhosttyBufferLine[]; + + public readonly onDeleteEmitter = this._register(new Emitter()); + public readonly onDelete = this.onDeleteEmitter.event; + public readonly onInsertEmitter = this._register(new Emitter()); + public readonly onInsert = this.onInsertEmitter.event; + public readonly onTrimEmitter = this._register(new Emitter()); + public readonly onTrim = this.onTrimEmitter.event; + + private _workCell = new CellData(); + + constructor( + private _buffer: GhosttyWasmBuffer, + private _maxLength: number + ) { + super(); + this._lines = new Array(_maxLength); + for (let i = 0; i < _maxLength; i++) { + this._lines[i] = new GhosttyBufferLine(this._buffer, i); + } + } + + public get maxLength(): number { + return this._maxLength; + } + + public set maxLength(newMaxLength: number) { + if (this._maxLength === newMaxLength) { + return; + } + + const nextLength = Math.min(newMaxLength, this.length); + const oldLines = this._getLogicalLines(nextLength); + const newLines = new Array(newMaxLength); + for (let i = 0; i < newMaxLength; i++) { + newLines[i] = new GhosttyBufferLine(this._buffer, i); + } + for (let i = 0; i < nextLength; i++) { + this._buffer.copyRow(oldLines[i].row, newLines[i].row); + } + + this._lines = newLines; + this._maxLength = newMaxLength; + this._startIndex = 0; + this._length = nextLength; + } + + public get length(): number { + return this._length; + } + + public set length(newLength: number) { + this._length = newLength; + } + + public get isFull(): boolean { + return this._length === this._maxLength; + } + + public get(index: number): IBufferLine | undefined { + if (index < 0 || index >= this._length) { + return undefined; + } + return this._lines[this._getCyclicIndex(index)]; + } + + public set(index: number, value: IBufferLine): void { + if (index < 0) { + return; + } + if (index >= this._length) { + this._length = Math.min(index + 1, this._maxLength); + } + this._setIndex(index, value); + } + + public push(value: IBufferLine): void { + if (this._length === this._maxLength) { + this._startIndex = ++this._startIndex % this._maxLength; + this.onTrimEmitter.fire(1); + } else { + this._length++; + } + this.set(this._length - 1, value); + } + + public recycle(): GhosttyBufferLine { + if (!this.isFull) { + throw new Error('recycle is only supported when buffer is full'); + } + this._startIndex = ++this._startIndex % this._maxLength; + this.onTrimEmitter.fire(1); + return this._lines[this._getCyclicIndex(this._length - 1)]; + } + + public pop(): IBufferLine | undefined { + if (this._length === 0) { + return undefined; + } + const value = this._lines[this._getCyclicIndex(this._length - 1)]; + this._length--; + return value; + } + + public splice(start: number, deleteCount: number, ...items: IBufferLine[]): void { + if (deleteCount === 0 && items.length === 0) { + return; + } + + const before = Math.max(0, Math.min(start, this._length)); + const actualDeleteCount = Math.min(deleteCount, this._length - before); + + if (actualDeleteCount) { + for (let i = before; i < this._length - actualDeleteCount; i++) { + const src = this.get(i + actualDeleteCount); + if (src) { + this._setIndex(i, src); + } + } + this._length -= actualDeleteCount; + this.onDeleteEmitter.fire({ index: before, amount: actualDeleteCount }); + } + + if (items.length) { + for (let i = this._length - 1; i >= before; i--) { + const src = this.get(i); + if (src) { + this._setIndex(i + items.length, src); + } + } + for (let i = 0; i < items.length; i++) { + this._setIndex(before + i, items[i]); + } + this.onInsertEmitter.fire({ index: before, amount: items.length }); + + if (this._length + items.length > this._maxLength) { + const countToTrim = this._length + items.length - this._maxLength; + this._startIndex = (this._startIndex + countToTrim) % this._maxLength; + this._length = this._maxLength; + this.onTrimEmitter.fire(countToTrim); + } else { + this._length += items.length; + } + } + } + + public trimStart(count: number): void { + if (count <= 0) { + return; + } + count = Math.min(count, this._length); + if (count > 0) { + this._startIndex = (this._startIndex + count) % this._maxLength; + this._length -= count; + this.onTrimEmitter.fire(count); + } + } + + public shiftElements(start: number, count: number, offset: number): void { + if (count <= 0) { + return; + } + if (start < 0 || start >= this._length) { + throw new Error('start argument out of range'); + } + if (start + offset < 0) { + throw new Error('Cannot shift elements in list beyond index 0'); + } + + if (offset > 0) { + for (let i = count - 1; i >= 0; i--) { + const srcLine = this.get(start + i); + if (srcLine) { + this._setIndex(start + i + offset, srcLine); + } + } + const expandListBy = (start + count + offset) - this._length; + if (expandListBy > 0) { + this._length += expandListBy; + while (this._length > this._maxLength) { + this._length--; + this._startIndex = (this._startIndex + 1) % this._maxLength; + this.onTrimEmitter.fire(1); + } + } + } else if (offset < 0) { + for (let i = 0; i < count; i++) { + const srcLine = this.get(start + i); + if (srcLine) { + this._setIndex(start + i + offset, srcLine); + } + } + } + } + + public normalize(): void { + if (this._startIndex === 0) { + return; + } + const snapshot = this._getLogicalLines(this._length).map(line => line.clone()); + for (let i = 0; i < snapshot.length; i++) { + this._copyInto(this._lines[i].row, snapshot[i]); + } + this._startIndex = 0; + } + + private _setIndex(index: number, value: IBufferLine): void { + const line = this._lines[this._getCyclicIndex(index)]; + this._copyInto(line.row, value); + } + + private _getCyclicIndex(index: number): number { + return (this._startIndex + index) % this._maxLength; + } + + private _copyInto(rowIndex: number, value: IBufferLine): void { + const nullContent = 1 << Content.WIDTH_SHIFT; + if (value instanceof GhosttyBufferLine) { + this._buffer.copyRow(value.row, rowIndex); + return; + } + + const colsToCopy = Math.min(this._buffer.cols, value.length); + for (let col = 0; col < colsToCopy; col++) { + const cell = value.loadCell(col, this._workCell); + this._buffer.setCell(rowIndex, col, cell.content, cell.fg, cell.bg); + if (cell.content & Content.IS_COMBINED_MASK) { + this._buffer.setCombined(rowIndex, col, cell.combinedData); + } else { + this._buffer.clearCombined(rowIndex, col); + } + if (cell.bg & BgFlags.HAS_EXTENDED) { + this._buffer.setExtended(rowIndex, col, cell.extended); + } else { + this._buffer.clearExtended(rowIndex, col); + } + } + for (let col = colsToCopy; col < this._buffer.cols; col++) { + this._buffer.setCell(rowIndex, col, nullContent, 0, 0); + this._buffer.clearCombined(rowIndex, col); + this._buffer.clearExtended(rowIndex, col); + } + this._buffer.setRowWrap(rowIndex, value.isWrapped); + } + + private _getLogicalLines(count: number): GhosttyBufferLine[] { + const result: GhosttyBufferLine[] = []; + for (let i = 0; i < count; i++) { + result.push(this._lines[this._getCyclicIndex(i)]); + } + return result; + } +} diff --git a/src/common/buffer/GhosttyWasmBuffer.ts b/src/common/buffer/GhosttyWasmBuffer.ts new file mode 100644 index 000000000..36c7d9d15 --- /dev/null +++ b/src/common/buffer/GhosttyWasmBuffer.ts @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime'; +import { IExtendedAttrs } from 'common/Types'; + +const CELL_STRIDE = 3; + +export class GhosttyWasmBuffer { + private readonly _wasm: IGhosttyWasmExports; + private _handle: number; + private _cols: number; + private _rows: number; + private _maxRows: number; + + private _combined = new Map>(); + private _extended = new Map>(); + + private _cellScratchPtr: number; + + constructor(cols: number, rows: number, maxRows: number) { + this._wasm = GhosttyWasmRuntime.getInstanceSync(); + this._cols = cols; + this._rows = rows; + this._maxRows = maxRows; + + this._handle = this._wasm.ghostty_xterm_buffer_new(cols, rows, maxRows); + if (!this._handle) { + throw new Error('Failed to allocate Ghostty xterm buffer'); + } + + this._cellScratchPtr = this._wasm.ghostty_wasm_alloc_u8_array(CELL_STRIDE * 4); + if (!this._cellScratchPtr) { + this._wasm.ghostty_xterm_buffer_free(this._handle); + this._handle = 0; + throw new Error('Failed to allocate Ghostty cell scratch buffer'); + } + } + + public dispose(): void { + if (this._handle) { + this._wasm.ghostty_xterm_buffer_free(this._handle); + this._handle = 0; + } + if (this._cellScratchPtr) { + this._wasm.ghostty_wasm_free_u8_array(this._cellScratchPtr, CELL_STRIDE * 4); + this._cellScratchPtr = 0; + } + } + + public get cols(): number { + return this._cols; + } + + public get rows(): number { + return this._rows; + } + + public get maxRows(): number { + return this._maxRows; + } + + public resize(cols: number, rows: number, maxRows: number): void { + const oldCols = this._cols; + if (!this._wasm.ghostty_xterm_buffer_resize(this._handle, cols, rows, maxRows)) { + throw new Error('Failed to resize Ghostty xterm buffer'); + } + this._cols = cols; + this._rows = rows; + this._maxRows = maxRows; + + if (cols < oldCols) { + for (const [row, rowMap] of this._combined) { + for (const col of rowMap.keys()) { + if (col >= cols) { + rowMap.delete(col); + } + } + if (rowMap.size === 0) { + this._combined.delete(row); + } + } + for (const [row, rowMap] of this._extended) { + for (const col of rowMap.keys()) { + if (col >= cols) { + rowMap.delete(col); + } + } + if (rowMap.size === 0) { + this._extended.delete(row); + } + } + } + } + + public clear(): void { + this._wasm.ghostty_xterm_buffer_clear(this._handle); + this._combined.clear(); + this._extended.clear(); + } + + public getCell(row: number, col: number): [number, number, number] { + this._wasm.ghostty_xterm_buffer_get_cell(this._handle, row, col, this._cellScratchPtr); + const view = new DataView(this._wasm.memory.buffer, this._cellScratchPtr, CELL_STRIDE * 4); + const content = view.getUint32(0, true); + const fg = view.getUint32(4, true); + const bg = view.getUint32(8, true); + return [content, fg, bg]; + } + + public setCell(row: number, col: number, content: number, fg: number, bg: number): void { + this._wasm.ghostty_xterm_buffer_set_cell(this._handle, row, col, content, fg, bg); + } + + public clearRow(row: number, content: number, fg: number, bg: number): void { + this._wasm.ghostty_xterm_buffer_clear_row(this._handle, row, content, fg, bg); + this._combined.delete(row); + this._extended.delete(row); + } + + public copyRow(srcRow: number, dstRow: number): void { + this._wasm.ghostty_xterm_buffer_copy_row(this._handle, srcRow, dstRow); + const combined = this._combined.get(srcRow); + if (combined) { + this._combined.set(dstRow, new Map(combined)); + } else { + this._combined.delete(dstRow); + } + const extended = this._extended.get(srcRow); + if (extended) { + this._extended.set(dstRow, new Map(extended)); + } else { + this._extended.delete(dstRow); + } + } + + public getRowWrap(row: number): boolean { + return this._wasm.ghostty_xterm_buffer_get_row_wrap(this._handle, row) !== 0; + } + + public setRowWrap(row: number, wrap: boolean): void { + this._wasm.ghostty_xterm_buffer_set_row_wrap(this._handle, row, wrap ? 1 : 0); + } + + public getCombined(row: number, col: number): string | undefined { + return this._combined.get(row)?.get(col); + } + + public setCombined(row: number, col: number, data: string): void { + let rowMap = this._combined.get(row); + if (!rowMap) { + rowMap = new Map(); + this._combined.set(row, rowMap); + } + rowMap.set(col, data); + } + + public clearCombined(row: number, col: number): void { + const rowMap = this._combined.get(row); + if (!rowMap) { + return; + } + rowMap.delete(col); + if (rowMap.size === 0) { + this._combined.delete(row); + } + } + + public getExtended(row: number, col: number): IExtendedAttrs | undefined { + return this._extended.get(row)?.get(col); + } + + public getExtendedRow(row: number): Map | undefined { + return this._extended.get(row); + } + + public setExtended(row: number, col: number, data: IExtendedAttrs): void { + let rowMap = this._extended.get(row); + if (!rowMap) { + rowMap = new Map(); + this._extended.set(row, rowMap); + } + rowMap.set(col, data); + } + + public clearExtended(row: number, col: number): void { + const rowMap = this._extended.get(row); + if (!rowMap) { + return; + } + rowMap.delete(col); + if (rowMap.size === 0) { + this._extended.delete(row); + } + } +} diff --git a/src/common/parser/GhosttyWasmParser.ts b/src/common/parser/GhosttyWasmParser.ts new file mode 100644 index 000000000..083fd8856 --- /dev/null +++ b/src/common/parser/GhosttyWasmParser.ts @@ -0,0 +1,831 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Disposable, toDisposable } from 'common/Lifecycle'; +import { IDisposable } from 'common/Types'; +import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, IApcHandler, IApcParser, ApcFallbackHandlerType } from 'common/parser/Types'; +import { OscParser } from 'common/parser/OscParser'; +import { DcsParser } from 'common/parser/DcsParser'; +import { ApcParser } from 'common/parser/ApcParser'; +import { Params } from 'common/parser/Params'; +import { StringToUtf32 } from 'common/input/TextDecoder'; +import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime'; + +const enum RawEventTag { + PRINT = 0, + EXECUTE = 1, + ESC = 2, + CSI = 3, + OSC = 4, + DCS_HOOK = 5, + DCS_PUT = 6, + DCS_UNHOOK = 7, + APC_START = 8, + APC_PUT = 9, + APC_END = 10 +} + +export class GhosttyWasmParser extends Disposable implements IEscapeSequenceParser { + public precedingJoinState = 0; + + private _printHandler: PrintHandlerType; + private _executeHandlers: { [flag: number]: ExecuteHandlerType }; + private _csiHandlers: IHandlerCollection; + private _escHandlers: IHandlerCollection; + private readonly _oscParser: OscParser; + private readonly _dcsParser: IDcsParser; + private readonly _apcParser: IApcParser; + private _errorHandler: (state: IParsingState) => IParsingState; + + private _printHandlerFb: PrintFallbackHandlerType; + private _executeHandlerFb: ExecuteFallbackHandlerType; + private _csiHandlerFb: CsiFallbackHandlerType; + private _escHandlerFb: EscFallbackHandlerType; + private _errorHandlerFb: (state: IParsingState) => IParsingState; + + private _parseStack: IParserStackState = { + state: ParserStackType.NONE, + handlers: [], + handlerPos: 0, + transition: 0, + chunkPos: 0 + }; + + private _wasm?: IGhosttyWasmExports; + private _parserHandle = 0; + private _wasmReady?: Promise; + + private _eventSize = 0; + private _eventMaxParams = 0; + private _eventMaxIntermediates = 0; + private readonly _ptrSize = 4; + + private _eventIndex = 0; + private _pendingEvents: { ptr: number, len: number } | undefined; + private _resumeParams: IParams | undefined; + + private _textEncoder = new TextEncoder(); + private _textDecoder = new TextDecoder('utf-8'); + private _utf32Scratch = new Uint32Array(4096); + private _oscBuffer = new StringToUtf32(); + private _dcsPutBuffer = new Uint32Array(1); + private _apcPutBuffer = new Uint32Array(1); + + constructor() { + super(); + + this._printHandlerFb = () => { }; + this._executeHandlerFb = () => { }; + this._csiHandlerFb = () => { }; + this._escHandlerFb = () => { }; + this._errorHandlerFb = (state: IParsingState): IParsingState => state; + + this._printHandler = this._printHandlerFb; + this._executeHandlers = Object.create(null); + this._csiHandlers = Object.create(null); + this._escHandlers = Object.create(null); + + this._oscParser = this._register(new OscParser()); + this._dcsParser = this._register(new DcsParser()); + this._apcParser = this._register(new ApcParser()); + this._errorHandler = this._errorHandlerFb; + + this._register(toDisposable(() => { + this._csiHandlers = Object.create(null); + this._executeHandlers = Object.create(null); + this._escHandlers = Object.create(null); + })); + + this._register(toDisposable(() => { + if (this._wasm && this._parserHandle) { + this._wasm.ghostty_raw_parser_free(this._parserHandle); + } + this._parserHandle = 0; + })); + + this.registerEscHandler({ final: '\\' }, () => true); + } + + public identToString(ident: number): string { + const res: string[] = []; + while (ident) { + res.push(String.fromCharCode(ident & 0xFF)); + ident >>= 8; + } + return res.reverse().join(''); + } + + public setPrintHandler(handler: PrintHandlerType): void { + this._printHandler = handler; + } + + public clearPrintHandler(): void { + this._printHandler = this._printHandlerFb; + } + + public registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable { + const ident = this._identifier(id, [0x30, 0x7e]); + this._escHandlers[ident] ??= []; + const handlerList = this._escHandlers[ident]; + handlerList.push(handler); + return { + dispose: () => { + const handlerIndex = handlerList.indexOf(handler); + if (handlerIndex !== -1) { + handlerList.splice(handlerIndex, 1); + } + } + }; + } + + public clearEscHandler(id: IFunctionIdentifier): void { + if (this._escHandlers[this._identifier(id, [0x30, 0x7e])]) delete this._escHandlers[this._identifier(id, [0x30, 0x7e])]; + } + + public setEscHandlerFallback(handler: EscFallbackHandlerType): void { + this._escHandlerFb = handler; + } + + public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void { + this._executeHandlers[flag.charCodeAt(0)] = handler; + } + + public clearExecuteHandler(flag: string): void { + if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)]; + } + + public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void { + this._executeHandlerFb = handler; + } + + public registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable { + const ident = this._identifier(id); + this._csiHandlers[ident] ??= []; + const handlerList = this._csiHandlers[ident]; + handlerList.push(handler); + return { + dispose: () => { + const handlerIndex = handlerList.indexOf(handler); + if (handlerIndex !== -1) { + handlerList.splice(handlerIndex, 1); + } + } + }; + } + + public clearCsiHandler(id: IFunctionIdentifier): void { + if (this._csiHandlers[this._identifier(id)]) delete this._csiHandlers[this._identifier(id)]; + } + + public setCsiHandlerFallback(callback: CsiFallbackHandlerType): void { + this._csiHandlerFb = callback; + } + + public registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable { + return this._dcsParser.registerHandler(this._identifier(id), handler); + } + + public clearDcsHandler(id: IFunctionIdentifier): void { + this._dcsParser.clearHandler(this._identifier(id)); + } + + public setDcsHandlerFallback(handler: DcsFallbackHandlerType): void { + this._dcsParser.setHandlerFallback(handler); + } + + public registerOscHandler(ident: number, handler: IOscHandler): IDisposable { + return this._oscParser.registerHandler(ident, handler); + } + + public clearOscHandler(ident: number): void { + this._oscParser.clearHandler(ident); + } + + public setOscHandlerFallback(handler: OscFallbackHandlerType): void { + this._oscParser.setHandlerFallback(handler); + } + + public registerApcHandler(ident: number, handler: IApcHandler): IDisposable { + return this._apcParser.registerHandler(ident, handler); + } + + public clearApcHandler(ident: number): void { + this._apcParser.clearHandler(ident); + } + + public setApcHandlerFallback(handler: ApcFallbackHandlerType): void { + this._apcParser.setHandlerFallback(handler); + } + + public setErrorHandler(handler: (state: IParsingState) => IParsingState): void { + this._errorHandler = handler; + } + + public clearErrorHandler(): void { + this._errorHandler = this._errorHandlerFb; + } + + public reset(): void { + this._oscParser.reset(); + this._dcsParser.reset(); + this._apcParser.reset(); + this.precedingJoinState = 0; + if (this._wasm && this._parserHandle) { + this._wasm.ghostty_raw_parser_reset(this._parserHandle); + } + if (this._parseStack.state !== ParserStackType.NONE) { + this._parseStack.state = ParserStackType.RESET; + this._parseStack.handlers = []; + } + } + + public parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise { + if (!this._wasm) { + this._wasmReady ??= this._initWasm(); + return this._wasmReady; + } + + if (this._parseStack.state !== ParserStackType.NONE) { + return this._resume(promiseResult); + } + + const bytes = this._encodeUtf32(data, length); + const result = this._writeToWasm(bytes); + if (result) { + return result; + } + return this._drainEvents(); + } + + private _resume(promiseResult?: boolean): void | Promise { + if (promiseResult === undefined || this._parseStack.state === ParserStackType.FAIL) { + this._parseStack.state = ParserStackType.FAIL; + throw new Error('improper continuation due to previous async handler, giving up parsing'); + } + + switch (this._parseStack.state) { + case ParserStackType.CSI: + case ParserStackType.ESC: + return this._resumeHandlerLoop(promiseResult); + case ParserStackType.OSC: + return this._resumeOsc(promiseResult); + case ParserStackType.DCS: + return this._resumeDcs(promiseResult); + case ParserStackType.APC: + return this._resumeApc(promiseResult); + case ParserStackType.RESET: + this._parseStack.state = ParserStackType.NONE; + return; + default: + this._parseStack.state = ParserStackType.NONE; + return; + } + } + + private _resumeHandlerLoop(promiseResult: boolean): void | Promise { + const handlers = this._parseStack.handlers as CsiHandlerType[] | EscHandlerType[]; + let handlerPos = this._parseStack.handlerPos - 1; + if (promiseResult === false && handlerPos > -1) { + if (this._parseStack.state === ParserStackType.CSI) { + const params = this._resumeParams ?? new Params(); + for (; handlerPos >= 0; handlerPos--) { + const handlerResult = (handlers as CsiHandlerType[])[handlerPos](params); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + this._parseStack.handlerPos = handlerPos; + return handlerResult; + } + } + } else { + for (; handlerPos >= 0; handlerPos--) { + const handlerResult = (handlers as EscHandlerType[])[handlerPos](); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + this._parseStack.handlerPos = handlerPos; + return handlerResult; + } + } + } + } + + this._resumeParams = undefined; + this._parseStack.handlers = []; + this._parseStack.state = ParserStackType.NONE; + return this._continueAfterAsync(); + } + + private _resumeOsc(promiseResult: boolean): void | Promise { + const handlerResult = this._oscParser.end(true, promiseResult); + if (handlerResult) { + return handlerResult as Promise; + } + this._parseStack.state = ParserStackType.NONE; + return this._continueAfterAsync(); + } + + private _resumeDcs(promiseResult: boolean): void | Promise { + const handlerResult = this._dcsParser.unhook(true, promiseResult); + if (handlerResult) { + return handlerResult as Promise; + } + this._parseStack.state = ParserStackType.NONE; + return this._continueAfterAsync(); + } + + private _resumeApc(promiseResult: boolean): void | Promise { + const handlerResult = this._apcParser.end(true, promiseResult); + if (handlerResult) { + return handlerResult as Promise; + } + this._parseStack.state = ParserStackType.NONE; + return this._continueAfterAsync(); + } + + private _identifier(id: IFunctionIdentifier, finalRange: number[] = [0x40, 0x7e]): number { + let res = 0; + if (id.prefix) { + if (id.prefix.length > 1) { + throw new Error('only one byte as prefix supported'); + } + res = id.prefix.charCodeAt(0); + if (res && 0x3c > res || res > 0x3f) { + throw new Error('prefix must be in range 0x3c .. 0x3f'); + } + } + if (id.intermediates) { + if (id.intermediates.length > 2) { + throw new Error('only two bytes as intermediates are supported'); + } + for (let i = 0; i < id.intermediates.length; ++i) { + const intermediate = id.intermediates.charCodeAt(i); + if (0x20 > intermediate || intermediate > 0x2f) { + throw new Error('intermediate must be in range 0x20 .. 0x2f'); + } + res <<= 8; + res |= intermediate; + } + } + if (id.final.length !== 1) { + throw new Error('final must be a single byte'); + } + const finalCode = id.final.charCodeAt(0); + if (finalRange[0] > finalCode || finalCode > finalRange[1]) { + throw new Error(`final must be in range ${finalRange[0]} .. ${finalRange[1]}`); + } + res <<= 8; + res |= finalCode; + + return res; + } + + private async _initWasm(): Promise { + this._wasm = await GhosttyWasmRuntime.getInstance(); + this._parserHandle = this._wasm.ghostty_raw_parser_new(0); + this._eventSize = this._wasm.ghostty_raw_parser_event_size(); + this._eventMaxParams = this._wasm.ghostty_raw_parser_max_params(); + this._eventMaxIntermediates = this._wasm.ghostty_raw_parser_max_intermediates(); + return true; + } + + private _encodeUtf32(data: Uint32Array, length: number): Uint8Array { + if (length === 0) { + return new Uint8Array(0); + } + const bytes: number[] = []; + for (let i = 0; i < length; i++) { + const codePoint = data[i]; + if (codePoint === 0x9c) { + bytes.push(0x1b, 0x5c); + continue; + } + if (codePoint <= 0x7f || (codePoint >= 0x80 && codePoint <= 0x9f)) { + bytes.push(codePoint); + } else if (codePoint <= 0x7ff) { + bytes.push( + 0xc0 | (codePoint >> 6), + 0x80 | (codePoint & 0x3f) + ); + } else if (codePoint <= 0xffff) { + bytes.push( + 0xe0 | (codePoint >> 12), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f) + ); + } else { + bytes.push( + 0xf0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3f), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f) + ); + } + } + return new Uint8Array(bytes); + } + + private _writeToWasm(bytes: Uint8Array): void | Promise { + if (!this._wasm || !this._parserHandle) { + return; + } + if (!bytes.length) { + return; + } + + const dataPtr = this._wasm.ghostty_wasm_alloc_u8_array(bytes.length); + if (!dataPtr) { + return; + } + + const mem = new Uint8Array(this._wasm.memory.buffer, dataPtr, bytes.length); + mem.set(bytes); + this._wasm.ghostty_raw_parser_write(this._parserHandle, dataPtr, bytes.length); + this._wasm.ghostty_wasm_free_u8_array(dataPtr, bytes.length); + } + + private _drainEvents(): void | Promise { + if (!this._wasm || !this._parserHandle) { + return; + } + + const eventsPtr = this._wasm.ghostty_raw_parser_events_ptr(this._parserHandle); + const eventsLen = this._wasm.ghostty_raw_parser_events_len(this._parserHandle); + if (!eventsPtr || !eventsLen) { + this._wasm.ghostty_raw_parser_events_clear(this._parserHandle); + return; + } + + this._pendingEvents = { ptr: eventsPtr, len: eventsLen }; + this._eventIndex = 0; + + const result = this._processEvents(); + if (result) { + return result; + } + + this._wasm.ghostty_raw_parser_events_clear(this._parserHandle); + this._pendingEvents = undefined; + } + + private _continueAfterAsync(): void | Promise { + if (!this._wasm || !this._parserHandle || !this._pendingEvents) { + return; + } + + this._eventIndex++; + const result = this._processEvents(); + if (result) { + return result; + } + + this._wasm.ghostty_raw_parser_events_clear(this._parserHandle); + this._pendingEvents = undefined; + } + + private _processEvents(): void | Promise { + if (!this._pendingEvents || !this._wasm) { + return; + } + + const { ptr, len } = this._pendingEvents; + const mem = this._wasm.memory.buffer; + const view = new DataView(mem); + + let textStart = 0; + let textLen = 0; + + for (; this._eventIndex < len; this._eventIndex++) { + const base = ptr + this._eventIndex * this._eventSize; + const tag = view.getInt32(base, true); + + if (tag !== RawEventTag.PRINT && textLen > 0) { + this._flushText(view, ptr, textStart, textLen); + textLen = 0; + } + + switch (tag) { + case RawEventTag.PRINT: { + if (textLen === 0) { + textStart = this._eventIndex; + } + textLen++; + break; + } + case RawEventTag.EXECUTE: { + const code = view.getUint32(base + 4, true); + this._handleExecute(code); + break; + } + case RawEventTag.ESC: { + const event = this._readEvent(view, base); + const ident = this._buildIdent(event.intermediates, event.final, false, [0x30, 0x7e]); + const handlers = this._escHandlers[ident]; + const handlerResult = this._callEscHandlers(handlers, ident); + if (handlerResult) { + return handlerResult; + } + break; + } + case RawEventTag.CSI: { + const event = this._readEvent(view, base); + const params = this._buildParams(event.params, event.paramsLen, event.paramsSep); + const ident = this._buildIdent(event.intermediates, event.final, true); + const handlers = this._csiHandlers[ident]; + const handlerResult = this._callCsiHandlers(handlers, ident, params); + if (handlerResult) { + return handlerResult; + } + break; + } + case RawEventTag.OSC: { + const event = this._readEvent(view, base); + const oscResult = this._handleOsc(event.code, event.dataPtr, event.dataLen); + if (oscResult) { + return oscResult; + } + break; + } + case RawEventTag.DCS_HOOK: { + const event = this._readEvent(view, base); + const params = this._buildParams(event.params, event.paramsLen, event.paramsSep); + const ident = this._buildIdent(event.intermediates, event.final, true); + this._dcsParser.hook(ident, params); + this.precedingJoinState = 0; + break; + } + case RawEventTag.DCS_PUT: { + const code = view.getUint32(base + 4, true); + this._dcsPutBuffer[0] = code; + this._dcsParser.put(this._dcsPutBuffer, 0, 1); + break; + } + case RawEventTag.DCS_UNHOOK: { + const dcsResult = this._dcsParser.unhook(true); + if (dcsResult) { + this._parseStack.state = ParserStackType.DCS; + this.precedingJoinState = 0; + return dcsResult as Promise; + } + this.precedingJoinState = 0; + break; + } + case RawEventTag.APC_START: { + this._apcParser.start(); + this.precedingJoinState = 0; + break; + } + case RawEventTag.APC_PUT: { + const code = view.getUint32(base + 4, true); + this._apcPutBuffer[0] = code; + this._apcParser.put(this._apcPutBuffer, 0, 1); + break; + } + case RawEventTag.APC_END: { + const apcResult = this._apcParser.end(true); + if (apcResult) { + this._parseStack.state = ParserStackType.APC; + this.precedingJoinState = 0; + return apcResult as Promise; + } + this.precedingJoinState = 0; + break; + } + } + } + + if (textLen > 0) { + this._flushText(view, ptr, textStart, textLen); + } + + return; + } + + private _flushText(view: DataView, ptr: number, start: number, count: number): void { + let remaining = count; + let offset = 0; + while (remaining > 0) { + const chunk = Math.min(remaining, this._utf32Scratch.length); + for (let i = 0; i < chunk; i++) { + const base = ptr + (start + offset + i) * this._eventSize; + const code = view.getUint32(base + 4, true); + this._utf32Scratch[i] = code; + } + this._printHandler(this._utf32Scratch, 0, chunk); + remaining -= chunk; + offset += chunk; + } + } + + private _handleExecute(code: number): void { + if (this._executeHandlers[code]) { + this._executeHandlers[code](); + } else { + this._executeHandlerFb(code); + } + this.precedingJoinState = 0; + } + + private _callEscHandlers(handlers: EscHandlerType[] | undefined, ident: number): void | Promise { + if (!handlers) { + this._escHandlerFb(ident); + this.precedingJoinState = 0; + return; + } + let j = handlers.length - 1; + for (; j >= 0; j--) { + const handlerResult = handlers[j](); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + this._parseStack.state = ParserStackType.ESC; + this._parseStack.handlers = handlers; + this._parseStack.handlerPos = j; + this.precedingJoinState = 0; + return handlerResult; + } + } + if (j < 0) { + this._escHandlerFb(ident); + } + this.precedingJoinState = 0; + } + + private _callCsiHandlers(handlers: CsiHandlerType[] | undefined, ident: number, params: IParams): void | Promise { + if (!handlers) { + this._csiHandlerFb(ident, params); + this.precedingJoinState = 0; + return; + } + let j = handlers.length - 1; + for (; j >= 0; j--) { + const handlerResult = handlers[j](params); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + this._parseStack.state = ParserStackType.CSI; + this._resumeParams = params; + this._parseStack.handlers = handlers; + this._parseStack.handlerPos = j; + this.precedingJoinState = 0; + return handlerResult; + } + } + if (j < 0) { + this._csiHandlerFb(ident, params); + } + this.precedingJoinState = 0; + } + + private _handleOsc(oscId: number, dataPtr: number, dataLen: number): void | Promise { + console.log('handle osc'); + if (!this._wasm) { + return; + } + + let payload = ''; + if (dataLen > 0) { + if (!dataPtr || dataPtr + dataLen > this._wasm.memory.buffer.byteLength) { + return; + } + const bytes = new Uint8Array(this._wasm.memory.buffer, dataPtr, dataLen); + payload = this._textDecoder.decode(bytes); + } + if (payload.indexOf('\u0000') !== -1) { + if (oscId === 8) { + const parts = payload.split('\u0000').filter((part, index, all) => { + if (part.length > 0) { + return true; + } + return index < all.length - 1; + }); + if (parts.length > 0) { + const uri = parts[parts.length - 1] ?? ''; + const params = parts.slice(0, -1).join(':'); + payload = `${params};${uri}`; + } else { + payload = ''; + } + } else { + payload = payload.replace(/\u0000+/g, ''); + } + } + const oscPrefix = `${oscId};`; + const rawPayload = payload.startsWith(oscPrefix) + ? payload.slice(oscPrefix.length) + : payload; + let oscPayload = rawPayload; + if (oscId === 8 && oscPayload && !oscPayload.startsWith(';')) { + const sepIndex = oscPayload.indexOf(';'); + const paramsPart = sepIndex === -1 ? oscPayload : oscPayload.slice(0, sepIndex); + if (!paramsPart.includes('=')) { + oscPayload = `;${oscPayload}`; + } + } + const oscString = `${oscPrefix}${oscPayload}`; + + this._oscParser.start(); + const length = this._decodeToScratch(oscString); + this._oscParser.put(this._utf32Scratch, 0, length); + const handlerResult = this._oscParser.end(true); + if (handlerResult) { + this._parseStack.state = ParserStackType.OSC; + this.precedingJoinState = 0; + return handlerResult as Promise; + } + this.precedingJoinState = 0; + } + + private _buildParams(params: Uint16Array, length: number, sepBits: number): Params { + const result = new Params(this._eventMaxParams, 256); + if (length === 0) { + result.addParam(0); + return result; + } + + let i = 0; + while (i < length) { + result.addParam(params[i]); + if (sepBits & (1 << i)) { + i++; + while (i < length) { + result.addSubParam(params[i]); + if ((sepBits & (1 << i)) === 0) { + break; + } + i++; + } + } + i++; + } + + return result; + } + + private _buildIdent(intermediates: string, final: string, allowPrefix: boolean, finalRange: number[] = [0x40, 0x7e]): number { + let prefix: string | undefined; + let inters = intermediates; + if (allowPrefix && intermediates.length) { + const first = intermediates.charCodeAt(0); + if (first >= 0x3c && first <= 0x3f) { + prefix = intermediates.charAt(0); + inters = intermediates.substring(1); + } + } + return this._identifier({ prefix, intermediates: inters || undefined, final }, finalRange); + } + + private _readEvent(view: DataView, base: number): { + final: string; + intermediates: string; + params: Uint16Array; + paramsLen: number; + paramsSep: number; + code: number; + dataPtr: number; + dataLen: number; + } { + const code = view.getUint32(base + 4, true); + const final = String.fromCharCode(view.getUint8(base + 8)); + const intermediatesLen = Math.min(view.getUint8(base + 9), this._eventMaxIntermediates); + const paramsLen = Math.min(view.getUint8(base + 10), this._eventMaxParams); + const paramsSep = view.getUint32(base + 12, true); + + let intermediates = ''; + const interOffset = base + 16; + for (let i = 0; i < intermediatesLen; i++) { + intermediates += String.fromCharCode(view.getUint8(interOffset + i)); + } + + const paramsOffset = base + 16 + this._eventMaxIntermediates; + const params = new Uint16Array(paramsLen); + for (let i = 0; i < paramsLen; i++) { + params[i] = view.getUint16(paramsOffset + i * 2, true); + } + + const rawDataPtrOffset = paramsOffset + this._eventMaxParams * 2; + const dataPtrOffset = (rawDataPtrOffset + (this._ptrSize - 1)) & ~(this._ptrSize - 1); + const dataPtr = view.getUint32(dataPtrOffset, true); + const dataLen = view.getUint32(dataPtrOffset + this._ptrSize, true); + + return { + final, + intermediates, + params, + paramsLen, + paramsSep, + code, + dataPtr, + dataLen + }; + } + + private _decodeToScratch(text: string): number { + if (this._utf32Scratch.length < text.length * 2) { + this._utf32Scratch = new Uint32Array(text.length * 2); + } + return this._oscBuffer.decode(text, this._utf32Scratch); + } +} diff --git a/src/common/parser/GhosttyWasmRuntime.ts b/src/common/parser/GhosttyWasmRuntime.ts new file mode 100644 index 000000000..558d6cc27 --- /dev/null +++ b/src/common/parser/GhosttyWasmRuntime.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/* eslint-disable @typescript-eslint/naming-convention */ +export interface IGhosttyWasmExports { + memory: WebAssembly.Memory; + ghostty_raw_parser_new(allocPtr: number): number; + ghostty_raw_parser_free(parser: number): void; + ghostty_raw_parser_reset(parser: number): void; + ghostty_raw_parser_write(parser: number, dataPtr: number, len: number): void; + ghostty_raw_parser_events_ptr(parser: number): number; + ghostty_raw_parser_events_len(parser: number): number; + ghostty_raw_parser_events_clear(parser: number): void; + ghostty_raw_parser_event_size(): number; + ghostty_raw_parser_max_params(): number; + ghostty_raw_parser_max_intermediates(): number; + ghostty_wasm_alloc_u8_array(len: number): number; + ghostty_wasm_free_u8_array(ptr: number, len: number): void; + ghostty_xterm_buffer_new(cols: number, rows: number, maxRows: number): number; + ghostty_xterm_buffer_free(buffer: number): void; + ghostty_xterm_buffer_resize(buffer: number, cols: number, rows: number, maxRows: number): number; + ghostty_xterm_buffer_clear(buffer: number): void; + ghostty_xterm_buffer_get_cols(buffer: number): number; + ghostty_xterm_buffer_get_rows(buffer: number): number; + ghostty_xterm_buffer_get_max_rows(buffer: number): number; + ghostty_xterm_buffer_get_cell(buffer: number, row: number, col: number, outPtr: number): void; + ghostty_xterm_buffer_set_cell(buffer: number, row: number, col: number, content: number, fg: number, bg: number): void; + ghostty_xterm_buffer_clear_row(buffer: number, row: number, content: number, fg: number, bg: number): void; + ghostty_xterm_buffer_copy_row(buffer: number, srcRow: number, dstRow: number): void; + ghostty_xterm_buffer_get_row_wrap(buffer: number, row: number): number; + ghostty_xterm_buffer_set_row_wrap(buffer: number, row: number, wrap: number): void; +} +/* eslint-enable @typescript-eslint/naming-convention */ + +export class GhosttyWasmRuntime { + private static _instancePromise: Promise | undefined; + private static _instanceSync: IGhosttyWasmExports | undefined; + + public static getInstance(): Promise { + this._instancePromise ??= this._loadAsync(); + return this._instancePromise; + } + + public static getInstanceSync(): IGhosttyWasmExports { + this._instanceSync ??= this._loadSync(); + return this._instanceSync; + } + + private static async _loadAsync(): Promise { + const wasmUrl = GhosttyWasmRuntime._getWasmUrl(); + const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node; + if (isNode) { + const requireFn = (globalThis as any).require + ?? (globalThis as any).process?.mainModule?.require + ?? (globalThis as any).__xtermRequire; + const fs = requireFn?.('fs') as { readFile(path: string, cb: (err: Error | null, data?: Uint8Array) => void): void } | undefined; + if (!fs) { + throw new Error('Node.js async WASM loading requires require("fs")'); + } + const wasmBytes = await new Promise((resolve, reject) => { + fs.readFile(wasmUrl, (err, data) => { + if (err || !data) { + reject(err ?? new Error(`Failed to load Ghostty WASM from ${wasmUrl}`)); + return; + } + resolve(data); + }); + }); + const env = { log: () => {} }; + const { instance } = await WebAssembly.instantiate(wasmBytes, { env }); + return instance.exports as unknown as IGhosttyWasmExports; + } + + const response = await fetch(wasmUrl); + if (!response.ok) { + throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`); + } + const wasmBytes = await response.arrayBuffer(); + const env = { log: () => {} }; + const { instance } = await WebAssembly.instantiate(wasmBytes, { env }); + return instance.exports as unknown as IGhosttyWasmExports; + } + + private static _loadSync(): IGhosttyWasmExports { + const wasmUrl = GhosttyWasmRuntime._getWasmUrl(); + const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node; + if (!isNode && typeof document !== 'undefined') { + const request = new XMLHttpRequest(); + request.open('GET', wasmUrl, false); + request.overrideMimeType('text/plain; charset=x-user-defined'); + request.send(null); + if (request.status !== 200 || !request.responseText) { + throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`); + } + const binary = request.responseText; + const wasmBytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + wasmBytes[i] = binary.charCodeAt(i) & 0xFF; + } + const module = new WebAssembly.Module(wasmBytes); + const env = { log: () => {} }; + const instance = new WebAssembly.Instance(module, { env }); + return instance.exports as unknown as IGhosttyWasmExports; + } + + // Node.js sync load for tests and headless usage. + const requireFn = (globalThis as any).require + ?? (globalThis as any).process?.mainModule?.require + ?? (globalThis as any).__xtermRequire; + const fs = requireFn?.('fs') as { readFileSync(path: string): Uint8Array } | undefined; + if (!fs) { + throw new Error('Node.js sync WASM loading requires require("fs")'); + } + const wasmBytes = fs.readFileSync(wasmUrl); + const module = new WebAssembly.Module(wasmBytes); + const env = { log: () => {} }; + const instance = new WebAssembly.Instance(module, { env }); + return instance.exports as unknown as IGhosttyWasmExports; + } + + private static _getWasmUrl(): string { + const override = (globalThis as any).XTERM_GHOSTTY_WASM_URL as string | undefined; + if (override) { + return override; + } + if (typeof document !== 'undefined') { + return 'vendor/ghostty-wasm/ghostty-vt.wasm'; + } + return 'vendor/ghostty-wasm/ghostty-vt.wasm'; + } +} diff --git a/vendor/ghostty b/vendor/ghostty new file mode 160000 index 000000000..6c0e02d11 --- /dev/null +++ b/vendor/ghostty @@ -0,0 +1 @@ +Subproject commit 6c0e02d1138d5376e4ac2f5e311b225494514a97 diff --git a/vendor/ghostty-wasm/ghostty-vt.wasm b/vendor/ghostty-wasm/ghostty-vt.wasm new file mode 100644 index 000000000..350491bc4 Binary files /dev/null and b/vendor/ghostty-wasm/ghostty-vt.wasm differ