import { type GhosttyKeyboardLayoutMap, ghosttyKeyForCode, ghosttyUnshiftedCodepoint, loadGhosttyKeyboardLayoutMap, } from "./keyCodes"; import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; const GHOSTTY_SUCCESS = 0; const GHOSTTY_OUT_OF_SPACE = -3; const MAX_SCROLLBACK_ROWS = 10_000; // wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the // libghostty-vt revision pinned alongside this module. const SELECTION_FORMAT_OPTIONS_SIZE = 16; const RENDER_DATA = { cols: 1, rows: 2, dirty: 3, rowIterator: 4, background: 5, foreground: 6, cursor: 7, cursorHasValue: 8, cursorStyle: 10, cursorVisible: 11, cursorBlinking: 12, cursorInViewport: 14, cursorX: 15, cursorY: 16, } as const; const ROW_DATA = { dirty: 1, raw: 2, cells: 3, } as const; const CELL_DATA = { raw: 1, style: 2, graphemesLength: 3, graphemes: 4, background: 5, foreground: 6, selected: 7, } as const; const RAW_CELL_DATA = { wide: 3, } as const; export const GHOSTTY_CELL_WIDE = { narrow: 0, wide: 1, spacerTail: 2, spacerHead: 3, } as const; export interface GhosttyColor { readonly r: number; readonly g: number; readonly b: number; } export interface GhosttyTheme { readonly foreground: GhosttyColor; readonly background: GhosttyColor; readonly cursor: GhosttyColor; /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ readonly selectionBackground?: string; } export interface GhosttyCell { readonly text: string; readonly wide: number; readonly foreground: GhosttyColor; readonly background: GhosttyColor; readonly bold: boolean; readonly italic: boolean; readonly invisible: boolean; readonly strikethrough: boolean; readonly overline: boolean; readonly underline: boolean; readonly selected: boolean; } export interface GhosttyRow { readonly cells: readonly GhosttyCell[]; readonly text: string; readonly isWrapContinuation: boolean; /** Whether this row soft-wraps onto the next row. */ readonly wrapsToNext: boolean; } export interface GhosttySnapshot { readonly cols: number; readonly rows: number; readonly foreground: GhosttyColor; readonly background: GhosttyColor; readonly cursor: GhosttyColor; readonly cursorX: number; readonly cursorY: number; readonly cursorVisible: boolean; readonly cursorBlinking: boolean; readonly cursorStyle: number; readonly dirtyRows: ReadonlySet; readonly rowData: readonly GhosttyRow[]; } export interface GhosttySelectionRange { readonly viewport: { readonly start: { readonly x: number; readonly y: number }; readonly end: { readonly x: number; readonly y: number }; }; readonly screen: { readonly start: { readonly x: number; readonly y: number }; readonly end: { readonly x: number; readonly y: number }; }; } export interface GhosttyScrollbar { readonly total: number; readonly offset: number; readonly len: number; } /** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */ export interface GhosttyPointInput { readonly x: number; readonly y: number; readonly tag?: 1 | 2; } export interface GhosttyMouseInput { readonly action: "press" | "release" | "motion"; readonly button: number | null; readonly mods: number; readonly x: number; readonly y: number; readonly screenWidth: number; readonly screenHeight: number; readonly cellWidth: number; readonly cellHeight: number; readonly paddingLeft: number; readonly paddingRight: number; readonly paddingTop: number; readonly paddingBottom: number; readonly anyButtonPressed: boolean; } const decoder = new TextDecoder(); const encoder = new TextEncoder(); function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor { const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255); return { r: channel(foreground.r, background.r), g: channel(foreground.g, background.g), b: channel(foreground.b, background.b), }; } function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { return left.r === right.r && left.g === right.g && left.b === right.b; } export class GhosttyTerminalCore { private readonly runtime: GhosttyRuntime; private terminalSlot = 0; private terminal = 0; private renderStateSlot = 0; private renderState = 0; private rowIteratorSlot = 0; private rowCellsSlot = 0; private keyEncoderSlot = 0; private keyEncoder = 0; private keyEventSlot = 0; private keyEvent = 0; private mouseEncoderSlot = 0; private mouseEncoder = 0; private mouseEventSlot = 0; private mouseEvent = 0; private ptyWriterId = 0; private ptyWriter: ((data: string) => void) | null = null; private scratch = 0; private style = 0; private scrollbar = 0; private rows: GhosttyRow[] = []; private disposed = false; private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined; private constructor(runtime: GhosttyRuntime) { this.runtime = runtime; void loadGhosttyKeyboardLayoutMap().then((layoutMap) => { if (!this.disposed) this.keyboardLayoutMap = layoutMap; }); } static async create( cols: number, rows: number, cellWidth: number, cellHeight: number, theme: GhosttyTheme, onPtyData: (data: string) => void, ): Promise { const core = new GhosttyTerminalCore(await loadGhosttyRuntime()); try { core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData); return core; } catch (error) { core.dispose(); throw error; } } private initialize( cols: number, rows: number, cellWidth: number, cellHeight: number, theme: GhosttyTheme, onPtyData: (data: string) => void, ): void { const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size; const options = this.runtime.alloc(optionsSize); this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); this.terminalSlot = this.runtime.allocOpaque(); const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); this.runtime.free(options, optionsSize); this.assertSuccess("ghostty_terminal_new", terminalResult); this.terminal = this.runtime.readPointer(this.terminalSlot); this.applyDefaultCursorBlink(); this.ptyWriter = onPtyData; this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); this.renderStateSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_render_state_new", this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot), ); this.renderState = this.runtime.readPointer(this.renderStateSlot); this.rowIteratorSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_render_state_row_iterator_new", this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot), ); this.rowCellsSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_render_state_row_cells_new", this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot), ); this.keyEncoderSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_key_encoder_new", this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot), ); this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot); this.keyEventSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_key_event_new", this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot), ); this.keyEvent = this.runtime.readPointer(this.keyEventSlot); this.mouseEncoderSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_mouse_encoder_new", this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot), ); this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot); this.mouseEventSlot = this.runtime.allocOpaque(); this.assertSuccess( "ghostty_mouse_event_new", this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot), ); this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot); this.scratch = this.runtime.alloc(16); const styleSize = this.runtime.layout("GhosttyStyle").size; this.style = this.runtime.alloc(styleSize); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); this.setTheme(theme); this.resize(cols, rows, cellWidth, cellHeight); } write(data: string | Uint8Array): void { this.ensureActive(); const bytes = typeof data === "string" ? encoder.encode(data) : data; if (bytes.length === 0) return; const pointer = this.runtime.alloc(bytes.length); this.runtime.bytes(pointer, bytes.length).set(bytes); this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length); this.runtime.free(pointer, bytes.length); } resetAndWrite(data: string): void { this.ensureActive(); this.runtime.call("ghostty_terminal_reset", this.terminal); // RIS returns the cursor to Ghostty's built-in steady default, so the // embedder default has to be applied again before the replay runs. this.applyDefaultCursorBlink(); this.rows = []; if (data.length === 0) return; const writer = this.ptyWriter; if (this.ptyWriterId !== 0) { this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); this.ptyWriterId = 0; } try { this.write(data); } finally { if (writer !== null && !this.disposed) { this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); } } } resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void { this.ensureActive(); this.assertSuccess( "ghostty_terminal_resize", this.runtime.call( "ghostty_terminal_resize", this.terminal, Math.max(1, Math.min(65_535, cols)), Math.max(1, Math.min(65_535, rows)), Math.max(1, Math.round(cellWidth)), Math.max(1, Math.round(cellHeight)), ), ); } /** * Ghostty's built-in default cursor is steady, while the xterm.js renderer * this replaced ran with `cursorBlink: true`. Option 23 is the embedder's * default blink, which is the state a session starts in and returns to on * DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor * through DECSCUSR or DEC mode 12 still win. */ private applyDefaultCursorBlink(): void { const blink = this.runtime.alloc(1); this.runtime.bytes(blink, 1)[0] = 1; this.runtime.call("ghostty_terminal_set", this.terminal, 23, blink); this.runtime.free(blink, 1); } setTheme(theme: GhosttyTheme): void { this.ensureActive(); const color = this.runtime.alloc(3); for (const [option, value] of [ [11, theme.foreground], [12, theme.background], [13, theme.cursor], ] as const) { this.runtime.bytes(color, 3).set([value.r, value.g, value.b]); this.runtime.call("ghostty_terminal_set", this.terminal, option, color); } this.runtime.free(color, 3); } scroll(deltaRows: number): void { this.ensureActive(); const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); const scroll = this.runtime.alloc(layout.size); this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2); const value = layout.fields.value!; this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true); this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); this.runtime.free(scroll, layout.size); } scrollToBottom(): void { this.ensureActive(); const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); const scroll = this.runtime.alloc(layout.size); this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1); this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); this.runtime.free(scroll, layout.size); } isViewportActive(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 1)[0] = 0; return ( this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 ); } scrollbarState(): GhosttyScrollbar | null { this.ensureActive(); const layout = this.runtime.layout("GhosttyTerminalScrollbar"); this.runtime.bytes(this.scrollbar, layout.size).fill(0); if ( this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !== GHOSTTY_SUCCESS ) { return null; } return { total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"), offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"), len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"), }; } isMouseTracking(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 1)[0] = 0; return ( this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 ); } isMouseAnyEventTracking(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 1)[0] = 0; return ( this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 ); } isAlternateScreen(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 4).fill(0); return ( this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) === GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1 ); } isApplicationCursorKeys(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 1)[0] = 0; return ( this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 ); } encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { this.ensureActive(); this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); this.runtime.call( "ghostty_key_event_set_action", this.keyEvent, action === "release" ? 0 : event.repeat ? 2 : 1, ); this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); const mods = (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 1 << 1 : 0) | (event.altKey ? 1 << 2 : 0) | (event.metaKey ? 1 << 3 : 0) | (event.getModifierState("CapsLock") ? 1 << 4 : 0) | (event.getModifierState("NumLock") ? 1 << 5 : 0); this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, 0); this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); this.runtime.call( "ghostty_key_event_set_unshifted_codepoint", this.keyEvent, ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap), ); const text = event.key.length === 1 ? event.key : ""; const textBytes = encoder.encode(text); const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length); if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes); this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length); const written = this.runtime.call("ghostty_wasm_alloc_usize"); const encoded = this.encodeOutput(written, (output, outputSize) => this.runtime.call( "ghostty_key_encoder_encode", this.keyEncoder, this.keyEvent, output, outputSize, written, ), ); this.runtime.call("ghostty_wasm_free_usize", written); if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length); return encoded; } encodePaste(data: string): string { this.ensureActive(); const input = encoder.encode(data); if (input.length === 0) return ""; const inputPointer = this.runtime.alloc(input.length); this.runtime.bytes(inputPointer, input.length).set(input); this.runtime.bytes(this.scratch, 1)[0] = 0; const bracketed = this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0; const written = this.runtime.call("ghostty_wasm_alloc_usize"); let encoded = ""; const sizeResult = this.runtime.call( "ghostty_paste_encode", inputPointer, input.length, bracketed ? 1 : 0, 0, 0, written, ); const outputSize = this.runtime.view(written, 4).getUint32(0, true); if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { const output = this.runtime.alloc(outputSize); const result = this.runtime.call( "ghostty_paste_encode", inputPointer, input.length, bracketed ? 1 : 0, output, outputSize, written, ); const outputLength = this.runtime.view(written, 4).getUint32(0, true); encoded = result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; this.runtime.free(output, outputSize); } this.runtime.call("ghostty_wasm_free_usize", written); this.runtime.free(inputPointer, input.length); return encoded; } encodeMouse(input: GhosttyMouseInput): string { this.ensureActive(); this.runtime.call( "ghostty_mouse_encoder_setopt_from_terminal", this.mouseEncoder, this.terminal, ); const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize"); const size = this.runtime.alloc(sizeLayout.size); for (const [field, value] of [ ["size", sizeLayout.size], ["screen_width", input.screenWidth], ["screen_height", input.screenHeight], ["cell_width", input.cellWidth], ["cell_height", input.cellHeight], ["padding_top", input.paddingTop], ["padding_bottom", input.paddingBottom], ["padding_right", input.paddingRight], ["padding_left", input.paddingLeft], ] as const) { this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value))); } this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size); this.runtime.free(size, sizeLayout.size); this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0; this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch); this.runtime.bytes(this.scratch, 1)[0] = 1; this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch); this.runtime.call( "ghostty_mouse_event_set_action", this.mouseEvent, input.action === "press" ? 0 : input.action === "release" ? 1 : 2, ); if (input.button === null) { this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent); } else { this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button); } this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods); const positionLayout = this.runtime.layout("GhosttyMousePosition"); const position = this.runtime.alloc(positionLayout.size); const positionView = this.runtime.view(position, positionLayout.size); positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true); positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true); this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position); this.runtime.free(position, positionLayout.size); const written = this.runtime.call("ghostty_wasm_alloc_usize"); const encoded = this.encodeOutput(written, (output, outputSize) => this.runtime.call( "ghostty_mouse_encoder_encode", this.mouseEncoder, this.mouseEvent, output, outputSize, written, ), ); this.runtime.call("ghostty_wasm_free_usize", written); return encoded; } setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { this.ensureActive(); const selectionLayout = this.runtime.layout("GhosttySelection"); const gridRefSize = this.runtime.layout("GhosttyGridRef").size; const selection = this.runtime.alloc(selectionLayout.size); let start = 0; let endRef = 0; try { this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1); endRef = this.gridRef(end.x, end.y, end.tag ?? 1); const startField = selectionLayout.fields.start!; const endField = selectionLayout.fields.end!; this.runtime .bytes(selection + startField.offset, startField.size) .set(this.runtime.bytes(start, startField.size)); this.runtime .bytes(selection + endField.offset, endField.size) .set(this.runtime.bytes(endRef, endField.size)); this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); } finally { this.runtime.free(start, gridRefSize); this.runtime.free(endRef, gridRefSize); this.runtime.free(selection, selectionLayout.size); } } selectAll(): void { this.ensureActive(); const layout = this.runtime.layout("GhosttySelection"); const selection = this.runtime.alloc(layout.size); this.runtime.setField(selection, "GhosttySelection", "size", layout.size); if ( this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS ) { this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); } this.runtime.free(selection, layout.size); } selectWord(col: number, row: number): GhosttySelectionRange | null { return this.selectAt( "GhosttyTerminalSelectWordOptions", "ghostty_terminal_select_word", col, row, ); } selectLine(col: number, row: number): GhosttySelectionRange | null { return this.selectAt( "GhosttyTerminalSelectLineOptions", "ghostty_terminal_select_line", col, row, ); } hyperlinkAt(col: number, row: number): string | null { this.ensureActive(); const ref = this.gridRef(col, row); const written = this.runtime.call("ghostty_wasm_alloc_usize"); const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written); const outputSize = this.runtime.view(written, 4).getUint32(0, true); let hyperlink: string | null = null; if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { const output = this.runtime.alloc(outputSize); const result = this.runtime.call( "ghostty_grid_ref_hyperlink_uri", ref, output, outputSize, written, ); const outputLength = this.runtime.view(written, 4).getUint32(0, true); if (result === GHOSTTY_SUCCESS && outputLength > 0) { hyperlink = decoder.decode(this.runtime.bytes(output, outputLength)); } this.runtime.free(output, outputSize); } this.runtime.call("ghostty_wasm_free_usize", written); this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); return hyperlink; } clearSelection(): void { this.ensureActive(); this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0); } snapshot(): GhosttySnapshot { this.ensureActive(); this.assertSuccess( "ghostty_render_state_update", this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal), ); const cols = this.getU16(RENDER_DATA.cols); const rowCount = this.getU16(RENDER_DATA.rows); const dirty = this.getU32(RENDER_DATA.dirty); const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 }); const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 }); const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue); const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground; const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport); const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport; const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1; const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1; if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) { this.rows = Array.from({ length: rowCount }, () => ({ cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)), text: "", isWrapContinuation: false, wrapsToNext: false, })); } const dirtyRows = new Set(); if (dirty !== 0) { this.assertSuccess( "ghostty_render_state_get(row iterator)", this.runtime.call( "ghostty_render_state_get", this.renderState, RENDER_DATA.rowIterator, this.rowIteratorSlot, ), ); const iterator = this.runtime.readPointer(this.rowIteratorSlot); let rowIndex = 0; while ( rowIndex < rowCount && this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0 ) { const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty); if (rowDirty) { this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); dirtyRows.add(rowIndex); this.runtime.bytes(this.scratch, 1)[0] = 0; this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); } rowIndex += 1; } this.runtime.view(this.scratch, 4).setUint32(0, 0, true); this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); } return { cols, rows: rowCount, foreground, background, cursor, cursorX, cursorY, cursorVisible, cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking), cursorStyle: this.getU32(RENDER_DATA.cursorStyle), dirtyRows, rowData: this.rows, }; } selectionText(): string { this.ensureActive(); const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE); const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE); optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true); optionsView.setUint32(4, 0, true); optionsView.setUint8(8, 1); optionsView.setUint8(9, 1); optionsView.setUint32(12, 0, true); const written = this.runtime.call("ghostty_wasm_alloc_usize"); const sizeResult = this.runtime.call( "ghostty_terminal_selection_format_buf", this.terminal, options, 0, 0, written, ); const outputSize = this.runtime.view(written, 4).getUint32(0, true); let text = ""; if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { const output = this.runtime.alloc(outputSize); const result = this.runtime.call( "ghostty_terminal_selection_format_buf", this.terminal, options, output, outputSize, written, ); const outputLength = this.runtime.view(written, 4).getUint32(0, true); if (result === GHOSTTY_SUCCESS) { text = decoder.decode(this.runtime.bytes(output, outputLength)); } this.runtime.free(output, outputSize); } this.runtime.call("ghostty_wasm_free_usize", written); this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE); return text; } viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { return this.convertPoint(col, row, 1, 2); } screenPointToViewport(col: number, row: number): { x: number; y: number } | null { return this.convertPoint(col, row, 2, 1); } private convertPoint( col: number, row: number, fromTag: 1 | 2, toTag: 1 | 2, ): { x: number; y: number } | null { this.ensureActive(); const ref = this.gridRef(col, row, fromTag); const point = this.pointFromGridRef(ref, toTag); this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); return point; } dispose(): void { if (this.disposed) return; this.disposed = true; if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent); if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder); if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent); if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder); if (this.rowCellsSlot) { const cells = this.runtime.readPointer(this.rowCellsSlot); if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells); } if (this.rowIteratorSlot) { const iterator = this.runtime.readPointer(this.rowIteratorSlot); if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator); } if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState); if (this.terminal) { if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); this.runtime.call("ghostty_terminal_free", this.terminal); } if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size); if (this.scrollbar) { this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); } if (this.scratch) this.runtime.free(this.scratch, 16); for (const slot of [ this.mouseEventSlot, this.mouseEncoderSlot, this.keyEventSlot, this.keyEncoderSlot, this.rowCellsSlot, this.rowIteratorSlot, this.renderStateSlot, this.terminalSlot, ]) { this.runtime.freeOpaque(slot); } } private encodeOutput( written: number, encode: (output: number, outputSize: number) => number, ): string { const sizeResult = encode(0, 0); const outputSize = this.runtime.view(written, 4).getUint32(0, true); if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return ""; if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return ""; const output = this.runtime.alloc(outputSize); const result = encode(output, outputSize); const outputLength = this.runtime.view(written, 4).getUint32(0, true); const encoded = result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; this.runtime.free(output, outputSize); return encoded; } private readRow( iterator: number, cols: number, defaultForeground: GhosttyColor, defaultBackground: GhosttyColor, ): GhosttyRow { this.assertSuccess( "ghostty_render_state_row_get(raw)", this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), ); const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); this.runtime.bytes(this.scratch + 8, 1)[0] = 0; this.assertSuccess( "ghostty_row_get(wrap continuation)", this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), ); const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; this.runtime.bytes(this.scratch + 8, 1)[0] = 0; this.assertSuccess( "ghostty_row_get(wrap)", this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), ); const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; this.assertSuccess( "ghostty_render_state_row_get(cells)", this.runtime.call( "ghostty_render_state_row_get", iterator, ROW_DATA.cells, this.rowCellsSlot, ), ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); const cells: GhosttyCell[] = []; while ( cells.length < cols && this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 ) { let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); const styleSize = this.runtime.layout("GhosttyStyle").size; this.runtime.bytes(this.style, styleSize).fill(0); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); this.runtime.call( "ghostty_render_state_row_cells_get", cellsIterator, CELL_DATA.style, this.style, ); const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; if (inverse) [foreground, background] = [background, foreground]; if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { foreground = blend(foreground, background); } const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); let text = ""; if (graphemeLength > 0) { const bufferSize = graphemeLength * 4; const codepoints = this.runtime.alloc(bufferSize); if ( this.runtime.call( "ghostty_render_state_row_cells_get", cellsIterator, CELL_DATA.graphemes, codepoints, ) === GHOSTTY_SUCCESS ) { // Read through a DataView: the byte-array allocator guarantees no // 4-byte alignment, which a Uint32Array view would require. const codepointView = this.runtime.view(codepoints, bufferSize); const codes: number[] = []; for (let index = 0; index < graphemeLength; index += 1) { codes.push(codepointView.getUint32(index * 4, true)); } text = String.fromCodePoint(...codes); } this.runtime.free(codepoints, bufferSize); } let wide = 0; if (text.length === 0 && cells.at(-1)?.text.length) { this.assertSuccess( "ghostty_render_state_row_cells_get(raw)", this.runtime.call( "ghostty_render_state_row_cells_get", cellsIterator, CELL_DATA.raw, this.scratch, ), ); const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); this.assertSuccess( "ghostty_cell_get(wide)", this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), ); wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); } cells.push({ text, wide, foreground, background, bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, selected: this.getCellBool(cellsIterator, CELL_DATA.selected), }); } while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); return { cells, text: cells .map((cell) => cell.text || " ") .join("") .trimEnd(), isWrapContinuation, wrapsToNext, }; } private gridRef(col: number, row: number, tag: 1 | 2 = 1): number { const pointLayout = this.runtime.layout("GhosttyPoint"); const point = this.runtime.alloc(pointLayout.size); this.runtime.setField(point, "GhosttyPoint", "tag", tag); const pointValue = pointLayout.fields.value!; const valueOffset = pointValue.offset; const view = this.runtime.view(point + valueOffset, pointValue.size); view.setUint16(0, Math.max(0, col), true); view.setUint32(4, Math.max(0, row), true); const gridRefSize = this.runtime.layout("GhosttyGridRef").size; const gridRef = this.runtime.alloc(gridRefSize); this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize); const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef); this.runtime.free(point, pointLayout.size); if (result !== GHOSTTY_SUCCESS) { this.runtime.free(gridRef, gridRefSize); this.assertSuccess("ghostty_terminal_grid_ref", result); } return gridRef; } private selectAt( optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions", operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line", col: number, row: number, ): GhosttySelectionRange | null { this.ensureActive(); const optionsLayout = this.runtime.layout(optionsName); const selectionLayout = this.runtime.layout("GhosttySelection"); const options = this.runtime.alloc(optionsLayout.size); let ref = 0; let selection = 0; let range: GhosttySelectionRange | null = null; try { this.runtime.setField(options, optionsName, "size", optionsLayout.size); ref = this.gridRef(col, row); const refField = optionsLayout.fields.ref!; this.runtime .bytes(options + refField.offset, refField.size) .set(this.runtime.bytes(ref, refField.size)); selection = this.runtime.alloc(selectionLayout.size); this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); const result = this.runtime.call(operation, this.terminal, options, selection); if (result === GHOSTTY_SUCCESS) { const start = selection + selectionLayout.fields.start!.offset; const end = selection + selectionLayout.fields.end!.offset; const viewportStart = this.pointFromGridRef(start, 1); const viewportEnd = this.pointFromGridRef(end, 1); const screenStart = this.pointFromGridRef(start, 2); const screenEnd = this.pointFromGridRef(end, 2); if (viewportStart && viewportEnd && screenStart && screenEnd) { range = { viewport: { start: viewportStart, end: viewportEnd }, screen: { start: screenStart, end: screenEnd }, }; } this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); } } finally { this.runtime.free(selection, selectionLayout.size); this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); this.runtime.free(options, optionsLayout.size); } return range; } private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate"); const coordinate = this.runtime.alloc(coordinateLayout.size); const result = this.runtime.call( "ghostty_terminal_point_from_grid_ref", this.terminal, ref, tag, coordinate, ); const point = result === GHOSTTY_SUCCESS ? { x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), } : null; this.runtime.free(coordinate, coordinateLayout.size); return point; } private getU16(data: number): number { this.runtime.bytes(this.scratch, 2).fill(0); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); return this.runtime.view(this.scratch, 2).getUint16(0, true); } private getU32(data: number): number { this.runtime.bytes(this.scratch, 4).fill(0); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); return this.runtime.view(this.scratch, 4).getUint32(0, true); } private getBool(data: number): boolean { this.runtime.bytes(this.scratch, 1)[0] = 0; this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); return this.runtime.bytes(this.scratch, 1)[0] !== 0; } private getColor(data: number, fallback: GhosttyColor): GhosttyColor { this.runtime.bytes(this.scratch, 3).fill(0); const result = this.runtime.call( "ghostty_render_state_get", this.renderState, data, this.scratch, ); return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; } private getRowBool(iterator: number, data: number): boolean { this.runtime.bytes(this.scratch, 1)[0] = 0; return ( this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 ); } private getCellU32(iterator: number, data: number): number { this.runtime.bytes(this.scratch, 4).fill(0); const result = this.runtime.call( "ghostty_render_state_row_cells_get", iterator, data, this.scratch, ); return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; } private getCellBool(iterator: number, data: number): boolean { this.runtime.bytes(this.scratch, 1)[0] = 0; return ( this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) === GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 ); } private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { this.runtime.bytes(this.scratch, 3).fill(0); const result = this.runtime.call( "ghostty_render_state_row_cells_get", iterator, data, this.scratch, ); return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; } private readColor(pointer: number): GhosttyColor { const bytes = this.runtime.bytes(pointer, 3); return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; } private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { return { text: "", wide: 0, foreground, background, bold: false, italic: false, invisible: false, strikethrough: false, overline: false, underline: false, selected: false, }; } private assertSuccess(operation: string, result: number): void { if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`); } private ensureActive(): void { if (this.disposed) throw new Error("libghostty-vt terminal has been disposed"); } } export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean { return sameColor(left, right); }