diff --git a/lib/buffer.test.ts b/lib/buffer.test.ts new file mode 100644 index 00000000..b2bd5d6e --- /dev/null +++ b/lib/buffer.test.ts @@ -0,0 +1,457 @@ +/** + * Buffer API tests + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { Terminal } from './terminal'; + +describe('Buffer API', () => { + let term: Terminal | null = null; + let container: HTMLElement | null = null; + + beforeEach(async () => { + // Create a container element if document is available + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + term = new Terminal({ cols: 80, rows: 24 }); + await term.open(container); + } + }); + + afterEach(() => { + if (term) { + term.dispose(); + term = null; + } + // Clean up container + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null; + } + }); + + describe('BufferNamespace', () => { + test('should have buffer property', () => { + if (!term) return; // Skip if no DOM + if (!term) return; // Skip if no DOM + expect(term.buffer).toBeDefined(); + }); + + test('should have active, normal, and alternate buffers', () => { + if (!term) return; // Skip if no DOM + expect(term.buffer.active).toBeDefined(); + expect(term.buffer.normal).toBeDefined(); + expect(term.buffer.alternate).toBeDefined(); + }); + + test('active buffer should be normal by default', () => { + if (!term) return; // Skip if no DOM + expect(term.buffer.active.type).toBe('normal'); + }); + + test('should switch to alternate buffer', () => { + if (!term) return; // Skip if no DOM + // Enter alternate screen (smcup) + term.write('\x1b[?1049h'); + + // Active buffer should now be alternate + expect(term.buffer.active.type).toBe('alternate'); + }); + + test('should switch back to normal buffer', () => { + if (!term) return; // Skip if no DOM + // Enter alternate screen + term.write('\x1b[?1049h'); + expect(term.buffer.active.type).toBe('alternate'); + + // Exit alternate screen (rmcup) + term.write('\x1b[?1049l'); + expect(term.buffer.active.type).toBe('normal'); + }); + }); + + describe('Buffer', () => { + test('should have correct type', () => { + if (!term) return; // Skip if no DOM + expect(term.buffer.normal.type).toBe('normal'); + expect(term.buffer.alternate.type).toBe('alternate'); + }); + + test('should track cursor position', () => { + if (!term) return; // Skip if no DOM + term.write('Hello'); + const buffer = term.buffer.active; + + expect(buffer.cursorX).toBe(5); + expect(buffer.cursorY).toBe(0); + }); + + test('should track cursor position after newline', () => { + if (!term) return; // Skip if no DOM + term.write('Hello\r\nWorld'); + const buffer = term.buffer.active; + + expect(buffer.cursorX).toBe(5); + expect(buffer.cursorY).toBe(1); + }); + + test('should have correct length', () => { + if (!term) return; // Skip if no DOM + const buffer = term.buffer.normal; + expect(buffer.length).toBeGreaterThanOrEqual(24); + }); + + test('should return null cell', () => { + if (!term) return; // Skip if no DOM + const buffer = term.buffer.active; + const nullCell = buffer.getNullCell(); + + expect(nullCell.getCode()).toBe(0); + expect(nullCell.getChars()).toBe(''); + expect(nullCell.getWidth()).toBe(1); + }); + }); + + describe('BufferLine', () => { + test('should get line from buffer', () => { + if (!term) return; // Skip if no DOM + term.write('Hello, World!'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + + expect(line).toBeDefined(); + expect(line!.length).toBe(80); + }); + + test('should return undefined for out of bounds line', () => { + if (!term) return; // Skip if no DOM + const buffer = term.buffer.active; + const line = buffer.getLine(10000); + + expect(line).toBeUndefined(); + }); + + test('should have correct isWrapped flag', () => { + if (!term) return; // Skip if no DOM + // Write a short line (should not wrap) + term.write('Short line'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + + expect(line).toBeDefined(); + expect(line!.isWrapped).toBe(false); + }); + + test('translateToString should return line content', () => { + if (!term) return; // Skip if no DOM + term.write('Hello, World!'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const text = line!.translateToString(); + + expect(text).toContain('Hello, World!'); + }); + + test('translateToString should trim right when requested', () => { + if (!term) return; // Skip if no DOM + term.write('Hello'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const text = line!.translateToString(true); + + expect(text).toBe('Hello'); + expect(text.length).toBe(5); + }); + + test('translateToString should respect startColumn and endColumn', () => { + if (!term) return; // Skip if no DOM + term.write('Hello, World!'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const text = line!.translateToString(false, 7, 12); + + expect(text).toBe('World'); + }); + }); + + describe('BufferCell', () => { + test('should get cell from line', () => { + if (!term) return; // Skip if no DOM + term.write('Hello'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell).toBeDefined(); + }); + + test('should return undefined for out of bounds cell', () => { + if (!term) return; // Skip if no DOM + term.write('Hello'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(200); + + expect(cell).toBeUndefined(); + }); + + test('getChars should return character', () => { + if (!term) return; // Skip if no DOM + term.write('H'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.getChars()).toBe('H'); + }); + + test('getCode should return codepoint', () => { + if (!term) return; // Skip if no DOM + term.write('A'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.getCode()).toBe(65); // 'A' = 65 + }); + + test('getWidth should return 1 for normal characters', () => { + if (!term) return; // Skip if no DOM + term.write('A'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.getWidth()).toBe(1); + }); + + test('should detect bold text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[1mBold\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isBold()).toBe(1); + }); + + test('should detect italic text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[3mItalic\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isItalic()).toBe(1); + }); + + test('should detect underline text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[4mUnderline\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isUnderline()).toBe(1); + }); + + test('should detect strikethrough text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[9mStrike\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isStrikethrough()).toBe(1); + }); + + test('should detect blink text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[5mBlink\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isBlink()).toBe(1); + }); + + test('should detect inverse text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[7mInverse\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isInverse()).toBe(1); + }); + + test('should detect invisible text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[8mInvisible\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isInvisible()).toBe(1); + }); + + test('should detect faint text', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[2mFaint\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isFaint()).toBe(1); + }); + + test('should return RGB foreground color', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[31mRed\x1b[0m'); // ANSI red + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + const color = cell!.getFgColor(); + expect(color).toBeGreaterThan(0); + }); + + test('should return RGB background color', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[41mRed BG\x1b[0m'); // ANSI red background + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + const color = cell!.getBgColor(); + expect(color).toBeGreaterThan(0); + }); + + test('empty cell should return empty string', () => { + if (!term) return; // Skip if no DOM + // Get a cell that was never written to + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(50); // Far from any written text + + expect(cell!.getChars()).toBe(''); + expect(cell!.getCode()).toBe(0); + }); + }); + + describe('Multi-line content', () => { + test('should handle multiple lines correctly', () => { + if (!term) return; // Skip if no DOM + term.write('Line 1\r\n'); + term.write('Line 2\r\n'); + term.write('Line 3'); + + const buffer = term.buffer.active; + + const line0 = buffer.getLine(0); + const line1 = buffer.getLine(1); + const line2 = buffer.getLine(2); + + expect(line0!.translateToString(true)).toBe('Line 1'); + expect(line1!.translateToString(true)).toBe('Line 2'); + expect(line2!.translateToString(true)).toBe('Line 3'); + }); + + test('should handle colored multi-line content', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[31mRed line\x1b[0m\r\n'); + term.write('\x1b[32mGreen line\x1b[0m\r\n'); + term.write('\x1b[34mBlue line\x1b[0m'); + + const buffer = term.buffer.active; + + const line0 = buffer.getLine(0); + const line1 = buffer.getLine(1); + const line2 = buffer.getLine(2); + + expect(line0!.translateToString(true)).toBe('Red line'); + expect(line1!.translateToString(true)).toBe('Green line'); + expect(line2!.translateToString(true)).toBe('Blue line'); + + // Check that first character of each line has correct style + expect(line0!.getCell(0)!.getFgColor()).toBeGreaterThan(0); + expect(line1!.getCell(0)!.getFgColor()).toBeGreaterThan(0); + expect(line2!.getCell(0)!.getFgColor()).toBeGreaterThan(0); + }); + }); + + describe('Unicode support', () => { + test('should handle emoji correctly', () => { + if (!term) return; // Skip if no DOM + term.write('😀'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.getChars()).toBe('😀'); + expect(cell!.getCode()).toBe(0x1f600); // Emoji codepoint + }); + + test('should handle accented characters', () => { + if (!term) return; // Skip if no DOM + term.write('Héllo'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + + expect(line!.translateToString(true)).toBe('Héllo'); + }); + + test('should handle various Unicode characters', () => { + if (!term) return; // Skip if no DOM + term.write('日本語'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + + expect(line!.translateToString(true)).toBe('日本語'); + }); + }); + + describe('Edge cases', () => { + test('should handle empty buffer', () => { + if (!term) return; // Skip if no DOM + const buffer = term.buffer.active; + const line = buffer.getLine(0); + + expect(line).toBeDefined(); + expect(line!.translateToString(true)).toBe(''); + }); + + test('should handle full line of text', () => { + if (!term) return; // Skip if no DOM + // Write exactly 80 characters + const fullLine = 'A'.repeat(80); + term.write(fullLine); + + const buffer = term.buffer.active; + const line = buffer.getLine(0); + + expect(line!.translateToString(true)).toBe(fullLine); + }); + + test('should handle cursor at end of line', () => { + if (!term) return; // Skip if no DOM + term.write('X'.repeat(80)); + const buffer = term.buffer.active; + + expect(buffer.cursorX).toBe(80); + }); + + test('should handle multiple style attributes', () => { + if (!term) return; // Skip if no DOM + term.write('\x1b[1;3;4mBold+Italic+Underline\x1b[0m'); + const buffer = term.buffer.active; + const line = buffer.getLine(0); + const cell = line!.getCell(0); + + expect(cell!.isBold()).toBe(1); + expect(cell!.isItalic()).toBe(1); + expect(cell!.isUnderline()).toBe(1); + }); + }); +}); diff --git a/lib/buffer.ts b/lib/buffer.ts new file mode 100644 index 00000000..39177d5f --- /dev/null +++ b/lib/buffer.ts @@ -0,0 +1,365 @@ +/** + * 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(); + + // 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 { + 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, + }; + 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 + const scrollbackOffset = scrollbackLength - y - 1; // Most recent = 0 + 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, + }, + 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 { + if (this.cell.codepoint === 0) { + return ''; + } + return String.fromCodePoint(this.cell.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; + } +} diff --git a/lib/interfaces.ts b/lib/interfaces.ts index a609fc67..366c6f44 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -79,3 +79,117 @@ export interface IKeyEvent { key: string; domEvent: KeyboardEvent; } + +// ============================================================================ +// Buffer API Interfaces (xterm.js compatibility) +// ============================================================================ + +/** + * Top-level buffer API namespace + * Provides access to active, normal, and alternate screen buffers + */ +export interface IBufferNamespace { + /** The currently active buffer (normal or alternate) */ + readonly active: IBuffer; + /** The normal buffer (primary screen) */ + readonly normal: IBuffer; + /** The alternate buffer (used by full-screen apps like vim) */ + readonly alternate: IBuffer; + + /** Event fired when buffer changes (normal ↔ alternate) */ + readonly onBufferChange: IEvent; +} + +/** + * A terminal buffer (normal or alternate screen) + */ +export interface IBuffer { + /** Buffer type: 'normal' or 'alternate' */ + readonly type: 'normal' | 'alternate'; + /** Cursor X position (0-indexed) */ + readonly cursorX: number; + /** Cursor Y position (0-indexed, relative to viewport) */ + readonly cursorY: number; + /** Viewport Y position (scroll offset, 0 = bottom of scrollback) */ + readonly viewportY: number; + /** Base Y position (always 0 for normal buffer, may vary for alternate) */ + readonly baseY: number; + /** Total buffer length (rows + scrollback for normal, just rows for alternate) */ + readonly length: number; + + /** + * Get a line from the buffer + * @param y Line index (0 = top of scrollback for normal buffer) + * @returns Line object or undefined if out of bounds + */ + getLine(y: number): IBufferLine | undefined; + + /** + * Get the null cell (used for empty/uninitialized cells) + */ + getNullCell(): IBufferCell; +} + +/** + * A single line in the buffer + */ +export interface IBufferLine { + /** Length of the line (in columns) */ + readonly length: number; + /** Whether this line wraps to the next line */ + readonly isWrapped: boolean; + + /** + * Get a cell from this line + * @param x Column index (0-indexed) + * @returns Cell object or undefined if out of bounds + */ + getCell(x: number): IBufferCell | undefined; + + /** + * Translate the line to a string + * @param trimRight Whether to trim trailing whitespace (default: false) + * @param startColumn Start column (default: 0) + * @param endColumn End column (default: length) + * @returns String representation of the line + */ + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} + +/** + * A single cell in the buffer + */ +export interface IBufferCell { + /** Character(s) in this cell (may be empty, single char, or emoji) */ + getChars(): string; + /** Unicode codepoint (0 for null cell) */ + getCode(): number; + /** Character width (1 = normal, 2 = wide/emoji, 0 = combining) */ + getWidth(): number; + + /** Foreground color index (for palette colors) or -1 for RGB */ + getFgColorMode(): number; + /** Background color index (for palette colors) or -1 for RGB */ + getBgColorMode(): number; + /** Foreground RGB color (or 0 for default) */ + getFgColor(): number; + /** Background RGB color (or 0 for default) */ + getBgColor(): number; + + /** Whether cell has bold style */ + isBold(): number; + /** Whether cell has italic style */ + isItalic(): number; + /** Whether cell has underline style */ + isUnderline(): number; + /** Whether cell has strikethrough style */ + isStrikethrough(): number; + /** Whether cell has blink style */ + isBlink(): number; + /** Whether cell has inverse video style */ + isInverse(): number; + /** Whether cell has invisible style */ + isInvisible(): number; + /** Whether cell has faint/dim style */ + isFaint(): number; +} diff --git a/lib/terminal.ts b/lib/terminal.ts index 3a6d4a41..680197a6 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -12,10 +12,12 @@ * ``` */ +import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; import { Ghostty, type GhosttyCell, type GhosttyTerminal } from './ghostty'; import { InputHandler } from './input-handler'; import type { + IBufferNamespace, IBufferRange, IDisposable, IEvent, @@ -38,6 +40,9 @@ export class Terminal implements ITerminalCore { public element?: HTMLElement; public textarea?: HTMLTextAreaElement; + // Buffer API (xterm.js compatibility) + public readonly buffer: IBufferNamespace; + // Options private options: Required> & { wasmPath?: string; @@ -111,6 +116,9 @@ export class Terminal implements ITerminalCore { this.cols = this.options.cols; this.rows = this.options.rows; + + // Initialize buffer API + this.buffer = new BufferNamespace(this); } // ==========================================================================