Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
11 kB · 405 lines
TypeScript
at renderstate
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406/** * Buffer API - xterm.js-compatible buffer access * * Provides read-only access to terminal buffer contents, cursor state, * and viewport position. Wraps Ghostty WASM terminal state. * * Usage: * ```typescript * const cell = term.buffer.active.getLine(0)?.getCell(5); * console.log(cell?.getChars(), cell?.isBold()); * * if (term.buffer.active.type === 'alternate') { * console.log('Full-screen app running'); * } * ``` */
import { EventEmitter } from './event-emitter';import type { GhosttyTerminal } from './ghostty';import { CellFlags } from './ghostty';import type { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IDisposable, IEvent,} from './interfaces';import type { Terminal } from './terminal';import type { GhosttyCell } from './types';
// ============================================================================// BufferNamespace - Top-level buffer API// ============================================================================
/** * Top-level buffer API namespace * Provides access to active, normal, and alternate screen buffers */export class BufferNamespace implements IBufferNamespace { private terminal: Terminal; private bufferChangeEmitter = new EventEmitter<IBuffer>();
// Lazy-initialized buffer wrappers (stateless, so we can cache them) private _normalBuffer?: Buffer; private _alternateBuffer?: Buffer;
constructor(terminal: Terminal) { this.terminal = terminal; }
get active(): IBuffer { // Query WASM to determine which buffer is active const wasmTerm = (this.terminal as any).wasmTerm as GhosttyTerminal | undefined; if (!wasmTerm) { return this.normal; // Default to normal if not initialized }
return wasmTerm.isAlternateScreen() ? this.alternate : this.normal; }
get normal(): IBuffer { if (!this._normalBuffer) { this._normalBuffer = new Buffer(this.terminal, 'normal'); } return this._normalBuffer; }
get alternate(): IBuffer { if (!this._alternateBuffer) { this._alternateBuffer = new Buffer(this.terminal, 'alternate'); } return this._alternateBuffer; }
get onBufferChange(): IEvent<IBuffer> { return this.bufferChangeEmitter.event; }
/** * Internal: Fire buffer change event when screen switches * Should be called by Terminal when detecting screen change */ _fireBufferChange(buffer: IBuffer): void { this.bufferChangeEmitter.fire(buffer); }}
// ============================================================================// Buffer - Represents a terminal buffer (normal or alternate)// ============================================================================
/** * A terminal buffer (normal or alternate screen) */export class Buffer implements IBuffer { private terminal: Terminal; private bufferType: 'normal' | 'alternate'; private nullCell: BufferCell;
constructor(terminal: Terminal, type: 'normal' | 'alternate') { this.terminal = terminal; this.bufferType = type;
// Create a null cell (codepoint=0, default colors, no flags) const nullCellData: GhosttyCell = { codepoint: 0, fg_r: 204, fg_g: 204, fg_b: 204, bg_r: 0, bg_g: 0, bg_b: 0, flags: 0, width: 1, hyperlink_id: 0, }; this.nullCell = new BufferCell(nullCellData, 0); }
get type(): 'normal' | 'alternate' { return this.bufferType; }
get cursorX(): number { const wasmTerm = this.getWasmTerm(); if (!wasmTerm) return 0; return wasmTerm.getCursor().x; }
get cursorY(): number { const wasmTerm = this.getWasmTerm(); if (!wasmTerm) return 0; return wasmTerm.getCursor().y; }
get viewportY(): number { // Get viewport offset from Terminal // For now, return 0 (no scrollback navigation implemented yet) return 0; }
get baseY(): number { // For normal buffer: 0 // For alternate buffer: 0 (alternate has no scrollback) return 0; }
get length(): number { const wasmTerm = this.getWasmTerm(); if (!wasmTerm) return 0;
if (this.bufferType === 'alternate') { // Alternate buffer has no scrollback, just visible rows return wasmTerm.rows; } else { // Normal buffer: scrollback + visible rows const scrollback = wasmTerm.getScrollbackLength(); return scrollback + wasmTerm.rows; } }
getLine(y: number): IBufferLine | undefined { const wasmTerm = this.getWasmTerm(); if (!wasmTerm) return undefined;
// Check bounds if (y < 0 || y >= this.length) { return undefined; }
// Determine if accessing scrollback or visible screen const scrollbackLength = wasmTerm.getScrollbackLength(); let cells: GhosttyCell[] | null; let lineNumber: number; let isWrapped: boolean;
if (this.bufferType === 'normal' && y < scrollbackLength) { // Accessing scrollback // WASM getScrollbackLine: offset 0 = oldest, offset (length-1) = newest // Buffer coords: y=0 = oldest, y=(length-1) = newest // So scrollbackOffset = y directly! const scrollbackOffset = y; cells = wasmTerm.getScrollbackLine(scrollbackOffset); // TODO: We'd need WASM API to check if scrollback line is wrapped // For now, assume not wrapped isWrapped = false; } else { // Accessing visible screen lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y; cells = wasmTerm.getLine(lineNumber); isWrapped = wasmTerm.isRowWrapped(lineNumber); }
if (!cells) { return undefined; }
return new BufferLine(cells, isWrapped, wasmTerm.cols); }
getNullCell(): IBufferCell { return this.nullCell; }
private getWasmTerm(): GhosttyTerminal | undefined { return (this.terminal as any).wasmTerm as GhosttyTerminal | undefined; }}
// ============================================================================// BufferLine - Represents a single line in the buffer// ============================================================================
/** * A single line in the buffer */export class BufferLine implements IBufferLine { private cells: GhosttyCell[]; private _isWrapped: boolean; private _length: number;
constructor(cells: GhosttyCell[], isWrapped: boolean, length: number) { this.cells = cells; this._isWrapped = isWrapped; this._length = length; }
get length(): number { return this._length; }
get isWrapped(): boolean { return this._isWrapped; }
getCell(x: number): IBufferCell | undefined { if (x < 0 || x >= this._length) { return undefined; }
if (x >= this.cells.length) { // Cell beyond what was returned (empty/null cell) return new BufferCell( { codepoint: 0, fg_r: 204, fg_g: 204, fg_b: 204, bg_r: 0, bg_g: 0, bg_b: 0, flags: 0, width: 1, hyperlink_id: 0, }, x ); }
return new BufferCell(this.cells[x], x); }
translateToString(trimRight = false, startColumn = 0, endColumn = this._length): string { // Clamp bounds const start = Math.max(0, Math.min(startColumn, this._length)); const end = Math.max(start, Math.min(endColumn, this._length));
let result = ''; for (let x = start; x < end; x++) { const cell = this.getCell(x); if (cell) { const chars = cell.getChars(); result += chars; } }
if (trimRight) { result = result.trimEnd(); }
return result; }}
// ============================================================================// BufferCell - Represents a single cell in the buffer// ============================================================================
/** * A single cell in the buffer */export class BufferCell implements IBufferCell { private cell: GhosttyCell; private x: number;
constructor(cell: GhosttyCell, x: number) { this.cell = cell; this.x = x; }
getChars(): string { const codepoint = this.cell.codepoint;
// Return empty string for null character or invalid codepoints if (codepoint === 0) { return ''; }
// Validate codepoint is within valid Unicode range // Valid: 0x0000 to 0x10FFFF, excluding surrogates (0xD800-0xDFFF) if (codepoint < 0 || codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)) { // Return replacement character for invalid codepoints return '\uFFFD'; }
return String.fromCodePoint(codepoint); }
getCode(): number { return this.cell.codepoint; }
getWidth(): number { return this.cell.width; }
getFgColorMode(): number { // Return -1 for RGB (we always use RGB in our WASM implementation) // xterm.js uses different values: // 0 = default, 1 = palette 16, 2 = palette 256, 3 = RGB // For simplicity, we return -1 for RGB return -1; }
getBgColorMode(): number { return -1; }
getFgColor(): number { // Pack RGB into a single number: 0xRRGGBB return (this.cell.fg_r << 16) | (this.cell.fg_g << 8) | this.cell.fg_b; }
getBgColor(): number { // Pack RGB into a single number: 0xRRGGBB return (this.cell.bg_r << 16) | (this.cell.bg_g << 8) | this.cell.bg_b; }
isBold(): number { return (this.cell.flags & CellFlags.BOLD) !== 0 ? 1 : 0; }
isItalic(): number { return (this.cell.flags & CellFlags.ITALIC) !== 0 ? 1 : 0; }
isUnderline(): number { return (this.cell.flags & CellFlags.UNDERLINE) !== 0 ? 1 : 0; }
isStrikethrough(): number { return (this.cell.flags & CellFlags.STRIKETHROUGH) !== 0 ? 1 : 0; }
isBlink(): number { return (this.cell.flags & CellFlags.BLINK) !== 0 ? 1 : 0; }
isInverse(): number { return (this.cell.flags & CellFlags.INVERSE) !== 0 ? 1 : 0; }
isInvisible(): number { return (this.cell.flags & CellFlags.INVISIBLE) !== 0 ? 1 : 0; }
isFaint(): number { return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0; }
/** * Get hyperlink ID for this cell (0 = no link) * Used by link detection system */ getHyperlinkId(): number { return this.cell.hyperlink_id; }
/** * Get the Unicode codepoint for this cell * Used by link detection system */ getCodepoint(): number { return this.cell.codepoint; }
/** * Check if cell has dim/faint attribute * Added for IBufferCell compatibility */ isDim(): boolean { return (this.cell.flags & CellFlags.FAINT) !== 0; }}