1207 lines · 42.7 KB · TypeScript
RawDownload
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<number>;  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<GhosttyTerminalCore> {    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 "";

Lines 1–500 of 1207.